From 8152a99f475efa0eb8877bb058379bd770f85bbe Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Tue, 18 Nov 2025 01:51:46 -0500 Subject: [PATCH] Clean up UI state using molecule --- feature/node/build.gradle.kts | 1 + .../feature/node/list/NodeListScreen.kt | 4 +- .../feature/node/list/NodeListViewModel.kt | 148 ++++++++++-------- gradle/libs.versions.toml | 1 + 4 files changed, 85 insertions(+), 69 deletions(-) diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index ae51361bc..bab0d067d 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.markdown.renderer.android) implementation(libs.markdown.renderer.m3) implementation(libs.markdown.renderer) + implementation(libs.molecule) googleImplementation(libs.location.services) googleImplementation(libs.maps.compose) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 3cb254eee..590e6ea43 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -94,7 +94,7 @@ fun NodeListScreen( scrollToTopEvents: Flow? = null, activeNodeId: Int? = null, ) { - val state by viewModel.nodesUiState.collectAsStateWithLifecycle() + val state by viewModel.uiState.collectAsStateWithLifecycle() val nodes by viewModel.nodeList.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() @@ -161,7 +161,7 @@ fun NodeListScreen( .background(MaterialTheme.colorScheme.surfaceDim) .padding(8.dp), filterText = state.filter.filterText, - onTextChange = { viewModel.nodeFilterText = it }, + onTextChange = { viewModel.setFilterText(it) }, currentSortOption = state.sort, onSortSelect = viewModel::setSortOption, includeUnknown = state.filter.includeUnknown, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index b52069652..0a818aac0 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -17,16 +17,20 @@ package org.meshtastic.feature.node.list +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.cash.molecule.RecompositionMode +import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.meshtastic.core.data.repository.NodeRepository @@ -38,6 +42,7 @@ import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.model.isEffectivelyUnmessageable import org.meshtastic.proto.AdminProtos import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.deviceProfile import javax.inject.Inject @HiltViewModel @@ -63,78 +68,87 @@ constructor( private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null) val sharedContactRequested = _sharedContactRequested.asStateFlow() - private val nodeSortOption = nodeFilterPreferences.nodeSortOption + private val filterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "") - private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "") - private val includeUnknown = nodeFilterPreferences.includeUnknown - private val excludeInfrastructure = nodeFilterPreferences.excludeInfrastructure - private val onlyOnline = nodeFilterPreferences.onlyOnline - private val onlyDirect = nodeFilterPreferences.onlyDirect - private val showIgnored = nodeFilterPreferences.showIgnored + private val moleculeScope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + val uiState: StateFlow by + lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = RecompositionMode.ContextClock) { + val filterText by filterText + val includeUnknown by nodeFilterPreferences.includeUnknown.collectAsState() + val excludeInfrastructure by nodeFilterPreferences.excludeInfrastructure.collectAsState() + val onlyOnline by nodeFilterPreferences.onlyOnline.collectAsState() + val onlyDirect by nodeFilterPreferences.onlyDirect.collectAsState() + val showIgnored by nodeFilterPreferences.showIgnored.collectAsState() - private val nodeFilter: Flow = - combine(_nodeFilterText, includeUnknown, excludeInfrastructure, onlyOnline, onlyDirect, showIgnored) { values -> - NodeFilterState( - filterText = values[0] as String, - includeUnknown = values[1] as Boolean, - excludeInfrastructure = values[2] as Boolean, - onlyOnline = values[3] as Boolean, - onlyDirect = values[4] as Boolean, - showIgnored = values[5] as Boolean, - ) - } - val nodesUiState: StateFlow = - combine(nodeSortOption, nodeFilter, radioConfigRepository.deviceProfileFlow) { sort, nodeFilter, profile -> - NodesUiState( - sort = sort, - filter = nodeFilter, - distanceUnits = profile.config.display.units.number, - tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit, - ) - } - .stateInWhileSubscribed(initialValue = NodesUiState()) - - val nodeList: StateFlow> = - combine(nodeFilter, nodeSortOption, ::Pair) - .flatMapLatest { (filter, sort) -> - nodeRepository - .getNodes( - sort = sort, - filter = filter.filterText, - includeUnknown = filter.includeUnknown, - onlyOnline = filter.onlyOnline, - onlyDirect = filter.onlyDirect, + val filterState = + NodeFilterState( + filterText = filterText, + includeUnknown = includeUnknown, + excludeInfrastructure = excludeInfrastructure, + onlyOnline = onlyOnline, + onlyDirect = onlyDirect, + showIgnored = showIgnored, ) - .map { list -> - list - .filter { filter.showIgnored || !it.isIgnored } - .filter { node -> - if (filter.excludeInfrastructure) { - val role = node.user.role - val infrastructureRoles = - listOf( - ConfigProtos.Config.DeviceConfig.Role.ROUTER, - ConfigProtos.Config.DeviceConfig.Role.REPEATER, - ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE, - ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE, - ) - role !in infrastructureRoles && !node.isEffectivelyUnmessageable - } else { - true - } - } - } + val sort by + nodeFilterPreferences.nodeSortOption + .collectAsState(NodeSortOption.VIA_FAVORITE) + val profile by radioConfigRepository.deviceProfileFlow.collectAsState(deviceProfile {}) + NodesUiState( + sort = sort, + filter = filterState, + distanceUnits = profile.config.display.units.number, + tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit, + ) } - .stateInWhileSubscribed(initialValue = emptyList()) + } + + val nodeList: StateFlow> by + lazy(LazyThreadSafetyMode.NONE) { + moleculeScope.launchMolecule(mode = RecompositionMode.ContextClock) { + val uiState by uiState.collectAsState() + val sort = uiState.sort + val filter = uiState.filter + + val nodeList by + nodeRepository + .getNodes( + sort = sort, + filter = filter.filterText, + includeUnknown = filter.includeUnknown, + onlyOnline = filter.onlyOnline, + onlyDirect = filter.onlyDirect, + ) + .map { list -> + list + .filter { filter.showIgnored || !it.isIgnored } + .filter { node -> + if (filter.excludeInfrastructure) { + val role = node.user.role + val infrastructureRoles = + listOf( + ConfigProtos.Config.DeviceConfig.Role.ROUTER, + ConfigProtos.Config.DeviceConfig.Role.REPEATER, + ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE, + ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE, + ) + role !in infrastructureRoles && !node.isEffectivelyUnmessageable + } else { + true + } + } + } + .collectAsState(emptyList()) + nodeList + } + } val unfilteredNodeList: StateFlow> = nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList()) - var nodeFilterText: String - get() = _nodeFilterText.value - set(value) { - savedStateHandle[KEY_FILTER_TEXT] = value - } + fun setFilterText(filterText: String) { + savedStateHandle[KEY_FILTER_TEXT] = value + } fun setSortOption(sort: NodeSortOption) { nodeFilterPreferences.setNodeSort(sort) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 585c46a7b..ffec214cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -146,6 +146,7 @@ markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer- markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" } material = { module = "com.google.android.material:material", version = "1.13.0" } mgrs = { module = "mil.nga:mgrs", version = "2.1.3" } +molecule = { module = "app.cash.molecule:molecule-runtime", version = "2.2.0" } nordic = { module = "no.nordicsemi.kotlin.ble:client-android", version = "2.0.0-alpha12" } nordic-dfu = { module = "no.nordicsemi.android:dfu", version = "2.10.1" } org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }