From 69ee5bab31ad9af24a9b485d93d3afb25e48dc3f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 6 Sep 2025 23:01:03 -0500 Subject: [PATCH] refactor(navigation)!: Make contacts screen a list/detail view The contacts screen has been refactored to use a list/detail pattern. The `ContactsScreen` composable has been replaced with `Contacts` and now manages the display of both the list of conversations and the detail view for a selected conversation using `NavigableListDetailPaneScaffold`. The `ContactsRoutes.Contacts` route has been removed and `ContactsRoutes.Messages` is now the entry point for the contacts graph. The `MessageScreen` is now displayed in the detail pane. The `MainAppBar` composable has been renamed to `GlobalAppBar` and the previously private `MainAppBar` component is now public. Deep link URI for messages has been updated to use query parameters. Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/navigation/ContactsRoutes.kt | 40 +--- .../mesh/service/MeshServiceNotifications.kt | 2 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 8 +- .../mesh/ui/common/components/MainAppBar.kt | 4 +- .../geeksville/mesh/ui/contact/Contacts.kt | 224 +++++++++++------- 5 files changed, 163 insertions(+), 115 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt index 980d84c4dd..4f7d7380ec 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsRoutes.kt @@ -24,16 +24,13 @@ 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.contact.Contacts import com.geeksville.mesh.ui.message.QuickChatScreen import com.geeksville.mesh.ui.sharing.ShareScreen import kotlinx.serialization.Serializable sealed class ContactsRoutes { - @Serializable data object Contacts : Route - - @Serializable data class Messages(val contactKey: String, val message: String = "") : Route + @Serializable data class Messages(val contactKey: String? = null, val message: String? = null) : Route @Serializable data class Share(val message: String) : Route @@ -44,35 +41,22 @@ sealed class ContactsRoutes { @Suppress("LongMethod") fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: UIViewModel) { - navigation(startDestination = ContactsRoutes.Contacts) { - composable( - deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/contacts")), - ) { - ContactsScreen( - uiViewModel, - onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, - onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }, - onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) }, - ) - } + navigation(startDestination = ContactsRoutes.Messages()) { composable( deepLinks = listOf( - navDeepLink( - basePath = - "$DEEP_LINK_BASE_URI/messages", // {contactKey} and ?message={message} are auto-appended - ), + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/contacts"), + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/messages"), ), ) { backStackEntry -> val args = backStackEntry.toRoute() - MessageScreen( + Contacts( contactKey = args.contactKey, message = args.message, - viewModel = uiViewModel, - navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, - navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }, - navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) }, - onNavigateBack = navController::navigateUp, + uiViewModel = uiViewModel, + onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }, + onNavigateToShare = { navController.navigate(ChannelsRoutes.Channels) }, + onNavigateToQuickChat = { navController.navigate(ContactsRoutes.QuickChat) }, ) } } @@ -84,9 +68,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: ), ), ) { backStackEntry -> - val message = backStackEntry.toRoute().message + val args = backStackEntry.toRoute() ShareScreen(uiViewModel) { - navController.navigate(ContactsRoutes.Messages(it, message)) { + navController.navigate(ContactsRoutes.Messages(contactKey = it, message = args.message)) { popUpTo { inclusive = true } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt index 7c86834445..2432440f9f 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt @@ -377,7 +377,7 @@ class MeshServiceNotifications(private val context: Context) { } private fun createOpenMessageIntent(contactKey: String): PendingIntent { - val deepLinkUri = "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri() + val deepLinkUri = "$DEEP_LINK_BASE_URI/messages?contactKey=$contactKey".toUri() val deepLinkIntent = Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP 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 3e8e0f0e84..609ae0c09d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -93,7 +93,7 @@ import com.geeksville.mesh.navigation.SettingsRoutes import com.geeksville.mesh.repository.radio.MeshActivity import com.geeksville.mesh.service.ConnectionState import com.geeksville.mesh.service.MeshService -import com.geeksville.mesh.ui.common.components.MainAppBar +import com.geeksville.mesh.ui.common.components.GlobalAppBar import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog import com.geeksville.mesh.ui.common.components.SimpleAlertDialog @@ -116,7 +116,7 @@ import kotlinx.coroutines.launch import kotlin.reflect.KClass enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) { - Conversations(R.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.ContactsGraph), + Conversations(R.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.Messages()), Nodes(R.string.nodes, MeshtasticIcons.Nodes, NodesRoutes.NodesGraph), Map(R.string.map, MeshtasticIcons.Map, MapRoutes.Map), Settings(R.string.bottom_nav_settings, MeshtasticIcons.Settings, SettingsRoutes.SettingsGraph()), @@ -125,7 +125,7 @@ enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, companion object { fun NavDestination.isTopLevel(): Boolean = listOf>( - ContactsRoutes.Contacts::class, + ContactsRoutes.Messages::class, NodesRoutes.Nodes::class, MapRoutes.Map::class, ConnectionsRoutes.Connections::class, @@ -343,7 +343,7 @@ fun MainScreen( if (sharedContact != null) { SharedContactDialog(contact = sharedContact, onDismiss = { sharedContact = null }) } - MainAppBar( + GlobalAppBar( viewModel = uIViewModel, navController = navController, onAction = { action -> diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt index 35030ddc04..94df925df8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt @@ -60,7 +60,7 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction @Suppress("CyclomaticComplexMethod") @Composable -fun MainAppBar( +fun GlobalAppBar( modifier: Modifier = Modifier, viewModel: UIViewModel = hiltViewModel(), navController: NavHostController, @@ -123,7 +123,7 @@ fun MainAppBar( @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -private fun MainAppBar( +fun MainAppBar( modifier: Modifier = Modifier, title: String, subtitle: String? = null, 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 a7fe621308..6adfef902d 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 @@ -17,6 +17,7 @@ package com.geeksville.mesh.ui.contact +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -44,13 +45,20 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.animateFloatingActionButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -66,18 +74,36 @@ import com.geeksville.mesh.AppOnlyProtos import com.geeksville.mesh.R 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.message.MessageScreen +import com.geeksville.mesh.ui.node.components.NodeMenuAction +import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongMethod") @Composable -fun ContactsScreen( +fun Contacts( + contactKey: String?, + message: String?, uiViewModel: UIViewModel = hiltViewModel(), - onNavigateToMessages: (String) -> Unit = {}, onNavigateToNodeDetails: (Int) -> Unit = {}, onNavigateToShare: () -> Unit, + onNavigateToQuickChat: () -> Unit, ) { + val navigator = rememberListDetailPaneScaffoldNavigator() + val coroutineScope = rememberCoroutineScope() val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle() + val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle() + + LaunchedEffect(contactKey) { + if (contactKey != null) { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) + } + } + + BackHandler(navigator.canNavigateBack()) { coroutineScope.launch { navigator.navigateBack() } } + var showMuteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } @@ -94,86 +120,124 @@ fun ContactsScreen( val selectedCount = remember(selectedContacts) { selectedContacts.sumOf { it.messageCount } } val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } } - // Callback functions for item interaction - val onContactClick: (Contact) -> Unit = { contact -> - if (isSelectionModeActive) { - // If in selection mode, toggle selection - if (selectedContactKeys.contains(contact.contactKey)) { - selectedContactKeys.remove(contact.contactKey) - } else { - selectedContactKeys.add(contact.contactKey) - } - } else { - // If not in selection mode, navigate to messages - onNavigateToMessages(contact.contactKey) - } - } - - val onNodeChipClick: (Contact) -> Unit = { contact -> - 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) - - if (node != null) { - // navigate to node details. - onNavigateToNodeDetails(node.num) - } - } else { - // Channels - } - } - - val onContactLongClick: (Contact) -> Unit = { contact -> - // Enter selection mode and select the item on long press - if (!isSelectionModeActive) { - selectedContactKeys.add(contact.contactKey) - } else { - // If already in selection mode, toggle selection - if (selectedContactKeys.contains(contact.contactKey)) { - selectedContactKeys.remove(contact.contactKey) - } else { - selectedContactKeys.add(contact.contactKey) - } - } - } - Scaffold( - topBar = { - if (isSelectionModeActive) { - // Display selection toolbar when in selection mode - SelectionToolbar( - selectedCount = selectedContactKeys.size, - onCloseSelection = { selectedContactKeys.clear() }, - onMuteSelected = { showMuteDialog = true }, - onDeleteSelected = { showDeleteDialog = true }, - onSelectAll = { - selectedContactKeys.clear() - selectedContactKeys.addAll(contacts.map { it.contactKey }) + NavigableListDetailPaneScaffold( + navigator = navigator, + listPane = { + AnimatedPane { + Scaffold( + floatingActionButton = { + FloatingActionButton( + modifier = + Modifier.animateFloatingActionButton( + visible = isConnected, + alignment = Alignment.BottomEnd, + ), + onClick = onNavigateToShare, + ) { + Icon(Icons.Rounded.QrCode2, contentDescription = null) + } }, - isAllMuted = isAllMuted, // Pass the derived state - ) + topBar = { + if (isSelectionModeActive) { + SelectionToolbar( + selectedCount = selectedContactKeys.size, + onCloseSelection = { selectedContactKeys.clear() }, + onMuteSelected = { showMuteDialog = true }, + onDeleteSelected = { showDeleteDialog = true }, + onSelectAll = { + selectedContactKeys.clear() + selectedContactKeys.addAll(contacts.map { it.contactKey }) + }, + isAllMuted = isAllMuted, // Pass the derived state + ) + } else { + MainAppBar( + title = stringResource(R.string.app_name), + subtitle = stringResource(R.string.conversations), + canNavigateUp = false, + ourNode = ourNode, + isConnected = isConnected, + showNodeChip = ourNode != null && isConnected, + onNavigateUp = {}, + actions = {}, + onAction = { + if (it is NodeMenuAction.MoreDetails) { + onNavigateToNodeDetails(it.node.num) + } else { + uiViewModel.handleNodeMenuAction(it) + } + }, + ) + } + }, + ) { contentPadding -> + Column(modifier = Modifier.padding(contentPadding)) { + val channels by uiViewModel.channels.collectAsStateWithLifecycle() + ConversationsListScreen( + contacts = contacts, + selectedList = selectedContactKeys, + onClick = { + if (isSelectionModeActive) { + // If in selection mode, toggle selection + if (selectedContactKeys.contains(it.contactKey)) { + selectedContactKeys.remove(it.contactKey) + } else { + selectedContactKeys.add(it.contactKey) + } + } else { + coroutineScope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it.contactKey) + } + } + }, + onLongClick = { + // Enter selection mode and select the item on long press + if (!isSelectionModeActive) { + selectedContactKeys.add(it.contactKey) + } else { + // If already in selection mode, toggle selection + if (selectedContactKeys.contains(it.contactKey)) { + selectedContactKeys.remove(it.contactKey) + } else { + selectedContactKeys.add(it.contactKey) + } + } + }, + channels = channels, + onNodeChipClick = { + if (it.contactKey.contains("!")) { + // if it's a node, look up the nodeNum including the ! + val nodeKey = it.contactKey.substring(1) + val node = uiViewModel.getNode(nodeKey) + + onNavigateToNodeDetails(node.num) + } else { + // Channels + } + }, + ) + } + } } }, - floatingActionButton = { - FloatingActionButton( - modifier = Modifier.animateFloatingActionButton(visible = isConnected, alignment = Alignment.BottomEnd), - onClick = onNavigateToShare, - ) { - Icon(Icons.Rounded.QrCode2, contentDescription = null) + detailPane = { + AnimatedPane { + navigator.currentDestination?.contentKey?.let { key -> + MessageScreen( + contactKey = key, + message = message ?: "", + viewModel = uiViewModel, + navigateToMessages = { + coroutineScope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) } + }, + navigateToNodeDetails = onNavigateToNodeDetails, + navigateToQuickChatOptions = onNavigateToQuickChat, + onNavigateBack = { coroutineScope.launch { navigator.navigateBack() } }, + ) + } } }, - ) { paddingValues -> - val channels by uiViewModel.channels.collectAsStateWithLifecycle() - ContactListView( - contacts = contacts, - selectedList = selectedContactKeys, - onClick = onContactClick, - onLongClick = onContactLongClick, - contentPadding = paddingValues, - channels = channels, - onNodeChipClick = onNodeChipClick, - ) - } + ) DeleteConfirmationDialog( showDialog = showDeleteDialog, selectedCount = selectedCount, @@ -346,12 +410,12 @@ fun SelectionToolbar( } @Composable -fun ContactListView( +fun ConversationsListScreen( contacts: List, selectedList: List, onClick: (Contact) -> Unit, onLongClick: (Contact) -> Unit, - contentPadding: PaddingValues, + contentPadding: PaddingValues = PaddingValues(0.dp), channels: AppOnlyProtos.ChannelSet? = null, onNodeChipClick: (Contact) -> Unit, ) {