From 6d5e56b34fb3a5b284a4a1e2681b1f8338156014 Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Fri, 26 Sep 2025 19:54:31 -0400 Subject: [PATCH] Decouple contacts nav graph from `UiViewModel` (#3215) --- .../java/com/geeksville/mesh/model/UIState.kt | 80 +---------- .../mesh/navigation/ContactsNavigation.kt | 6 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 2 +- .../geeksville/mesh/ui/contact/Contacts.kt | 27 ++-- .../mesh/ui/contact/ContactsViewModel.kt | 135 ++++++++++++++++++ .../com/geeksville/mesh/ui/sharing/Share.kt | 4 +- 6 files changed, 156 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt 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 4a6e41de3..1fb6e3bff 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -54,7 +54,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -67,19 +66,14 @@ import org.meshtastic.core.data.repository.DeviceHardwareRepository import org.meshtastic.core.data.repository.FirmwareReleaseRepository import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.data.repository.QuickChatActionRepository import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.database.model.Node import org.meshtastic.core.datastore.UiPreferencesDataSource -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.util.getChannel -import org.meshtastic.core.model.util.getShortDate import org.meshtastic.core.model.util.toChannelSet import org.meshtastic.core.strings.R import javax.inject.Inject @@ -165,9 +159,8 @@ constructor( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, radioInterfaceService: RadioInterfaceService, - private val meshLogRepository: MeshLogRepository, + meshLogRepository: MeshLogRepository, private val deviceHardwareRepository: DeviceHardwareRepository, - private val packetRepository: PacketRepository, private val quickChatActionRepository: QuickChatActionRepository, firmwareReleaseRepository: FirmwareReleaseRepository, private val uiPreferencesDataSource: UiPreferencesDataSource, @@ -279,10 +272,6 @@ constructor( val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo - fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST) - - fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST) - val snackBarHostState = SnackbarHostState() fun showSnackBar(text: Int) = showSnackBar(app.getString(text)) @@ -327,67 +316,6 @@ constructor( debug("ViewModel created") } - val contactList = - combine(nodeDB.myNodeInfo, packetRepository.getContacts(), channels, packetRepository.getContactSettings()) { - myNodeInfo, - contacts, - channelSet, - settings, - -> - val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() - // Add empty channel placeholders (always show Broadcast contacts, even when empty) - val placeholder = - (0 until channelSet.settingsCount).associate { ch -> - val contactKey = "$ch${DataPacket.ID_BROADCAST}" - val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) - contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) - } - - (contacts + (placeholder - contacts.keys)).values.map { packet -> - val data = packet.data - val contactKey = packet.contact_key - - // Determine if this is my message (originated on this device) - val fromLocal = data.from == DataPacket.ID_LOCAL - val toBroadcast = data.to == DataPacket.ID_BROADCAST - - // grab usernames from NodeInfo - val user = getUser(if (fromLocal) data.to else data.from) - val node = getNode(if (fromLocal) data.to else data.from) - - val shortName = user.shortName - val longName = - if (toBroadcast) { - channelSet.getChannel(data.channel)?.name ?: app.getString(R.string.channel_name) - } else { - user.longName - } - - Contact( - contactKey = contactKey, - shortName = if (toBroadcast) "${data.channel}" else shortName, - longName = longName, - lastMessageTime = getShortDate(data.time), - lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}", - unreadCount = packetRepository.getUnreadCount(contactKey), - messageCount = packetRepository.getMessageCount(contactKey), - isMuted = settings[contactKey]?.isMuted == true, - isUnmessageable = user.isUnmessagable, - nodeColors = - if (!toBroadcast) { - node.colors - } else { - null - }, - ) - } - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) - private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null) val sharedContactRequested: StateFlow get() = _sharedContactRequested.asStateFlow() @@ -396,12 +324,6 @@ constructor( _sharedContactRequested.value = sharedContact } - fun setMuteUntil(contacts: List, until: Long) = - viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) } - - fun deleteContacts(contacts: List) = - viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) } - // Connection state to our radio device val connectionState get() = serviceRepository.connectionState diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt index d6224fd00..0381af0f8 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt @@ -23,7 +23,6 @@ import androidx.navigation.compose.composable import androidx.navigation.navDeepLink import androidx.navigation.navigation import androidx.navigation.toRoute -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.contact.ContactsScreen import com.geeksville.mesh.ui.message.MessageScreen import com.geeksville.mesh.ui.message.QuickChatScreen @@ -34,13 +33,12 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.navigation.NodesRoutes @Suppress("LongMethod") -fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: UIViewModel) { +fun NavGraphBuilder.contactsGraph(navController: NavHostController) { navigation(startDestination = ContactsRoutes.Contacts) { composable( deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/contacts")), ) { ContactsScreen( - uiViewModel, onClickNodeChip = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) { launchSingleTop = true @@ -81,7 +79,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: ), ) { backStackEntry -> val message = backStackEntry.toRoute().message - ShareScreen(uiViewModel) { + ShareScreen { navController.navigate(ContactsRoutes.Messages(it, message)) { popUpTo { inclusive = true } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 7dccf1dfe..62ae55d1a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -410,7 +410,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode startDestination = NodesRoutes.NodesGraph, modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding().imePadding(), ) { - contactsGraph(navController, uiViewModel = uIViewModel) + contactsGraph(navController) nodesGraph(navController, uiViewModel = uIViewModel) mapGraph(navController) channelsGraph(navController, uiViewModel = uIViewModel) diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index 80e2c723f..e42148861 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -63,7 +63,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.AppOnlyProtos import com.geeksville.mesh.model.Contact -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.common.components.MainAppBar import com.geeksville.mesh.ui.node.components.NodeMenuAction import org.meshtastic.core.strings.R @@ -73,14 +72,14 @@ import java.util.concurrent.TimeUnit @Suppress("LongMethod") @Composable fun ContactsScreen( - uiViewModel: UIViewModel = hiltViewModel(), + viewModel: ContactsViewModel = hiltViewModel(), onClickNodeChip: (Int) -> Unit = {}, onNavigateToMessages: (String) -> Unit = {}, onNavigateToNodeDetails: (Int) -> Unit = {}, onNavigateToShare: () -> Unit, ) { - val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle() - val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() var showMuteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } @@ -89,7 +88,7 @@ fun ContactsScreen( val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } } // State for contacts list - val contacts by uiViewModel.contactList.collectAsStateWithLifecycle() + val contacts by viewModel.contactList.collectAsStateWithLifecycle() // Derived state for selected contacts and count val selectedContacts = @@ -116,7 +115,7 @@ fun ContactsScreen( if (contact.contactKey.contains("!")) { // if it's a node, look up the nodeNum including the ! val nodeKey = contact.contactKey.substring(1) - val node = uiViewModel.getNode(nodeKey) + val node = viewModel.getNode(nodeKey) if (node != null) { // navigate to node details. @@ -145,8 +144,8 @@ fun ContactsScreen( MainAppBar( title = stringResource(R.string.conversations), ourNode = ourNode, - isConnected = isConnected, - showNodeChip = ourNode != null && isConnected, + isConnected = connectionState.isConnected(), + showNodeChip = ourNode != null && connectionState.isConnected(), canNavigateUp = false, onNavigateUp = {}, actions = {}, @@ -160,7 +159,11 @@ fun ContactsScreen( }, floatingActionButton = { FloatingActionButton( - modifier = Modifier.animateFloatingActionButton(visible = isConnected, alignment = Alignment.BottomEnd), + modifier = + Modifier.animateFloatingActionButton( + visible = connectionState.isConnected(), + alignment = Alignment.BottomEnd, + ), onClick = onNavigateToShare, ) { Icon(Icons.Rounded.QrCode2, contentDescription = null) @@ -183,7 +186,7 @@ fun ContactsScreen( ) } - val channels by uiViewModel.channels.collectAsStateWithLifecycle() + val channels by viewModel.channels.collectAsStateWithLifecycle() ContactListView( contacts = contacts, selectedList = selectedContactKeys, @@ -200,7 +203,7 @@ fun ContactsScreen( onDismiss = { showDeleteDialog = false }, onConfirm = { showDeleteDialog = false - uiViewModel.deleteContacts(selectedContactKeys.toList()) + viewModel.deleteContacts(selectedContactKeys.toList()) selectedContactKeys.clear() }, ) @@ -210,7 +213,7 @@ fun ContactsScreen( onDismiss = { showMuteDialog = false }, onConfirm = { muteUntil -> showMuteDialog = false - uiViewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil) + viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil) selectedContactKeys.clear() }, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt new file mode 100644 index 000000000..abc01c5eb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt @@ -0,0 +1,135 @@ +/* + * 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.contact + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.channelSet +import com.geeksville.mesh.model.Contact +import com.geeksville.mesh.service.ServiceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.data.repository.PacketRepository +import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.database.entity.Packet +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.util.getChannel +import org.meshtastic.core.model.util.getShortDate +import org.meshtastic.core.strings.R +import javax.inject.Inject +import kotlin.collections.map + +@HiltViewModel +class ContactsViewModel +@Inject +constructor( + @ApplicationContext private val context: Context, + private val nodeRepository: NodeRepository, + private val packetRepository: PacketRepository, + radioConfigRepository: RadioConfigRepository, + serviceRepository: ServiceRepository, +) : ViewModel() { + val ourNodeInfo = nodeRepository.ourNodeInfo + + val connectionState = serviceRepository.connectionState + + val channels = + radioConfigRepository.channelSetFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + channelSet {}, + ) + + val contactList = + combine( + nodeRepository.myNodeInfo, + packetRepository.getContacts(), + channels, + packetRepository.getContactSettings(), + ) { myNodeInfo, contacts, channelSet, settings -> + val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() + // Add empty channel placeholders (always show Broadcast contacts, even when empty) + val placeholder = + (0 until channelSet.settingsCount).associate { ch -> + val contactKey = "$ch${DataPacket.ID_BROADCAST}" + val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) + contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) + } + + (contacts + (placeholder - contacts.keys)).values.map { packet -> + val data = packet.data + val contactKey = packet.contact_key + + // Determine if this is my message (originated on this device) + val fromLocal = data.from == DataPacket.ID_LOCAL + val toBroadcast = data.to == DataPacket.ID_BROADCAST + + // grab usernames from NodeInfo + val user = getUser(if (fromLocal) data.to else data.from) + val node = getNode(if (fromLocal) data.to else data.from) + + val shortName = user.shortName + val longName = + if (toBroadcast) { + channelSet.getChannel(data.channel)?.name ?: context.getString(R.string.channel_name) + } else { + user.longName + } + + Contact( + contactKey = contactKey, + shortName = if (toBroadcast) "${data.channel}" else shortName, + longName = longName, + lastMessageTime = getShortDate(data.time), + lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}", + unreadCount = packetRepository.getUnreadCount(contactKey), + messageCount = packetRepository.getMessageCount(contactKey), + isMuted = settings[contactKey]?.isMuted == true, + isUnmessageable = user.isUnmessagable, + nodeColors = + if (!toBroadcast) { + node.colors + } else { + null + }, + ) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + + fun deleteContacts(contacts: List) = + viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) } + + fun setMuteUntil(contacts: List, until: Long) = + viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) } + + private fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt index 4681e8d22..4bb59582d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt @@ -40,13 +40,13 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.model.Contact -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.contact.ContactItem +import com.geeksville.mesh.ui.contact.ContactsViewModel import org.meshtastic.core.strings.R import org.meshtastic.core.ui.theme.AppTheme @Composable -fun ShareScreen(viewModel: UIViewModel = hiltViewModel(), onConfirm: (String) -> Unit) { +fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit) { val contactList by viewModel.contactList.collectAsStateWithLifecycle() ShareScreen(contacts = contactList, onConfirm = onConfirm)