Skip to content

Commit c6fc53a

Browse files
authored
Move complex logic out of MainView into MainViewModel using coroutine (#6036)
* Use a single source of truth for the UI
1 parent 6b497e6 commit c6fc53a

File tree

4 files changed

+277
-102
lines changed

4 files changed

+277
-102
lines changed

wear/src/main/kotlin/io/homeassistant/companion/android/home/HomeActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ class HomeActivity :
170170
mainViewModel.initAllSensors()
171171

172172
lifecycleScope.launch {
173-
if (mainViewModel.loadingState.value == MainViewModel.LoadingState.READY) {
173+
if (mainViewModel.loadingState == MainViewModel.LoadingState.READY) {
174174
try {
175175
mainViewModel.updateUI()
176176
} catch (e: CancellationException) {

wear/src/main/kotlin/io/homeassistant/companion/android/home/MainViewModel.kt

Lines changed: 197 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,33 @@ class MainViewModel @Inject constructor(
6565
ERROR,
6666
}
6767

68+
/**
69+
* Holds entity classification information for filtering entities in the UI.
70+
*/
71+
data class EntityClassification(
72+
val entitiesWithoutArea: Set<String> = emptySet(),
73+
val entitiesWithCategory: Set<String> = emptySet(),
74+
val entitiesHidden: Set<String> = emptySet(),
75+
val hasAreasToShow: Boolean = false,
76+
val hasMoreEntitiesToShow: Boolean = false,
77+
)
78+
79+
/**
80+
* Immutable UI state for MainView that contains thread-safe snapshots of all data.
81+
*/
82+
data class MainViewUiState(
83+
val entities: Map<String, Entity> = emptyMap(),
84+
val favoriteCaches: List<FavoriteCaches> = emptyList(),
85+
val isFavoritesOnly: Boolean = false,
86+
val loadingState: LoadingState = LoadingState.LOADING,
87+
val entitiesByAreaOrder: List<String> = emptyList(),
88+
val entitiesByArea: Map<String, List<String>> = emptyMap(),
89+
val areas: List<AreaRegistryResponse> = emptyList(),
90+
val entitiesByDomainFilteredOrder: List<String> = emptyList(),
91+
val entitiesByDomainFiltered: Map<String, List<String>> = emptyMap(),
92+
val entitiesByDomain: Map<String, List<String>> = emptyMap(),
93+
)
94+
6895
private val app = application
6996

7097
private lateinit var homePresenter: HomePresenter
@@ -86,6 +113,12 @@ class MainViewModel @Inject constructor(
86113
private val _supportedEntities = MutableStateFlow(emptyList<String>())
87114
val supportedEntities = _supportedEntities.asStateFlow()
88115

116+
private val _entityClassification = MutableStateFlow(EntityClassification())
117+
val entityClassification = _entityClassification.asStateFlow()
118+
119+
private val _mainViewUiState = MutableStateFlow(MainViewUiState())
120+
val mainViewUiState = _mainViewUiState.asStateFlow()
121+
89122
/**
90123
* IDs of favorites in the Favorites database.
91124
*/
@@ -115,13 +148,22 @@ class MainViewModel @Inject constructor(
115148
var entitiesByDomainOrder = mutableStateListOf<String>()
116149
private set
117150

151+
/**
152+
* Filtered entities by domain - only entities without area, category, or hidden status.
153+
* Used for the "More Entities" section in the UI.
154+
*/
155+
var entitiesByDomainFiltered = mutableStateMapOf<String, SnapshotStateList<Entity>>()
156+
private set
157+
var entitiesByDomainFilteredOrder = mutableStateListOf<String>()
158+
private set
159+
118160
// Content of EntityListView
119-
var entityLists = mutableStateMapOf<String, List<Entity>>()
161+
var entityListIds = mutableStateMapOf<String, List<String>>()
120162
var entityListsOrder = mutableStateListOf<String>()
121163
var entityListFilter: (Entity) -> Boolean = { true }
122164

123165
// settings
124-
var loadingState = mutableStateOf(LoadingState.LOADING)
166+
var loadingState by mutableStateOf(LoadingState.LOADING)
125167
private set
126168
var isHapticEnabled = mutableStateOf(false)
127169
private set
@@ -200,7 +242,7 @@ class MainViewModel @Inject constructor(
200242
if (!homePresenter.isConnected()) return@launch
201243
try {
202244
// Load initial state
203-
loadingState.value = LoadingState.LOADING
245+
loadingState = LoadingState.LOADING
204246
updateUI()
205247

206248
// Finished initial load, update state
@@ -209,14 +251,16 @@ class MainViewModel @Inject constructor(
209251
homePresenter.onInvalidAuthorization()
210252
return@launch
211253
}
212-
loadingState.value = if (webSocketState == WebSocketState.Active) {
254+
loadingState = if (webSocketState == WebSocketState.Active) {
213255
LoadingState.READY
214256
} else {
215257
LoadingState.ERROR
216258
}
259+
updateMainViewUiState()
217260
} catch (e: Exception) {
218261
Timber.e(e, "Exception while loading entities")
219-
loadingState.value = LoadingState.ERROR
262+
loadingState = LoadingState.ERROR
263+
updateMainViewUiState()
220264
}
221265
}
222266
}
@@ -261,6 +305,8 @@ class MainViewModel @Inject constructor(
261305
}
262306
if (!isFavoritesOnly) {
263307
updateEntityDomains()
308+
} else {
309+
updateMainViewUiState()
264310
}
265311
}
266312

@@ -316,48 +362,175 @@ class MainViewModel @Inject constructor(
316362
.map { it.entityId }
317363
.filter { it.split(".")[0] in supportedDomains() }
318364

319-
private fun updateEntityDomains() {
365+
/**
366+
* Updates the main view UI state with thread-safe snapshots of all data.
367+
* This should be called on a background thread whenever state changes.
368+
*/
369+
private fun updateMainViewUiState() {
370+
_mainViewUiState.value = MainViewUiState(
371+
entities = entities.toMap(),
372+
favoriteCaches = favoriteCaches.toList(),
373+
isFavoritesOnly = isFavoritesOnly,
374+
loadingState = loadingState,
375+
entitiesByAreaOrder = entitiesByAreaOrder.toList(),
376+
entitiesByArea = entitiesByArea.mapValues { (_, entities) -> entities.map { it.entityId } },
377+
areas = areas.toList(),
378+
entitiesByDomainFilteredOrder = entitiesByDomainFilteredOrder.toList(),
379+
entitiesByDomainFiltered = entitiesByDomainFiltered.mapValues { (_, entities) ->
380+
entities.map { it.entityId }
381+
},
382+
entitiesByDomain = entitiesByDomain.mapValues { (_, entities) -> entities.map { it.entityId } },
383+
)
384+
}
385+
386+
/**
387+
* This function does a lot of manipulation and could take some time so we need
388+
* to make sure it doesn't happen in the Main thread.
389+
*/
390+
private suspend fun updateEntityDomains() = withContext(Dispatchers.Default) {
320391
val entitiesList = entities.values.toList().sortedBy { it.entityId }
321392
val areasList = areaRegistry.orEmpty().sortedBy { it.name }
322393
val domainsList = entitiesList.map { it.domain }.distinct()
394+
val validAreaIds = areasList.map { it.areaId }.toSet()
395+
396+
// Single pass: compute entity metadata and cache area lookups to avoid redundant calls
397+
val entityAreaMap = mutableMapOf<String, AreaRegistryResponse?>()
398+
val withoutArea = mutableSetOf<String>()
399+
val withCategory = mutableSetOf<String>()
400+
val hidden = mutableSetOf<String>()
401+
402+
entities.keys.forEach { entityId ->
403+
val area = getAreaForEntity(entityId)
404+
entityAreaMap[entityId] = area
405+
406+
if (area == null) {
407+
withoutArea.add(entityId)
408+
}
409+
if (getCategoryForEntity(entityId) != null) {
410+
withCategory.add(entityId)
411+
}
412+
if (getHiddenByForEntity(entityId) != null) {
413+
hidden.add(entityId)
414+
}
415+
}
416+
417+
// Determine if entity should be shown in filtered views
418+
val shouldShowEntity: (String) -> Boolean = { entityId ->
419+
entityId !in withCategory && entityId !in hidden
420+
}
421+
422+
// Group entities by area using cached area lookups
423+
updateEntitiesByArea(areasList, entitiesList, entityAreaMap)
424+
425+
// Remove areas that no longer exist
426+
entitiesByArea.keys.toList().forEach { areaId ->
427+
if (areaId !in validAreaIds) {
428+
entitiesByArea.remove(areaId)
429+
}
430+
}
431+
432+
// Group entities by domain (both full and filtered) in a single pass
433+
updateEntitiesByDomain(domainsList, entitiesList, withoutArea, withCategory, hidden)
434+
435+
// Compute UI visibility flags
436+
val hasAreasToShow = entitiesByArea.values.any { areaEntities ->
437+
areaEntities.any { entity -> shouldShowEntity(entity.entityId) }
438+
}
439+
440+
val hasMoreEntitiesToShow = withoutArea.any(shouldShowEntity)
441+
442+
// Update entity classification with all computed values
443+
_entityClassification.value = EntityClassification(
444+
entitiesWithoutArea = withoutArea,
445+
entitiesWithCategory = withCategory,
446+
entitiesHidden = hidden,
447+
hasAreasToShow = hasAreasToShow,
448+
hasMoreEntitiesToShow = hasMoreEntitiesToShow,
449+
)
450+
451+
// Update the main view UI state with snapshots
452+
updateMainViewUiState()
453+
}
323454

324-
// Create a list with all areas + their entities
455+
/**
456+
* Updates the entities grouped by area.
457+
*/
458+
private fun updateEntitiesByArea(
459+
areasList: List<AreaRegistryResponse>,
460+
entitiesList: List<Entity>,
461+
entityAreaMap: Map<String, AreaRegistryResponse?>,
462+
) {
325463
areasList.forEach { area ->
326-
val entitiesInArea = mutableStateListOf<Entity>()
327-
entitiesInArea.addAll(
328-
entitiesList
329-
.filter { getAreaForEntity(it.entityId)?.areaId == area.areaId }
330-
.sortedBy { (it.attributes["friendly_name"] ?: it.entityId) as String },
331-
)
464+
val entitiesInArea = entitiesList
465+
.filter { entityAreaMap[it.entityId]?.areaId == area.areaId }
466+
.sortedBy { (it.attributes["friendly_name"] ?: it.entityId) as String }
467+
332468
entitiesByArea[area.areaId]?.let {
333469
it.clear()
334470
it.addAll(entitiesInArea)
335471
} ?: run {
336-
entitiesByArea[area.areaId] = entitiesInArea
472+
entitiesByArea[area.areaId] = mutableStateListOf<Entity>().apply { addAll(entitiesInArea) }
337473
}
338474
}
475+
339476
entitiesByAreaOrder.clear()
340477
entitiesByAreaOrder.addAll(areasList.map { it.areaId })
341-
// Quick check: are there any areas in the list that no longer exist?
342-
entitiesByArea.forEach {
343-
if (!areasList.any { item -> item.areaId == it.key }) {
344-
entitiesByArea.remove(it.key)
345-
}
346-
}
478+
}
479+
480+
/**
481+
* Updates entities grouped by domain (both full and filtered).
482+
*/
483+
private fun updateEntitiesByDomain(
484+
domainsList: List<String>,
485+
entitiesList: List<Entity>,
486+
withoutArea: Set<String>,
487+
withCategory: Set<String>,
488+
hidden: Set<String>,
489+
) {
490+
val filteredDomainsList = mutableListOf<String>()
347491

348-
// Create a list with all discovered domains + their entities
349492
domainsList.forEach { domain ->
350-
val entitiesInDomain = mutableStateListOf<Entity>()
351-
entitiesInDomain.addAll(entitiesList.filter { it.domain == domain })
493+
// All entities in domain
494+
val entitiesInDomain = entitiesList.filter { it.domain == domain }
495+
352496
entitiesByDomain[domain]?.let {
353497
it.clear()
354498
it.addAll(entitiesInDomain)
355499
} ?: run {
356-
entitiesByDomain[domain] = entitiesInDomain
500+
entitiesByDomain[domain] = mutableStateListOf<Entity>().apply { addAll(entitiesInDomain) }
501+
}
502+
503+
// Filtered entities (without area, category, or hidden status)
504+
val entitiesInDomainFiltered = entitiesInDomain.filter { entity ->
505+
entity.entityId in withoutArea &&
506+
entity.entityId !in withCategory &&
507+
entity.entityId !in hidden
508+
}
509+
510+
if (entitiesInDomainFiltered.isNotEmpty()) {
511+
filteredDomainsList.add(domain)
512+
entitiesByDomainFiltered[domain]?.let {
513+
it.clear()
514+
it.addAll(entitiesInDomainFiltered)
515+
} ?: run {
516+
entitiesByDomainFiltered[domain] =
517+
mutableStateListOf<Entity>().apply { addAll(entitiesInDomainFiltered) }
518+
}
357519
}
358520
}
521+
359522
entitiesByDomainOrder.clear()
360523
entitiesByDomainOrder.addAll(domainsList)
524+
525+
// Remove domains that no longer have filtered entities
526+
entitiesByDomainFiltered.keys.toList().forEach { domain ->
527+
if (domain !in filteredDomainsList) {
528+
entitiesByDomainFiltered.remove(domain)
529+
}
530+
}
531+
532+
entitiesByDomainFilteredOrder.clear()
533+
entitiesByDomainFilteredOrder.addAll(filteredDomainsList)
361534
}
362535

363536
fun toggleEntity(entityId: String, state: String) {

wear/src/main/kotlin/io/homeassistant/companion/android/home/views/HomeView.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import android.provider.Settings
55
import androidx.activity.compose.rememberLauncherForActivityResult
66
import androidx.activity.result.contract.ActivityResultContracts
77
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.derivedStateOf
9+
import androidx.compose.runtime.getValue
810
import androidx.compose.runtime.mutableStateListOf
911
import androidx.compose.runtime.mutableStateOf
1012
import androidx.compose.runtime.remember
@@ -85,9 +87,9 @@ fun LoadHomePage(mainViewModel: MainViewModel) {
8587
},
8688
onRetryLoadEntitiesClicked = mainViewModel::loadEntities,
8789
onSettingsClicked = { swipeDismissableNavController.navigate(SCREEN_SETTINGS) },
88-
onNavigationClicked = { lists, order, filter ->
89-
mainViewModel.entityLists.clear()
90-
mainViewModel.entityLists.putAll(lists)
90+
onNavigationClicked = { entityIdLists, order, filter ->
91+
mainViewModel.entityListIds.clear()
92+
mainViewModel.entityListIds.putAll(entityIdLists)
9193
mainViewModel.entityListsOrder.clear()
9294
mainViewModel.entityListsOrder.addAll(order)
9395
mainViewModel.entityListFilter = filter
@@ -130,8 +132,18 @@ fun LoadHomePage(mainViewModel: MainViewModel) {
130132
}
131133
}
132134
composable(SCREEN_ENTITY_LIST) {
135+
// Build entity lists by looking up entities from the live entities map
136+
// This ensures real-time state updates when entities change
137+
// Using derivedStateOf to only recompute when entityListIds or entities change
138+
val entityLists by remember {
139+
derivedStateOf {
140+
mainViewModel.entityListIds.mapValues { (_, entityIds) ->
141+
entityIds.mapNotNull { entityId -> mainViewModel.entities[entityId] }
142+
}
143+
}
144+
}
133145
EntityViewList(
134-
entityLists = mainViewModel.entityLists,
146+
entityLists = entityLists,
135147
entityListsOrder = mainViewModel.entityListsOrder,
136148
entityListFilter = mainViewModel.entityListFilter,
137149
onEntityClicked = { entityId, state ->
@@ -150,7 +162,7 @@ fun LoadHomePage(mainViewModel: MainViewModel) {
150162
mainViewModel.refreshNotificationPermission()
151163
}
152164
SettingsView(
153-
loadingState = mainViewModel.loadingState.value,
165+
loadingState = mainViewModel.loadingState,
154166
favorites = mainViewModel.favoriteEntityIds.value,
155167
onClickSetFavorites = {
156168
swipeDismissableNavController.navigate(

0 commit comments

Comments
 (0)