Refactor nav3 architecture and enhance adaptive layouts (#4944)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-27 09:43:44 -05:00 committed by GitHub
parent 3feec759a1
commit f2d09ff79d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 740 additions and 617 deletions

View file

@ -16,6 +16,8 @@
*/
package org.meshtastic.feature.messaging.navigation
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.replaceLast
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.QuickChatViewModel
@ -33,55 +36,54 @@ import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod")
fun EntryProviderScope<NavKey>.contactsGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
) {
entry<ContactsRoutes.ContactsGraph> {
entry<ContactsRoutes.ContactsGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
}
entry<ContactsRoutes.Contacts> {
entry<ContactsRoutes.Contacts>(metadata = { ListDetailSceneStrategy.listPane() }) {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
}
entry<ContactsRoutes.Messages> { args ->
ContactsEntryContent(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialContactKey = args.contactKey,
initialMessage = args.message,
entry<ContactsRoutes.Messages>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
val contactKey = args.contactKey
val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel =
koinViewModel(key = "messages-$contactKey")
messageViewModel.setContactKey(contactKey)
org.meshtastic.feature.messaging.MessageScreen(
contactKey = contactKey,
message = args.message,
viewModel = messageViewModel,
navigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) },
onNavigateBack = { backStack.removeLastOrNull() },
)
}
entry<ContactsRoutes.Share> { args ->
entry<ContactsRoutes.Share>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val message = args.message
val viewModel = koinViewModel<ContactsViewModel>()
ShareScreen(
viewModel = viewModel,
onConfirm = {
// Navigation 3 - replace Top with Messages manually, but for now we just pop and add
backStack.removeLastOrNull()
backStack.add(ContactsRoutes.Messages(it, message))
},
onConfirm = { contactKey -> backStack.replaceLast(ContactsRoutes.Messages(contactKey, message)) },
onNavigateUp = { backStack.removeLastOrNull() },
)
}
entry<ContactsRoutes.QuickChat> {
entry<ContactsRoutes.QuickChat>(metadata = { ListDetailSceneStrategy.extraPane() }) {
val viewModel = koinViewModel<QuickChatViewModel>()
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
}
@Composable
fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
fun ContactsEntryContent(backStack: NavBackStack<NavKey>, scrollToTopEvents: Flow<ScrollToTopEvent>) {
val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
@ -90,30 +92,11 @@ fun ContactsEntryContent(
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = koinViewModel(), // Ignored by custom detail pane below
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleDeepLink = uiViewModel::handleDeepLink,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = initialContactKey,
initialMessage = initialMessage,
detailPaneCustom = { contactKey ->
val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel =
koinViewModel(key = "messages-$contactKey")
messageViewModel.setContactKey(contactKey)
org.meshtastic.feature.messaging.MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
viewModel = messageViewModel,
navigateToNodeDetails = {
backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it))
},
navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) },
onNavigateBack = { backStack.removeLastOrNull() },
)
},
)
}

View file

@ -16,118 +16,41 @@
*/
package org.meshtastic.feature.messaging.ui.contact
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveContactsScreen(
backStack: NavBackStack<NavKey>,
contactsViewModel: ContactsViewModel,
messageViewModel: MessageViewModel,
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
initialMessage: String = "",
detailPaneCustom: @Composable ((contactKey: String) -> Unit)? = null,
) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
val onBackToGraph: () -> Unit = {
val currentKey = backStack.lastOrNull()
if (
currentKey is ContactsRoutes.Messages ||
currentKey is ContactsRoutes.Contacts ||
currentKey is ContactsRoutes.ContactsGraph
) {
// Check if we navigated here from another screen (e.g., from Nodes or Map)
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
val isFromDifferentGraph =
previousKey != null &&
previousKey !is ContactsRoutes.ContactsGraph &&
previousKey !is ContactsRoutes.Contacts &&
previousKey !is ContactsRoutes.Messages
if (isFromDifferentGraph) {
// Navigate back via NavController to return to the previous screen (e.g. Node Details)
backStack.removeLastOrNull()
}
}
}
AdaptiveListDetailScaffold(
navigator = navigator,
ContactsScreen(
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleDeepLink = onHandleDeepLink,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
viewModel = contactsViewModel,
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToMessages = { contactKey -> backStack.add(ContactsRoutes.Messages(contactKey)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
scrollToTopEvents = scrollToTopEvents,
onBackToGraph = onBackToGraph,
onTabPressedEvent = { it is ScrollToTopEvent.ConversationsTabPressed },
initialKey = initialContactKey,
listPane = { isActive, activeContactKey ->
ContactsScreen(
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleDeepLink = onHandleDeepLink,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
viewModel = contactsViewModel,
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToMessages = { contactKey ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
},
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
scrollToTopEvents = scrollToTopEvents,
activeContactKey = activeContactKey,
)
},
detailPane = { contentKey, handleBack ->
if (detailPaneCustom != null) {
detailPaneCustom(contentKey)
} else {
MessageScreen(
contactKey = contentKey,
message = if (contentKey == initialContactKey) initialMessage else "",
viewModel = messageViewModel,
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) },
onNavigateBack = handleBack,
)
}
},
emptyDetailPane = {
EmptyDetailPlaceholder(
icon = MeshtasticIcons.Conversations,
title = stringResource(Res.string.conversations),
)
},
activeContactKey = null,
)
}