Decouple NodeScreen from UiViewModel (#3207)

This commit is contained in:
Phil Oliver 2025-09-25 21:09:17 -04:00 committed by GitHub
parent 1ba8c536e2
commit 01290278e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 299 additions and 189 deletions

View file

@ -316,6 +316,7 @@
<ID>ParameterNaming:ChannelSettingsItemList.kt$onSelected</ID>
<ID>ParameterNaming:CleanNodeDatabaseScreen.kt$onCheckedChanged</ID>
<ID>ParameterNaming:CleanNodeDatabaseScreen.kt$onDaysChanged</ID>
<ID>ParameterNaming:ContactSharing.kt$onSharedContactRequested</ID>
<ID>ParameterNaming:Contacts.kt$onDeleteSelected</ID>
<ID>ParameterNaming:Contacts.kt$onMuteSelected</ID>
<ID>ParameterNaming:DropDownPreference.kt$onItemSelected</ID>
@ -386,6 +387,7 @@
<ID>TooManyFunctions:MeshService.kt$MeshService : ServiceLogging</ID>
<ID>TooManyFunctions:MeshService.kt$MeshService$&lt;no name provided&gt; : Stub</ID>
<ID>TooManyFunctions:NodeDetail.kt$com.geeksville.mesh.ui.node.NodeDetail.kt</ID>
<ID>TooManyFunctions:NodesViewModel.kt$NodesViewModel : ViewModel</ID>
<ID>TooManyFunctions:PacketRepository.kt$PacketRepository</ID>
<ID>TooManyFunctions:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModelLogging</ID>
<ID>TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService : Logging</ID>
@ -399,7 +401,6 @@
<ID>ViewModelForwarding:Main.kt$ScannedQrCodeDialog(uIViewModel, newChannelSet)</ID>
<ID>ViewModelForwarding:Main.kt$VersionChecks(uIViewModel)</ID>
<ID>ViewModelForwarding:Message.kt$MessageList( modifier = Modifier.fillMaxSize(), listState = listState, messages = messages, selectedIds = selectedMessageIds, onUnreadChanged = { messageId -&gt; onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) }, onSendReaction = { emoji, id -&gt; onEvent(MessageScreenEvent.SendReaction(emoji, id)) }, viewModel = viewModel, contactKey = contactKey, onReply = { message -&gt; replyingToPacketId = message?.packetId }, onNodeMenuAction = { action -&gt; onEvent(MessageScreenEvent.HandleNodeMenuAction(action)) }, )</ID>
<ID>ViewModelForwarding:NodeScreen.kt$AddContactFAB( modifier = Modifier.animateFloatingActionButton( visible = !isScrollInProgress &amp;&amp; connectionState == ConnectionState.CONNECTED &amp;&amp; shareCapable, alignment = Alignment.BottomEnd, ), model = model, onSharedContactImport = { contact -&gt; model.addSharedContact(contact) }, )</ID>
<ID>ViewModelInjection:DebugSearch.kt$viewModel</ID>
<ID>WildcardImport:UsbRepository.kt$import kotlinx.coroutines.flow.*</ID>
<ID>Wrapping:Message.kt${ event -&gt; when (event) { is MessageScreenEvent.SendMessage -&gt; { viewModel.sendMessage(event.text, contactKey, event.replyingToPacketId) if (event.replyingToPacketId != null) replyingToPacketId = null messageInputState.clearText() } is MessageScreenEvent.SendReaction -&gt; viewModel.sendReaction(event.emoji, event.messageId, contactKey) is MessageScreenEvent.DeleteMessages -&gt; { viewModel.deleteMessages(event.ids) selectedMessageIds.value = emptySet() showDeleteDialog = false } is MessageScreenEvent.ClearUnreadCount -&gt; viewModel.clearUnreadCount(contactKey, event.lastReadMessageId) is MessageScreenEvent.HandleNodeMenuAction -&gt; { when (val action = event.action) { is NodeMenuAction.DirectMessage -&gt; { val hasPKC = ourNode?.hasPKC == true &amp;&amp; action.node.hasPKC val targetChannel = if (hasPKC) { DataPacket.PKC_CHANNEL_INDEX } else { action.node.channel } navigateToMessages("$targetChannel${action.node.user.id}") } is NodeMenuAction.MoreDetails -&gt; navigateToNodeDetails(action.node.num) is NodeMenuAction.Share -&gt; sharedContact = action.node else -&gt; viewModel.handleNodeMenuAction(action) } } is MessageScreenEvent.SetTitle -&gt; viewModel.setTitle(event.title) is MessageScreenEvent.NavigateToMessages -&gt; navigateToMessages(event.contactKey) is MessageScreenEvent.NavigateToNodeDetails -&gt; navigateToNodeDetails(event.nodeNum) MessageScreenEvent.NavigateBack -&gt; onNavigateBack() is MessageScreenEvent.CopyToClipboard -&gt; { clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text)) selectedMessageIds.value = emptySet() } } }</ID>

View file

@ -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<ChannelSettings>, old: List<ChannelSetting
}
}
data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: String = "",
val includeUnknown: Boolean = false,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
val showDetails: Boolean = false,
val showIgnored: Boolean = false,
) {
companion object {
val Empty = NodesUiState()
}
}
data class Contact(
val contactKey: String,
val shortName: String,
@ -299,37 +281,11 @@ constructor(
.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val nodeFilterText = MutableStateFlow("")
private val nodeSortOption =
MutableStateFlow(NodeSortOption.entries.getOrElse(uiPrefs.nodeSortOption) { NodeSortOption.VIA_FAVORITE })
private val includeUnknown = MutableStateFlow(uiPrefs.includeUnknown)
private val showDetails = MutableStateFlow(uiPrefs.showDetails)
private val onlyOnline = MutableStateFlow(uiPrefs.onlyOnline)
private val onlyDirect = MutableStateFlow(uiPrefs.onlyDirect)
private val _showIgnored = MutableStateFlow(uiPrefs.showIgnored)
val showIgnored: StateFlow<Boolean> = _showIgnored
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
val showQuickChat: StateFlow<Boolean> = _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<Boolean>, 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<NodeFilterState> =
combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) {
filterText,
includeUnknown,
onlyOnline,
onlyDirect,
showIgnored,
->
NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored)
}
val nodesUiState: StateFlow<NodesUiState> =
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<List<Node>> =
val nodeList: StateFlow<List<Node>> =
nodeDB
.getNodes()
.stateIn(
@ -390,33 +302,6 @@ constructor(
initialValue = emptyList(),
)
val nodeList: StateFlow<List<Node>> =
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<MyNodeEntity?>
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<Boolean> = uiPreferencesDataSource.appIntroCompleted
fun onAppIntroCompleted() {

View file

@ -65,7 +65,6 @@ fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UI
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
NodeScreen(
model = uiViewModel,
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
)

View file

@ -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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Node?> = 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<AdminProtos.SharedContact?> = 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<Boolean> = _showIgnored
private val nodeFilter: Flow<NodeFilterState> =
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<NodesUiState> =
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<List<Node>> =
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<List<Node>> =
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<Boolean>, 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,
)

View file

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