From 01290278e9bfac8b3292f1ae6e6566165f057620 Mon Sep 17 00:00:00 2001
From: Phil Oliver <3497406+poliver@users.noreply.github.com>
Date: Thu, 25 Sep 2025 21:09:17 -0400
Subject: [PATCH] Decouple `NodeScreen` from `UiViewModel` (#3207)
---
app/detekt-baseline.xml | 3 +-
.../java/com/geeksville/mesh/model/UIState.kt | 142 +---------
.../mesh/navigation/NodesNavigation.kt | 1 -
.../com/geeksville/mesh/ui/node/NodeScreen.kt | 65 ++---
.../geeksville/mesh/ui/node/NodesViewModel.kt | 254 ++++++++++++++++++
.../mesh/ui/sharing/ContactSharing.kt | 23 +-
6 files changed, 299 insertions(+), 189 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/node/NodesViewModel.kt
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 36e1c68ec..e62c0a1dd 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -316,6 +316,7 @@
ParameterNaming:ChannelSettingsItemList.kt$onSelected
ParameterNaming:CleanNodeDatabaseScreen.kt$onCheckedChanged
ParameterNaming:CleanNodeDatabaseScreen.kt$onDaysChanged
+ ParameterNaming:ContactSharing.kt$onSharedContactRequested
ParameterNaming:Contacts.kt$onDeleteSelected
ParameterNaming:Contacts.kt$onMuteSelected
ParameterNaming:DropDownPreference.kt$onItemSelected
@@ -386,6 +387,7 @@
TooManyFunctions:MeshService.kt$MeshService : ServiceLogging
TooManyFunctions:MeshService.kt$MeshService$<no name provided> : Stub
TooManyFunctions:NodeDetail.kt$com.geeksville.mesh.ui.node.NodeDetail.kt
+ TooManyFunctions:NodesViewModel.kt$NodesViewModel : ViewModel
TooManyFunctions:PacketRepository.kt$PacketRepository
TooManyFunctions:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModelLogging
TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService : Logging
@@ -399,7 +401,6 @@
ViewModelForwarding:Main.kt$ScannedQrCodeDialog(uIViewModel, newChannelSet)
ViewModelForwarding:Main.kt$VersionChecks(uIViewModel)
ViewModelForwarding:Message.kt$MessageList( modifier = Modifier.fillMaxSize(), listState = listState, messages = messages, selectedIds = selectedMessageIds, onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) }, onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) }, viewModel = viewModel, contactKey = contactKey, onReply = { message -> replyingToPacketId = message?.packetId }, onNodeMenuAction = { action -> onEvent(MessageScreenEvent.HandleNodeMenuAction(action)) }, )
- ViewModelForwarding:NodeScreen.kt$AddContactFAB( modifier = Modifier.animateFloatingActionButton( visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable, alignment = Alignment.BottomEnd, ), model = model, onSharedContactImport = { contact -> model.addSharedContact(contact) }, )
ViewModelInjection:DebugSearch.kt$viewModel
WildcardImport:UsbRepository.kt$import kotlinx.coroutines.flow.*
Wrapping:Message.kt${ event -> when (event) { is MessageScreenEvent.SendMessage -> { viewModel.sendMessage(event.text, contactKey, event.replyingToPacketId) if (event.replyingToPacketId != null) replyingToPacketId = null messageInputState.clearText() } is MessageScreenEvent.SendReaction -> viewModel.sendReaction(event.emoji, event.messageId, contactKey) is MessageScreenEvent.DeleteMessages -> { viewModel.deleteMessages(event.ids) selectedMessageIds.value = emptySet() showDeleteDialog = false } is MessageScreenEvent.ClearUnreadCount -> viewModel.clearUnreadCount(contactKey, event.lastReadMessageId) is MessageScreenEvent.HandleNodeMenuAction -> { when (val action = event.action) { is NodeMenuAction.DirectMessage -> { val hasPKC = ourNode?.hasPKC == true && action.node.hasPKC val targetChannel = if (hasPKC) { DataPacket.PKC_CHANNEL_INDEX } else { action.node.channel } navigateToMessages("$targetChannel${action.node.user.id}") } is NodeMenuAction.MoreDetails -> navigateToNodeDetails(action.node.num) is NodeMenuAction.Share -> sharedContact = action.node else -> viewModel.handleNodeMenuAction(action) } } is MessageScreenEvent.SetTitle -> viewModel.setTitle(event.title) is MessageScreenEvent.NavigateToMessages -> navigateToMessages(event.contactKey) is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum) MessageScreenEvent.NavigateBack -> onNavigateBack() is MessageScreenEvent.CopyToClipboard -> { clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text)) selectedMessageIds.value = emptySet() } } }
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index d646d8c5e..b01713eb9 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -58,7 +58,6 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.util.safeNumber
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -81,7 +80,6 @@ import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
@@ -149,22 +147,6 @@ internal fun getChannelList(new: List, old: List = _showIgnored
-
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
val showQuickChat: StateFlow = _showQuickChat
- fun toggleShowIgnored() = toggle(_showIgnored) { uiPrefs.showIgnored = it }
-
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
- fun setSortOption(sort: NodeSortOption) {
- nodeSortOption.value = sort
- uiPrefs.nodeSortOption = sort.ordinal
- }
-
- fun toggleShowDetails() = toggle(showDetails) { uiPrefs.showDetails = it }
-
- fun toggleIncludeUnknown() = toggle(includeUnknown) { uiPrefs.includeUnknown = it }
-
- fun toggleOnlyOnline() = toggle(onlyOnline) { uiPrefs.onlyOnline = it }
-
- fun toggleOnlyDirect() = toggle(onlyDirect) { uiPrefs.onlyDirect = it }
-
private fun toggle(state: MutableStateFlow, onChanged: (newValue: Boolean) -> Unit) {
(!state.value).let { toggled ->
state.update { toggled }
@@ -337,51 +293,7 @@ constructor(
}
}
- data class NodeFilterState(
- val filterText: String,
- val includeUnknown: Boolean,
- val onlyOnline: Boolean,
- val onlyDirect: Boolean,
- val showIgnored: Boolean,
- )
-
- val nodeFilterStateFlow: Flow =
- combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) {
- filterText,
- includeUnknown,
- onlyOnline,
- onlyDirect,
- showIgnored,
- ->
- NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored)
- }
-
- val nodesUiState: StateFlow =
- combine(nodeFilterStateFlow, nodeSortOption, showDetails, radioConfigRepository.deviceProfileFlow) {
- filterFlow,
- sort,
- showDetails,
- profile,
- ->
- NodesUiState(
- sort = sort,
- filter = filterFlow.filterText,
- includeUnknown = filterFlow.includeUnknown,
- onlyOnline = filterFlow.onlyOnline,
- onlyDirect = filterFlow.onlyDirect,
- distanceUnits = profile.config.display.units.number,
- tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
- showDetails = showDetails,
- showIgnored = filterFlow.showIgnored,
- )
- }
- .stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = NodesUiState.Empty,
- )
-
- val unfilteredNodeList: StateFlow> =
+ val nodeList: StateFlow> =
nodeDB
.getNodes()
.stateIn(
@@ -390,33 +302,6 @@ constructor(
initialValue = emptyList(),
)
- val nodeList: StateFlow> =
- nodesUiState
- .flatMapLatest { state ->
- nodeDB
- .getNodes(state.sort, state.filter, state.includeUnknown, state.onlyOnline, state.onlyDirect)
- .map { list -> list.filter { it.isIgnored == state.showIgnored } }
- }
- .stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = emptyList(),
- )
-
- val onlineNodeCount =
- nodeDB.onlineNodeCount.stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = 0,
- )
-
- val totalNodeCount =
- nodeDB.totalNodeCount.stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = 0,
- )
-
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow
get() = nodeDB.myNodeInfo
@@ -603,9 +488,6 @@ constructor(
_sharedContactRequested.value = sharedContact
}
- fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
- viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact)) }
-
fun requestTraceroute(destNum: Int) {
info("Requesting traceroute for '$destNum'")
try {
@@ -749,9 +631,6 @@ constructor(
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
- val maxChannels
- get() = myNodeInfo.value?.maxChannels ?: 8
-
override fun onCleared() {
super.onCleared()
debug("ViewModel cleared")
@@ -788,21 +667,6 @@ constructor(
if (config.lora != newConfig.lora) setConfig(newConfig)
}
- fun setOwner(name: String) {
- val user =
- ourNodeInfo.value?.user?.copy {
- longName = name
- shortName = getInitials(name)
- } ?: return
-
- try {
- // Note: we use ?. here because we might be running in the emulator
- meshService?.setRemoteOwner(myNodeNum ?: return, user.toByteArray())
- } catch (ex: RemoteException) {
- errormsg("Can't set username on device, is device offline? ${ex.message}")
- }
- }
-
fun addQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
@@ -824,10 +688,6 @@ constructor(
serviceRepository.clearTracerouteResponse()
}
- fun setNodeFilterText(text: String) {
- nodeFilterText.value = text
- }
-
val appIntroCompleted: StateFlow = uiPreferencesDataSource.appIntroCompleted
fun onAppIntroCompleted() {
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
index c9dcaf0a7..5ea9aa797 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
@@ -65,7 +65,6 @@ fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UI
deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
NodeScreen(
- model = uiViewModel,
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
index 1677b92e8..4c03504a1 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
@@ -46,7 +46,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.geeksville.mesh.model.UIViewModel
+import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.node.components.NodeFilterTextField
@@ -65,23 +65,23 @@ import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeScreen(
- model: UIViewModel = hiltViewModel(),
+ nodesViewModel: NodesViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
) {
- val state by model.nodesUiState.collectAsStateWithLifecycle()
+ val state by nodesViewModel.nodesUiState.collectAsStateWithLifecycle()
- val nodes by model.nodeList.collectAsStateWithLifecycle()
- val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle()
- val onlineNodeCount by model.onlineNodeCount.collectAsStateWithLifecycle(0)
- val totalNodeCount by model.totalNodeCount.collectAsStateWithLifecycle(0)
- val unfilteredNodes by model.unfilteredNodeList.collectAsStateWithLifecycle()
+ val nodes by nodesViewModel.nodeList.collectAsStateWithLifecycle()
+ val ourNode by nodesViewModel.ourNodeInfo.collectAsStateWithLifecycle()
+ val onlineNodeCount by nodesViewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
+ val totalNodeCount by nodesViewModel.totalNodeCount.collectAsStateWithLifecycle(0)
+ val unfilteredNodes by nodesViewModel.unfilteredNodeList.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
val listState = rememberLazyListState()
val currentTimeMillis = rememberTimeTickWithLifecycle()
- val connectionState by model.connectionState.collectAsStateWithLifecycle()
+ val connectionState by nodesViewModel.connectionState.collectAsStateWithLifecycle()
var showSharedContact: Node? by remember { mutableStateOf(null) }
if (showSharedContact != null) {
@@ -106,15 +106,18 @@ fun NodeScreen(
floatingActionButton = {
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
val shareCapable = firmwareVersion.supportsQrCodeSharing()
-
+ val scannedContact: AdminProtos.SharedContact? by
+ nodesViewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
+ unfilteredNodes = unfilteredNodes,
+ scannedContact = scannedContact,
modifier =
Modifier.animateFloatingActionButton(
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
alignment = Alignment.BottomEnd,
),
- model = model,
- onSharedContactImport = { contact -> model.addSharedContact(contact) },
+ onSharedContactImport = { contact -> nodesViewModel.addSharedContact(contact) },
+ onSharedContactRequested = { contact -> nodesViewModel.setSharedContactRequested(contact) },
)
},
) { contentPadding ->
@@ -129,20 +132,20 @@ fun NodeScreen(
.graphicsLayer(alpha = animatedAlpha)
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
- filterText = state.filter,
- onTextChange = model::setNodeFilterText,
+ filterText = state.filter.filterText,
+ onTextChange = nodesViewModel::setNodeFilterText,
currentSortOption = state.sort,
- onSortSelect = model::setSortOption,
- includeUnknown = state.includeUnknown,
- onToggleIncludeUnknown = model::toggleIncludeUnknown,
- onlyOnline = state.onlyOnline,
- onToggleOnlyOnline = model::toggleOnlyOnline,
- onlyDirect = state.onlyDirect,
- onToggleOnlyDirect = model::toggleOnlyDirect,
+ onSortSelect = nodesViewModel::setSortOption,
+ includeUnknown = state.filter.includeUnknown,
+ onToggleIncludeUnknown = nodesViewModel::toggleIncludeUnknown,
+ onlyOnline = state.filter.onlyOnline,
+ onToggleOnlyOnline = nodesViewModel::toggleOnlyOnline,
+ onlyDirect = state.filter.onlyDirect,
+ onToggleOnlyDirect = nodesViewModel::toggleOnlyDirect,
showDetails = state.showDetails,
- onToggleShowDetails = model::toggleShowDetails,
- showIgnored = state.showIgnored,
- onToggleShowIgnored = model::toggleShowIgnored,
+ onToggleShowDetails = nodesViewModel::toggleShowDetails,
+ showIgnored = state.filter.showIgnored,
+ onToggleShowIgnored = nodesViewModel::toggleShowIgnored,
ignoredNodeCount = ignoredNodeCount,
)
}
@@ -156,18 +159,18 @@ fun NodeScreen(
tempInFahrenheit = state.tempInFahrenheit,
onAction = { menuItem ->
when (menuItem) {
- is NodeMenuAction.Remove -> model.removeNode(node.num)
- is NodeMenuAction.Ignore -> model.ignoreNode(node)
- is NodeMenuAction.Favorite -> model.favoriteNode(node)
+ is NodeMenuAction.Remove -> nodesViewModel.removeNode(node.num)
+ is NodeMenuAction.Ignore -> nodesViewModel.ignoreNode(node)
+ is NodeMenuAction.Favorite -> nodesViewModel.favoriteNode(node)
is NodeMenuAction.DirectMessage -> {
- val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC
+ val hasPKC = nodesViewModel.ourNodeInfo.value?.hasPKC == true && node.hasPKC
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
navigateToMessages("$channel${node.user.id}")
}
- is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num)
- is NodeMenuAction.RequestPosition -> model.requestPosition(node.num)
- is NodeMenuAction.TraceRoute -> model.requestTraceroute(node.num)
+ is NodeMenuAction.RequestUserInfo -> nodesViewModel.requestUserInfo(node.num)
+ is NodeMenuAction.RequestPosition -> nodesViewModel.requestPosition(node.num)
+ is NodeMenuAction.TraceRoute -> nodesViewModel.requestTraceroute(node.num)
is NodeMenuAction.MoreDetails -> navigateToNodeDetails(node.num)
is NodeMenuAction.Share -> showSharedContact = node
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodesViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodesViewModel.kt
new file mode 100644
index 000000000..d94df5c2d
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodesViewModel.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.geeksville.mesh.ui.node
+
+import android.os.RemoteException
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.geeksville.mesh.AdminProtos
+import com.geeksville.mesh.database.NodeRepository
+import com.geeksville.mesh.repository.datastore.RadioConfigRepository
+import com.geeksville.mesh.service.ServiceAction
+import com.geeksville.mesh.service.ServiceRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+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.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.database.model.NodeSortOption
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.prefs.ui.UiPrefs
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class NodesViewModel
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ radioConfigRepository: RadioConfigRepository,
+ private val serviceRepository: ServiceRepository,
+ private val uiPrefs: UiPrefs,
+) : ViewModel() {
+
+ val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
+
+ val onlineNodeCount =
+ nodeRepository.onlineNodeCount.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = 0,
+ )
+
+ val totalNodeCount =
+ nodeRepository.totalNodeCount.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = 0,
+ )
+
+ val connectionState = serviceRepository.connectionState
+
+ private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null)
+ val sharedContactRequested = _sharedContactRequested.asStateFlow()
+
+ private val nodeSortOption =
+ MutableStateFlow(NodeSortOption.entries.getOrElse(uiPrefs.nodeSortOption) { NodeSortOption.VIA_FAVORITE })
+
+ private val nodeFilterText = MutableStateFlow("")
+ private val includeUnknown = MutableStateFlow(uiPrefs.includeUnknown)
+ private val onlyOnline = MutableStateFlow(uiPrefs.onlyOnline)
+ private val onlyDirect = MutableStateFlow(uiPrefs.onlyDirect)
+ private val _showIgnored = MutableStateFlow(uiPrefs.showIgnored)
+ val showIgnored: StateFlow = _showIgnored
+
+ private val nodeFilter: Flow =
+ combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) {
+ filterText,
+ includeUnknown,
+ onlyOnline,
+ onlyDirect,
+ showIgnored,
+ ->
+ NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored)
+ }
+
+ private val showDetails = MutableStateFlow(uiPrefs.showDetails)
+
+ val nodesUiState: StateFlow =
+ combine(nodeSortOption, nodeFilter, showDetails, radioConfigRepository.deviceProfileFlow) {
+ sort,
+ nodeFilter,
+ showDetails,
+ profile,
+ ->
+ NodesUiState(
+ sort = sort,
+ filter = nodeFilter,
+ distanceUnits = profile.config.display.units.number,
+ tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
+ showDetails = showDetails,
+ )
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ 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,
+ )
+ .map { list -> list.filter { it.isIgnored == filter.showIgnored } }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = emptyList(),
+ )
+
+ val unfilteredNodeList: StateFlow> =
+ nodeRepository
+ .getNodes()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = emptyList(),
+ )
+
+ fun setNodeFilterText(text: String) {
+ nodeFilterText.value = text
+ }
+
+ fun toggleIncludeUnknown() = toggle(includeUnknown) { uiPrefs.includeUnknown = it }
+
+ fun toggleOnlyOnline() = toggle(onlyOnline) { uiPrefs.onlyOnline = it }
+
+ fun toggleOnlyDirect() = toggle(onlyDirect) { uiPrefs.onlyDirect = it }
+
+ fun toggleShowIgnored() = toggle(_showIgnored) { uiPrefs.showIgnored = it }
+
+ fun setSortOption(sort: NodeSortOption) {
+ nodeSortOption.value = sort
+ uiPrefs.nodeSortOption = sort.ordinal
+ }
+
+ fun toggleShowDetails() = toggle(showDetails) { uiPrefs.showDetails = it }
+
+ fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
+ viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact)) }
+
+ fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
+ Timber.i("Removing node '$nodeNum'")
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return@launch
+ serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
+ nodeRepository.deleteNode(nodeNum)
+ } catch (ex: RemoteException) {
+ Timber.e("Remove node error: ${ex.message}")
+ }
+ }
+
+ fun ignoreNode(node: Node) = viewModelScope.launch {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Ignore(node))
+ } catch (ex: RemoteException) {
+ Timber.e(ex, "Ignore node error")
+ }
+ }
+
+ fun favoriteNode(node: Node) = viewModelScope.launch {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Favorite(node))
+ } catch (ex: RemoteException) {
+ Timber.e(ex, "Favorite node error")
+ }
+ }
+
+ fun requestUserInfo(destNum: Int) {
+ Timber.i("Requesting UserInfo for '$destNum'")
+ try {
+ serviceRepository.meshService?.requestUserInfo(destNum)
+ } catch (ex: RemoteException) {
+ Timber.e("Request NodeInfo error: ${ex.message}")
+ }
+ }
+
+ fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
+ Timber.i("Requesting position for '$destNum'")
+ try {
+ serviceRepository.meshService?.requestPosition(destNum, position)
+ } catch (ex: RemoteException) {
+ Timber.e("Request position error: ${ex.message}")
+ }
+ }
+
+ fun requestTraceroute(destNum: Int) {
+ Timber.i("Requesting traceroute for '$destNum'")
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return
+ serviceRepository.meshService?.requestTraceroute(packetId, destNum)
+ } catch (ex: RemoteException) {
+ Timber.e("Request traceroute error: ${ex.message}")
+ }
+ }
+
+ fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
+ _sharedContactRequested.value = sharedContact
+ }
+
+ private fun toggle(state: MutableStateFlow, onChanged: (newValue: Boolean) -> Unit) {
+ (!state.value).let { toggled ->
+ state.update { toggled }
+ onChanged(toggled)
+ }
+ }
+}
+
+data class NodesUiState(
+ val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
+ val filter: NodeFilterState = NodeFilterState(),
+ val distanceUnits: Int = 0,
+ val tempInFahrenheit: Boolean = false,
+ val showDetails: Boolean = false,
+)
+
+data class NodeFilterState(
+ val filterText: String = "",
+ val includeUnknown: Boolean = false,
+ val onlyOnline: Boolean = false,
+ val onlyDirect: Boolean = false,
+ val showIgnored: Boolean = false,
+)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt
index e07a6319e..e89ad7304 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ContactSharing.kt
@@ -36,8 +36,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
@@ -48,13 +46,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.errormsg
-import com.geeksville.mesh.model.UIViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -79,19 +74,18 @@ import java.net.MalformedURLException
* requests using Accompanist Permissions.
*
* @param modifier Modifier for this composable.
- * @param model UIViewModel for interacting with application state.
* @param onSharedContactImport Callback invoked when a shared contact is successfully imported.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun AddContactFAB(
+ unfilteredNodes: List,
+ scannedContact: AdminProtos.SharedContact?,
modifier: Modifier = Modifier,
- model: UIViewModel = hiltViewModel(),
onSharedContactImport: (AdminProtos.SharedContact) -> Unit = {},
+ onSharedContactRequested: (AdminProtos.SharedContact?) -> Unit = {},
) {
- val scannedContact: AdminProtos.SharedContact? by model.sharedContactRequested.collectAsStateWithLifecycle(null)
-
val barcodeLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
@@ -104,15 +98,14 @@ fun AddContactFAB(
null
}
if (sharedContact != null) {
- model.setSharedContactRequested(sharedContact)
+ onSharedContactRequested(sharedContact)
}
}
}
scannedContact?.let { contactToImport ->
- val nodeNum = scannedContact?.nodeNum
- val nodes by model.unfilteredNodeList.collectAsState()
- val node = nodes.find { it.num == nodeNum }
+ val nodeNum = contactToImport.nodeNum
+ val node = unfilteredNodes.find { it.num == nodeNum }
SimpleAlertDialog(
title = R.string.import_shared_contact,
text = {
@@ -133,11 +126,11 @@ fun AddContactFAB(
}
},
dismissText = stringResource(R.string.cancel),
- onDismiss = { model.setSharedContactRequested(null) },
+ onDismiss = { onSharedContactRequested(null) },
confirmText = stringResource(R.string.import_label),
onConfirm = {
onSharedContactImport(contactToImport)
- model.setSharedContactRequested(null)
+ onSharedContactRequested(null)
},
)
}