@@ -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 ) {
0 commit comments