diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/home/HomeActivity.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/home/HomeActivity.kt index ea789fa83f8..a396053633e 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/home/HomeActivity.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/home/HomeActivity.kt @@ -153,7 +153,7 @@ class HomeActivity : } } launch { mainViewModel.entityRegistryUpdates() } - if (!mainViewModel.isFavoritesOnly) { + if (!mainViewModel.isFavoritesOnly.value) { launch { mainViewModel.areaUpdates() } launch { mainViewModel.deviceUpdates() } } diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/home/MainViewModel.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/home/MainViewModel.kt index 3f28512fe96..089a72cc5fe 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/home/MainViewModel.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/home/MainViewModel.kt @@ -4,11 +4,9 @@ import android.app.Application import android.content.ComponentName import android.content.pm.PackageManager import androidx.compose.runtime.State -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.core.app.NotificationManagerCompat @@ -66,6 +64,33 @@ class MainViewModel @Inject constructor( ERROR, } + /** + * Holds entity classification information for filtering entities in the UI. + */ + data class EntityClassification( + val entitiesWithoutArea: Set = emptySet(), + val entitiesWithCategory: Set = emptySet(), + val entitiesHidden: Set = emptySet(), + val hasAreasToShow: Boolean = false, + val hasMoreEntitiesToShow: Boolean = false, + ) + + /** + * Immutable UI state for MainView that contains thread-safe snapshots of all data. + */ + data class MainViewUiState( + val entities: Map = emptyMap(), + val favoriteCaches: List = emptyList(), + val isFavoritesOnly: Boolean = false, + val loadingState: LoadingState = LoadingState.LOADING, + val entitiesByAreaOrder: List = emptyList(), + val entitiesByArea: Map> = emptyMap(), + val areas: List = emptyList(), + val entitiesByDomainFilteredOrder: List = emptyList(), + val entitiesByDomainFiltered: Map> = emptyMap(), + val entitiesByDomain: Map> = emptyMap(), + ) + private val app = application private lateinit var homePresenter: HomePresenter @@ -87,6 +112,12 @@ class MainViewModel @Inject constructor( private val _supportedEntities = MutableStateFlow(emptyList()) val supportedEntities = _supportedEntities.asStateFlow() + private val _entityClassification = MutableStateFlow(EntityClassification()) + val entityClassification = _entityClassification.asStateFlow() + + private val _mainViewUiState = MutableStateFlow(MainViewUiState()) + val mainViewUiState = _mainViewUiState.asStateFlow() + /** * IDs of favorites in the Favorites database. */ @@ -116,6 +147,15 @@ class MainViewModel @Inject constructor( var entitiesByDomainOrder = mutableStateListOf() private set + /** + * Filtered entities by domain - only entities without area, category, or hidden status. + * Used for the "More Entities" section in the UI. + */ + var entitiesByDomainFiltered = mutableStateMapOf>() + private set + var entitiesByDomainFilteredOrder = mutableStateListOf() + private set + // Content of EntityListView var entityLists = mutableStateMapOf>() var entityListsOrder = mutableStateListOf() @@ -132,11 +172,11 @@ class MainViewModel @Inject constructor( private set var templateTiles = mutableStateMapOf() private set - var isFavoritesOnly by mutableStateOf(false) + var isFavoritesOnly = mutableStateOf(false) private set - var isAssistantAppAllowed by mutableStateOf(true) + var isAssistantAppAllowed = mutableStateOf(true) private set - var areNotificationsAllowed by mutableStateOf(false) + var areNotificationsAllowed = mutableStateOf(false) private set init { @@ -165,13 +205,13 @@ class MainViewModel @Inject constructor( isShowShortcutTextEnabled.value = homePresenter.getShowShortcutText() templateTiles.clear() templateTiles.putAll(homePresenter.getAllTemplateTiles()) - isFavoritesOnly = homePresenter.getWearFavoritesOnly() + isFavoritesOnly.value = homePresenter.getWearFavoritesOnly() val assistantAppComponent = ComponentName( BuildConfig.APPLICATION_ID, "io.homeassistant.companion.android.conversation.AssistantActivity", ) - isAssistantAppAllowed = + isAssistantAppAllowed.value = app.packageManager.getComponentEnabledSetting(assistantAppComponent) != PackageManager.COMPONENT_ENABLED_STATE_DISABLED @@ -215,9 +255,11 @@ class MainViewModel @Inject constructor( } else { LoadingState.ERROR } + updateMainViewUiState() } catch (e: Exception) { Timber.e(e, "Exception while loading entities") loadingState.value = LoadingState.ERROR + updateMainViewUiState() } } } @@ -239,7 +281,7 @@ class MainViewModel @Inject constructor( val getEntityRegistry = async { homePresenter.getEntityRegistry() } val getEntities = async { homePresenter.getEntities() } - if (!isFavoritesOnly) { + if (!isFavoritesOnly.value) { areaRegistry = getAreaRegistry.await()?.also { areas.clear() areas.addAll(it) @@ -260,8 +302,10 @@ class MainViewModel @Inject constructor( val climateEntities = it.filter { entity -> entity.domain == "climate" } climateEntitiesMap["climate"] = mutableStateListOf().apply { addAll(climateEntities) } } - if (!isFavoritesOnly) { + if (!isFavoritesOnly.value) { updateEntityDomains() + } else { + updateMainViewUiState() } } @@ -271,14 +315,14 @@ class MainViewModel @Inject constructor( } homePresenter.getEntityUpdates(supportedEntities.value)?.collect { updateEntityStates(it) - if (!isFavoritesOnly) { + if (!isFavoritesOnly.value) { updateEntityDomains() } } } suspend fun areaUpdates() { - if (!homePresenter.isConnected() || isFavoritesOnly) { + if (!homePresenter.isConnected() || isFavoritesOnly.value) { return } homePresenter.getAreaRegistryUpdates()?.throttleLatest(1000)?.collect { @@ -292,7 +336,7 @@ class MainViewModel @Inject constructor( } suspend fun deviceUpdates() { - if (!homePresenter.isConnected() || isFavoritesOnly) { + if (!homePresenter.isConnected() || isFavoritesOnly.value) { return } homePresenter.getDeviceRegistryUpdates()?.throttleLatest(1000)?.collect { @@ -317,48 +361,174 @@ class MainViewModel @Inject constructor( .map { it.entityId } .filter { it.split(".")[0] in supportedDomains() } - private fun updateEntityDomains() { + /** + * Updates the main view UI state with thread-safe snapshots of all data. + * This should be called on a background thread whenever state changes. + */ + private fun updateMainViewUiState() { + Timber.e("Hello doing an update? ") + _mainViewUiState.value = MainViewUiState( + entities = entities.toMap(), + favoriteCaches = favoriteCaches.toList(), + isFavoritesOnly = isFavoritesOnly.value, + loadingState = loadingState.value, + entitiesByAreaOrder = entitiesByAreaOrder.toList(), + entitiesByArea = entitiesByArea.mapValues { it.value.toList() }, + areas = areas.toList(), + entitiesByDomainFilteredOrder = entitiesByDomainFilteredOrder.toList(), + entitiesByDomainFiltered = entitiesByDomainFiltered.mapValues { it.value.toList() }, + entitiesByDomain = entitiesByDomain.mapValues { it.value.toList() }, + ) + } + + /** + * This function does a lot of manipulation and could take some time so we need + * to make sure it doesn't happen in the Main thread. + */ + private suspend fun updateEntityDomains() = withContext(Dispatchers.Default) { val entitiesList = entities.values.toList().sortedBy { it.entityId } val areasList = areaRegistry.orEmpty().sortedBy { it.name } val domainsList = entitiesList.map { it.domain }.distinct() + val validAreaIds = areasList.map { it.areaId }.toSet() + + // Single pass: compute entity metadata and cache area lookups to avoid redundant calls + val entityAreaMap = mutableMapOf() + val withoutArea = mutableSetOf() + val withCategory = mutableSetOf() + val hidden = mutableSetOf() + + entities.keys.forEach { entityId -> + val area = getAreaForEntity(entityId) + entityAreaMap[entityId] = area + + if (area == null) { + withoutArea.add(entityId) + } + if (getCategoryForEntity(entityId) != null) { + withCategory.add(entityId) + } + if (getHiddenByForEntity(entityId) != null) { + hidden.add(entityId) + } + } + + // Determine if entity should be shown in filtered views + val shouldShowEntity: (String) -> Boolean = { entityId -> + entityId !in withCategory && entityId !in hidden + } + + // Group entities by area using cached area lookups + updateEntitiesByArea(areasList, entitiesList, entityAreaMap) + + // Remove areas that no longer exist + entitiesByArea.keys.toList().forEach { areaId -> + if (areaId !in validAreaIds) { + entitiesByArea.remove(areaId) + } + } - // Create a list with all areas + their entities + // Group entities by domain (both full and filtered) in a single pass + updateEntitiesByDomain(domainsList, entitiesList, withoutArea, withCategory, hidden) + + // Compute UI visibility flags + val hasAreasToShow = entitiesByArea.values.any { areaEntities -> + areaEntities.any { entity -> shouldShowEntity(entity.entityId) } + } + + val hasMoreEntitiesToShow = withoutArea.any(shouldShowEntity) + + // Update entity classification with all computed values + _entityClassification.value = EntityClassification( + entitiesWithoutArea = withoutArea, + entitiesWithCategory = withCategory, + entitiesHidden = hidden, + hasAreasToShow = hasAreasToShow, + hasMoreEntitiesToShow = hasMoreEntitiesToShow, + ) + + // Update the main view UI state with snapshots + updateMainViewUiState() + } + + /** + * Updates the entities grouped by area. + */ + private fun updateEntitiesByArea( + areasList: List, + entitiesList: List, + entityAreaMap: Map, + ) { areasList.forEach { area -> - val entitiesInArea = mutableStateListOf() - entitiesInArea.addAll( - entitiesList - .filter { getAreaForEntity(it.entityId)?.areaId == area.areaId } - .sortedBy { (it.attributes["friendly_name"] ?: it.entityId) as String }, - ) + val entitiesInArea = entitiesList + .filter { entityAreaMap[it.entityId]?.areaId == area.areaId } + .sortedBy { (it.attributes["friendly_name"] ?: it.entityId) as String } + entitiesByArea[area.areaId]?.let { it.clear() it.addAll(entitiesInArea) } ?: run { - entitiesByArea[area.areaId] = entitiesInArea + entitiesByArea[area.areaId] = mutableStateListOf().apply { addAll(entitiesInArea) } } } + entitiesByAreaOrder.clear() entitiesByAreaOrder.addAll(areasList.map { it.areaId }) - // Quick check: are there any areas in the list that no longer exist? - entitiesByArea.forEach { - if (!areasList.any { item -> item.areaId == it.key }) { - entitiesByArea.remove(it.key) - } - } + } + + /** + * Updates entities grouped by domain (both full and filtered). + */ + private fun updateEntitiesByDomain( + domainsList: List, + entitiesList: List, + withoutArea: Set, + withCategory: Set, + hidden: Set, + ) { + val filteredDomainsList = mutableListOf() - // Create a list with all discovered domains + their entities domainsList.forEach { domain -> - val entitiesInDomain = mutableStateListOf() - entitiesInDomain.addAll(entitiesList.filter { it.domain == domain }) + // All entities in domain + val entitiesInDomain = entitiesList.filter { it.domain == domain } + entitiesByDomain[domain]?.let { it.clear() it.addAll(entitiesInDomain) } ?: run { - entitiesByDomain[domain] = entitiesInDomain + entitiesByDomain[domain] = mutableStateListOf().apply { addAll(entitiesInDomain) } + } + + // Filtered entities (without area, category, or hidden status) + val entitiesInDomainFiltered = entitiesInDomain.filter { entity -> + entity.entityId in withoutArea && + entity.entityId !in withCategory && + entity.entityId !in hidden + } + + if (entitiesInDomainFiltered.isNotEmpty()) { + filteredDomainsList.add(domain) + entitiesByDomainFiltered[domain]?.let { + it.clear() + it.addAll(entitiesInDomainFiltered) + } ?: run { + entitiesByDomainFiltered[domain] = + mutableStateListOf().apply { addAll(entitiesInDomainFiltered) } + } } } + entitiesByDomainOrder.clear() entitiesByDomainOrder.addAll(domainsList) + + // Remove domains that no longer have filtered entities + entitiesByDomainFiltered.keys.toList().forEach { domain -> + if (domain !in filteredDomainsList) { + entitiesByDomainFiltered.remove(domain) + } + } + + entitiesByDomainFilteredOrder.clear() + entitiesByDomainFilteredOrder.addAll(filteredDomainsList) } fun toggleEntity(entityId: String, state: String) { @@ -528,7 +698,7 @@ class MainViewModel @Inject constructor( fun setWearFavoritesOnly(enabled: Boolean) { viewModelScope.launch { homePresenter.setWearFavoritesOnly(enabled) - isFavoritesOnly = enabled + isFavoritesOnly.value = enabled } } @@ -553,7 +723,7 @@ class MainViewModel @Inject constructor( favoritesDao.delete(entityId) favoriteCachesDao.delete(entityId) - if (favoritesDao.getAll().isEmpty() && isFavoritesOnly) { + if (favoritesDao.getAll().isEmpty() && isFavoritesOnly.value) { setWearFavoritesOnly(false) } } @@ -583,11 +753,11 @@ class MainViewModel @Inject constructor( }, PackageManager.DONT_KILL_APP, ) - isAssistantAppAllowed = allowed + isAssistantAppAllowed.value = allowed } fun refreshNotificationPermission() { - areNotificationsAllowed = NotificationManagerCompat.from(app).areNotificationsEnabled() + areNotificationsAllowed.value = NotificationManagerCompat.from(app).areNotificationsEnabled() } fun logout() { diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/HomeView.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/HomeView.kt index 7987d456fd2..668994acdab 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/HomeView.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/HomeView.kt @@ -172,9 +172,9 @@ fun LoadHomePage(mainViewModel: MainViewModel) { onClickLogout = { mainViewModel.logout() }, isHapticEnabled = mainViewModel.isHapticEnabled.value, isToastEnabled = mainViewModel.isToastEnabled.value, - isFavoritesOnly = mainViewModel.isFavoritesOnly, - isAssistantAppAllowed = mainViewModel.isAssistantAppAllowed, - areNotificationsAllowed = mainViewModel.areNotificationsAllowed, + isFavoritesOnly = mainViewModel.isFavoritesOnly.value, + isAssistantAppAllowed = mainViewModel.isAssistantAppAllowed.value, + areNotificationsAllowed = mainViewModel.areNotificationsAllowed.value, onHapticEnabled = { mainViewModel.setHapticEnabled(it) }, onToastEnabled = { mainViewModel.setToastEnabled(it) }, setFavoritesOnly = { mainViewModel.setWearFavoritesOnly(it) }, diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/MainView.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/MainView.kt index 020df633b58..c4372497c83 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/MainView.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/home/views/MainView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.CircularProgressIndicator import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.ButtonDefaults @@ -66,6 +67,14 @@ fun MainView( val haptic = LocalHapticFeedback.current val context = LocalContext.current + // Collect UI state from ViewModel - single source of truth with thread-safe snapshots + val uiState by mainViewModel.mainViewUiState.collectAsStateWithLifecycle() + val entityClassification by mainViewModel.entityClassification.collectAsStateWithLifecycle() + val entitiesWithCategory = entityClassification.entitiesWithCategory + val entitiesHidden = entityClassification.entitiesHidden + val hasAreasToShow = entityClassification.hasAreasToShow + val hasMoreEntitiesToShow = entityClassification.hasMoreEntitiesToShow + WearAppTheme { ThemeLazyColumn { if (favoriteEntityIds.isNotEmpty()) { @@ -79,9 +88,9 @@ fun MainView( if (expandedFavorites) { items(favoriteEntityIds.size) { index -> val favoriteEntityID = favoriteEntityIds[index].split(",")[0] - if (mainViewModel.entities.isEmpty()) { + if (uiState.entities.isEmpty()) { // when we don't have the state of the entity, create a Chip from cache as we don't have the state yet - val cached = mainViewModel.favoriteCaches.find { it.id == favoriteEntityID } + val cached = uiState.favoriteCaches.find { it.id == favoriteEntityID } Button( modifier = Modifier .fillMaxWidth(), @@ -111,23 +120,21 @@ fun MainView( colors = getFilledTonalButtonColors(), ) } else { - mainViewModel.entities.values.toList() - .firstOrNull { it.entityId == favoriteEntityID } - ?.let { - EntityUi( - mainViewModel.entities[favoriteEntityID]!!, - onEntityClicked, - isHapticEnabled, - isToastEnabled, - ) { entityId -> onEntityLongClicked(entityId) } - } + uiState.entities[favoriteEntityID]?.let { + EntityUi( + it, + onEntityClicked, + isHapticEnabled, + isToastEnabled, + ) { entityId -> onEntityLongClicked(entityId) } + } } } } } - if (!mainViewModel.isFavoritesOnly) { - when (mainViewModel.loadingState.value) { + if (!uiState.isFavoritesOnly) { + when (uiState.loadingState) { MainViewModel.LoadingState.LOADING -> { if (favoriteEntityIds.isEmpty()) { // Add a Spacer to prevent settings being pushed to the screen center @@ -177,7 +184,7 @@ fun MainView( } } MainViewModel.LoadingState.READY -> { - if (mainViewModel.entities.isEmpty()) { + if (uiState.entities.isEmpty()) { item { Column( modifier = Modifier.fillMaxSize(), @@ -204,65 +211,51 @@ fun MainView( } } - if ( - mainViewModel.entitiesByArea.values.any { - it.isNotEmpty() && - it.any { entity -> - mainViewModel.getCategoryForEntity(entity.entityId) == null && - mainViewModel.getHiddenByForEntity(entity.entityId) == null - } - } - ) { + if (hasAreasToShow) { item { ListHeader(id = commonR.string.areas) } - for (id in mainViewModel.entitiesByAreaOrder) { - val entities = mainViewModel.entitiesByArea[id] - val entitiesToShow = entities?.filter { - mainViewModel.getCategoryForEntity(it.entityId) == null && - mainViewModel.getHiddenByForEntity(it.entityId) == null + for (id in uiState.entitiesByAreaOrder) { + val areaEntities = uiState.entitiesByArea[id] + val entitiesToShow = areaEntities?.filter { + it.entityId !in entitiesWithCategory && + it.entityId !in entitiesHidden } if (!entitiesToShow.isNullOrEmpty()) { - val area = mainViewModel.areas.first { it.areaId == id } - item { - Button( - modifier = Modifier.fillMaxWidth(), - label = { Text(area.name) }, - onClick = { - onNavigationClicked( - mapOf(area.name to entities), - listOf(area.name), - ) { - mainViewModel.getCategoryForEntity(it.entityId) == null && - mainViewModel.getHiddenByForEntity( - it.entityId, - ) == null - } - }, - colors = getPrimaryButtonColors(), - ) + val area = uiState.areas.firstOrNull { it.areaId == id } + if (area != null) { + item { + Button( + modifier = Modifier.fillMaxWidth(), + label = { Text(area.name) }, + onClick = { + onNavigationClicked( + mapOf(area.name to areaEntities), + listOf(area.name), + ) { + it.entityId !in entitiesWithCategory && + it.entityId !in entitiesHidden + } + }, + colors = getPrimaryButtonColors(), + ) + } } } } } - val domainEntitiesFilter: (entity: Entity) -> Boolean = - { - mainViewModel.getAreaForEntity(it.entityId) == null && - mainViewModel.getCategoryForEntity(it.entityId) == null && - mainViewModel.getHiddenByForEntity(it.entityId) == null - } - if (mainViewModel.entities.values.any(domainEntitiesFilter)) { + if (hasMoreEntitiesToShow) { item { ListHeader(id = commonR.string.more_entities) } } - // Buttons for each existing category - for (domain in mainViewModel.entitiesByDomainOrder) { - val domainEntities = mainViewModel.entitiesByDomain[domain]!! - val domainEntitiesToShow = - domainEntities.filter(domainEntitiesFilter) - if (domainEntitiesToShow.isNotEmpty()) { + + // Buttons for each domain with filtered entities + for (domain in uiState.entitiesByDomainFilteredOrder) { + val domainEntitiesFiltered = uiState.entitiesByDomainFiltered[domain] + val domainName = mainViewModel.stringForDomain(domain) + if (domainEntitiesFiltered != null && domainName != null) { item { Button( modifier = Modifier.fillMaxWidth(), @@ -273,15 +266,12 @@ fun MainView( context, ).let { Image(asset = it) } }, - label = { Text(mainViewModel.stringForDomain(domain)!!) }, + label = { Text(domainName) }, onClick = { onNavigationClicked( - mapOf( - mainViewModel.stringForDomain(domain)!! to domainEntities, - ), - listOf(mainViewModel.stringForDomain(domain)!!), - domainEntitiesFilter, - ) + mapOf(domainName to domainEntitiesFiltered), + listOf(domainName), + ) { true } }, colors = getPrimaryButtonColors(), ) @@ -293,7 +283,7 @@ fun MainView( Spacer(modifier = Modifier.height(32.dp)) } // All entities regardless of area - if (mainViewModel.entities.isNotEmpty()) { + if (uiState.entities.isNotEmpty()) { item { Button( modifier = Modifier @@ -309,12 +299,12 @@ fun MainView( }, onClick = { onNavigationClicked( - mainViewModel.entitiesByDomain.mapKeys { + uiState.entitiesByDomain.mapKeys { mainViewModel.stringForDomain( it.key, )!! }, - mainViewModel.entitiesByDomain.keys.map { + uiState.entitiesByDomain.keys.map { mainViewModel.stringForDomain( it, )!! @@ -329,7 +319,7 @@ fun MainView( } } - if (mainViewModel.isFavoritesOnly) { + if (uiState.isFavoritesOnly) { item { Spacer(Modifier.padding(32.dp)) }