kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
				
				
				
			Decouple contacts nav graph from `UiViewModel` (#3215)
							rodzic
							
								
									3e83e61a1a
								
							
						
					
					
						commit
						6d5e56b34f
					
				|  | @ -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<Node?> | ||||
|         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<AdminProtos.SharedContact?> = MutableStateFlow(null) | ||||
|     val sharedContactRequested: StateFlow<AdminProtos.SharedContact?> | ||||
|         get() = _sharedContactRequested.asStateFlow() | ||||
|  | @ -396,12 +324,6 @@ constructor( | |||
|         _sharedContactRequested.value = sharedContact | ||||
|     } | ||||
| 
 | ||||
|     fun setMuteUntil(contacts: List<String>, until: Long) = | ||||
|         viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) } | ||||
| 
 | ||||
|     fun deleteContacts(contacts: List<String>) = | ||||
|         viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) } | ||||
| 
 | ||||
|     // Connection state to our radio device | ||||
|     val connectionState | ||||
|         get() = serviceRepository.connectionState | ||||
|  |  | |||
|  | @ -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<ContactsRoutes.ContactsGraph>(startDestination = ContactsRoutes.Contacts) { | ||||
|         composable<ContactsRoutes.Contacts>( | ||||
|             deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(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<ContactsRoutes.Share>().message | ||||
|         ShareScreen(uiViewModel) { | ||||
|         ShareScreen { | ||||
|             navController.navigate(ContactsRoutes.Messages(it, message)) { | ||||
|                 popUpTo<ContactsRoutes.Share> { inclusive = true } | ||||
|             } | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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() | ||||
|         }, | ||||
|     ) | ||||
|  |  | |||
|  | @ -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 <https://www.gnu.org/licenses/>. | ||||
|  */ | ||||
| 
 | ||||
| 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<String>) = | ||||
|         viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) } | ||||
| 
 | ||||
|     fun setMuteUntil(contacts: List<String>, until: Long) = | ||||
|         viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) } | ||||
| 
 | ||||
|     private fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) | ||||
| } | ||||
|  | @ -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) | ||||
|  |  | |||
		Ładowanie…
	
		Reference in New Issue
	
	 Phil Oliver
						Phil Oliver