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) }, ) }