refactor: improve navigation backstack handling and screen decoupling

This commit refactors the navigation logic across `feature:node` and `feature:messaging` to use more robust backstack manipulation helpers and decouple screen implementations from navigation parameters.

Specific changes include:
- **Navigation Helpers**: Added `replaceLast` and `replaceAll` extensions to `MutableList<NavKey>` in `core:navigation` to allow more granular control over backstack mutations while minimizing structural changes.
- **Node Feature**:
    - Updated `NodesRoutes.NodeDetail` entry to directly instantiate `NodeDetailViewModel` and `CompassViewModel` using Koin, removing the reliance on `AdaptiveNodeListScreen` for detail rendering.
    - Simplified `AdaptiveNodeListScreen` by removing unused navigation and initialization parameters.
- **Messaging Feature**:
    - Refactored `ContactsRoutes.Messages` entry to handle ViewModel lifecycle and contact key initialization directly within the navigation graph.
    - Updated `ShareScreen` to use the new `replaceLast` helper for smoother transitions to message screens.
    - Simplified `AdaptiveContactsScreen` and `ContactsEntryContent` by removing unused `detailPaneCustom` and initialization logic.
- **Deep Linking**: Updated `MeshtasticAppShell` to use `backStack.replaceAll(navKeys)` when handling deep links, ensuring a cleaner backstack state transition.

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-26 17:28:51 -05:00
parent 4b4b68c443
commit 02ea30d665
6 changed files with 72 additions and 55 deletions

View File

@@ -24,7 +24,9 @@ import androidx.navigation3.runtime.NavKey
*/
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
if (isNotEmpty()) {
this[0] = route
if (this[0] != route) {
this[0] = route
}
while (size > 1) {
removeAt(lastIndex)
}
@@ -32,3 +34,41 @@ fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
add(route)
}
}
/**
* Replaces the last entry in the back stack with the given route.
* If the back stack is empty, it simply adds the route.
*/
fun MutableList<NavKey>.replaceLast(route: NavKey) {
if (isNotEmpty()) {
if (this[lastIndex] != route) {
this[lastIndex] = route
}
} else {
add(route)
}
}
/**
* Replaces the entire back stack with the given routes in a way that minimizes structural changes
* and prevents the back stack from temporarily becoming empty.
*/
fun MutableList<NavKey>.replaceAll(routes: List<NavKey>) {
if (routes.isEmpty()) {
clear()
return
}
for (i in routes.indices) {
if (i < size) {
// Only mutate if the route actually changed, protecting Nav3's internal state matching.
if (this[i] != routes[i]) {
this[i] = routes[i]
}
} else {
add(routes[i])
}
}
while (size > routes.size) {
removeAt(lastIndex)
}
}

View File

@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.replaceAll
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
@@ -39,8 +40,7 @@ fun MeshtasticAppShell(
) {
LaunchedEffect(uiViewModel) {
uiViewModel.navigationDeepLink.collect { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
backStack.replaceAll(navKeys)
}
}

View File

@@ -28,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
@@ -50,11 +51,20 @@ fun EntryProviderScope<NavKey>.contactsGraph(
}
entry<ContactsRoutes.Messages>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
ContactsEntryContent(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialContactKey = args.contactKey,
initialMessage = args.message,
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() },
)
}
@@ -63,10 +73,8 @@ fun EntryProviderScope<NavKey>.contactsGraph(
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() },
)
@@ -82,8 +90,6 @@ fun EntryProviderScope<NavKey>.contactsGraph(
fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
@@ -93,30 +99,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

@@ -25,7 +25,6 @@ import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@@ -33,16 +32,12 @@ import org.meshtastic.proto.SharedContact
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,
) {
ContactsScreen(
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },

View File

@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.node.list.NodeListScreen
import org.meshtastic.feature.node.list.NodeListViewModel
@@ -32,9 +31,6 @@ import org.meshtastic.feature.node.list.NodeListViewModel
fun AdaptiveNodeListScreen(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()

View File

@@ -53,6 +53,9 @@ import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
@@ -76,8 +79,6 @@ fun EntryProviderScope<NavKey>.nodesGraph(
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
@@ -86,8 +87,6 @@ fun EntryProviderScope<NavKey>.nodesGraph(
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
@@ -106,21 +105,21 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
entry<NodesRoutes.NodeDetail>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
val nodeDetailViewModel: NodeDetailViewModel = koinViewModel()
val compassViewModel: CompassViewModel = koinViewModel()
val destNum = args.destNum ?: 0 // Handle nullable destNum if needed
NodeDetailScreen(
nodeId = destNum,
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
navigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
onNavigateUp = { backStack.removeLastOrNull() },
)
}