From df4f10c4d64a273688aa9dbe5284ac3737756310 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 18 May 2026 12:29:27 -0500 Subject: [PATCH] fix(nav): remote admin nodenum + Nav3 consolidation and improvements (#5478) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 2 +- .../app/ui/NavigationAssemblyTest.kt | 2 +- .../core/navigation/DeepLinkRouter.kt | 31 +++-- .../core/navigation/MultiBackstack.kt | 27 ++++- .../org/meshtastic/core/navigation/Routes.kt | 24 +--- .../core/navigation/TopLevelDestination.kt | 8 +- .../core/navigation/DeepLinkRouterTest.kt | 108 ++++++++---------- .../core/navigation/MultiBackstackTest.kt | 22 ++-- .../core/navigation/NavBackStackExtTest.kt | 38 +++--- .../core/navigation/NavigationConfigTest.kt | 10 -- core/ui/detekt-baseline.xml | 2 +- .../core/ui/component/MeshtasticAppShell.kt | 2 +- .../core/ui/component/MeshtasticNavDisplay.kt | 62 ++++++---- .../ui/component/MeshtasticNavigationSuite.kt | 5 +- .../DesktopTopLevelDestinationParityTest.kt | 8 +- .../navigation/ConnectionsNavigation.kt | 10 -- feature/messaging/detekt-baseline.xml | 2 +- .../navigation/ContactsNavigation.kt | 4 - .../ui/contact/AdaptiveContactsScreen.kt | 2 +- .../node/navigation/AdaptiveNodeListScreen.kt | 2 +- .../node/navigation/NodesNavigation.kt | 36 ++---- .../settings/navigation/SettingsNavigation.kt | 30 ++--- .../radio/channel/ChannelsNavigation.kt | 8 -- 23 files changed, 196 insertions(+), 249 deletions(-) diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 46409b14e..9177c7edb 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -60,7 +60,7 @@ fun MainScreen() { if (viewModel.currentDeviceAddressFlow.value.isNullOrSelectedNone()) { TopLevelDestination.Connections.route } else { - NodesRoute.NodesGraph + NodesRoute.Nodes } val multiBackstack = rememberMultiBackstack(initialTab) val backStack = multiBackstack.activeBackStack diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index 1fd4b39ce..8f3bf2c71 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -43,7 +43,7 @@ class NavigationAssemblyTest { @Test fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest { setContent { - val backStack = rememberNavBackStack(NodesRoute.NodesGraph) + val backStack = rememberNavBackStack(NodesRoute.Nodes) entryProvider { contactsGraph(backStack, emptyFlow()) nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow()) diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt index 2351cd876..f88dabc60 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt @@ -60,7 +60,7 @@ object DeepLinkRouter { "quickchat", -> routeContacts(uri, pathSegments) - "connections" -> listOf(ConnectionsRoute.ConnectionsGraph) + "connections" -> listOf(ConnectionsRoute.Connections) "map" -> routeMap(uri, pathSegments) @@ -68,7 +68,7 @@ object DeepLinkRouter { "settings" -> routeSettings(pathSegments) - "channels" -> listOf(ChannelsRoute.ChannelsGraph) + "channels" -> listOf(ChannelsRoute.Channels) "firmware" -> routeFirmware(pathSegments) @@ -86,27 +86,24 @@ object DeepLinkRouter { return when (firstSegment) { "share" -> { val message = uri.getQueryParameter("message") ?: "" - listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share(message)) + listOf(ContactsRoute.Contacts, ContactsRoute.Share(message)) } "quickchat" -> { - listOf(ContactsRoute.ContactsGraph, ContactsRoute.QuickChat) + listOf(ContactsRoute.Contacts, ContactsRoute.QuickChat) } "messages" -> { val contactKey = if (segments.size > 1) segments[1] else uri.getQueryParameter("contactKey") ?: "" val message = uri.getQueryParameter("message") ?: "" if (contactKey.isNotBlank()) { - listOf( - ContactsRoute.ContactsGraph, - ContactsRoute.Messages(contactKey = contactKey, message = message), - ) + listOf(ContactsRoute.Contacts, ContactsRoute.Messages(contactKey = contactKey, message = message)) } else { - listOf(ContactsRoute.ContactsGraph) + listOf(ContactsRoute.Contacts) } } - else -> listOf(ContactsRoute.ContactsGraph) + else -> listOf(ContactsRoute.Contacts) } } @@ -121,17 +118,17 @@ object DeepLinkRouter { val destNum = destNumStr?.toIntOrNull() return if (destNum == null) { - listOf(NodesRoute.NodesGraph) + listOf(NodesRoute.Nodes) } else if (segments.size > 2) { val subRouteStr = segments[2].lowercase() val detailRouteFn = nodeDetailSubRoutes[subRouteStr] if (detailRouteFn != null) { - listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetailGraph(destNum), detailRouteFn(destNum)) + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum), detailRouteFn(destNum)) } else { - listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum)) + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum)) } } else { - listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum)) + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum)) } } @@ -153,14 +150,14 @@ object DeepLinkRouter { } if (subRouteStr == null) { - return listOf(SettingsRoute.SettingsGraph(destNum)) + return listOf(SettingsRoute.Settings(destNum)) } val subRoute = settingsSubRoutes[subRouteStr] return if (subRoute != null) { - listOf(SettingsRoute.SettingsGraph(destNum), subRoute) + listOf(SettingsRoute.Settings(destNum), subRoute) } else { - listOf(SettingsRoute.SettingsGraph(destNum)) + listOf(SettingsRoute.Settings(destNum)) } } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt index 067ee2ae7..49f1f55a6 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/MultiBackstack.kt @@ -17,20 +17,23 @@ package org.meshtastic.core.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack /** Manages independent backstacks for multiple tabs. */ -class MultiBackstack(val startTab: NavKey) { +class MultiBackstack(val startTab: NavKey, private val currentTabState: MutableState) { var backStacks: Map> = emptyMap() - var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab) + var currentTabRoute: NavKey by currentTabState private set val activeBackStack: NavBackStack @@ -73,6 +76,19 @@ class MultiBackstack(val startTab: NavKey) { } } +/** Saver that persists the active tab ordinal across process death. */ +private val CurrentTabSaver = + Saver, Int>( + save = { state -> + TopLevelDestination.entries.indexOfFirst { it.route::class == state.value::class }.takeIf { it >= 0 } + }, + restore = { ordinal -> + mutableStateOf( + TopLevelDestination.entries.getOrNull(ordinal)?.route ?: TopLevelDestination.Connections.route, + ) + }, + ) + /** Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. */ @Composable fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack { @@ -82,7 +98,12 @@ fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections. key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) } } - val multiBackstack = remember { MultiBackstack(initialTab) } + val currentTabState = + rememberSaveable(saver = CurrentTabSaver) { + mutableStateOf(TopLevelDestination.fromNavKey(initialTab)?.route ?: initialTab) + } + + val multiBackstack = remember { MultiBackstack(initialTab, currentTabState) } multiBackstack.backStacks = stacks return multiBackstack diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index d8682c40c..a0df53eb3 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -27,23 +27,17 @@ interface Graph : Route @Serializable sealed interface ChannelsRoute : Route { - @Serializable data object ChannelsGraph : ChannelsRoute, Graph - - @Serializable data object Channels : ChannelsRoute + @Serializable data object Channels : ChannelsRoute, Graph } @Serializable sealed interface ConnectionsRoute : Route { - @Serializable data object ConnectionsGraph : ConnectionsRoute, Graph - - @Serializable data object Connections : ConnectionsRoute + @Serializable data object Connections : ConnectionsRoute, Graph } @Serializable sealed interface ContactsRoute : Route { - @Serializable data object ContactsGraph : ContactsRoute, Graph - - @Serializable data object Contacts : ContactsRoute + @Serializable data object Contacts : ContactsRoute, Graph @Serializable data class Messages(val contactKey: String, val message: String = "") : ContactsRoute @@ -59,13 +53,7 @@ sealed interface MapRoute : Route { @Serializable sealed interface NodesRoute : Route { - @Serializable data object NodesGraph : NodesRoute, Graph - - @Serializable data object Nodes : NodesRoute - - @Serializable data class NodeDetailGraph(val destNum: Int? = null) : - NodesRoute, - Graph + @Serializable data object Nodes : NodesRoute, Graph @Serializable data class NodeDetail(val destNum: Int? = null) : NodesRoute } @@ -96,12 +84,10 @@ sealed interface NodeDetailRoute : Route { @Serializable sealed interface SettingsRoute : Route { - @Serializable data class SettingsGraph(val destNum: Int? = null) : + @Serializable data class Settings(val destNum: Int? = null) : SettingsRoute, Graph - @Serializable data class Settings(val destNum: Int? = null) : SettingsRoute - @Serializable data object DeviceConfiguration : SettingsRoute @Serializable data object ModuleConfiguration : SettingsRoute diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt index 8c8c4eea7..635ecfb7f 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/TopLevelDestination.kt @@ -32,11 +32,11 @@ import org.meshtastic.core.resources.nodes * and Desktop navigation shells. */ enum class TopLevelDestination(val label: StringResource, val route: Route) { - Conversations(Res.string.conversations, ContactsRoute.ContactsGraph), - Nodes(Res.string.nodes, NodesRoute.NodesGraph), + Conversations(Res.string.conversations, ContactsRoute.Contacts), + Nodes(Res.string.nodes, NodesRoute.Nodes), Map(Res.string.map, MapRoute.Map()), - Settings(Res.string.bottom_nav_settings, SettingsRoute.SettingsGraph()), - Connections(Res.string.connections, ConnectionsRoute.ConnectionsGraph), + Settings(Res.string.bottom_nav_settings, SettingsRoute.Settings()), + Connections(Res.string.connections, ConnectionsRoute.Connections), ; companion object { diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt index 04bda7472..c6fc642bd 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt @@ -47,25 +47,25 @@ class DeepLinkRouterTest { @Test fun `share with message`() { assertEquals( - listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share("hello world")), + listOf(ContactsRoute.Contacts, ContactsRoute.Share("hello world")), route("/share?message=hello%20world"), ) } @Test fun `share without message defaults to empty string`() { - assertEquals(listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share("")), route("/share")) + assertEquals(listOf(ContactsRoute.Contacts, ContactsRoute.Share("")), route("/share")) } @Test fun `quickchat routes to QuickChat`() { - assertEquals(listOf(ContactsRoute.ContactsGraph, ContactsRoute.QuickChat), route("/quickchat")) + assertEquals(listOf(ContactsRoute.Contacts, ContactsRoute.QuickChat), route("/quickchat")) } @Test fun `messages with contactKey path segment`() { assertEquals( - listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "abc123", message = "")), + listOf(ContactsRoute.Contacts, ContactsRoute.Messages(contactKey = "abc123", message = "")), route("/messages/abc123"), ) } @@ -73,7 +73,7 @@ class DeepLinkRouterTest { @Test fun `messages with contactKey query param`() { assertEquals( - listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "")), + listOf(ContactsRoute.Contacts, ContactsRoute.Messages(contactKey = "contact1", message = "")), route("/messages?contactKey=contact1"), ) } @@ -81,14 +81,14 @@ class DeepLinkRouterTest { @Test fun `messages with contactKey and message`() { assertEquals( - listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "hi")), + listOf(ContactsRoute.Contacts, ContactsRoute.Messages(contactKey = "contact1", message = "hi")), route("/messages/contact1?message=hi"), ) } @Test fun `messages without contactKey returns graph only`() { - assertEquals(listOf(ContactsRoute.ContactsGraph), route("/messages")) + assertEquals(listOf(ContactsRoute.Contacts), route("/messages")) } // endregion @@ -96,8 +96,8 @@ class DeepLinkRouterTest { // region connections @Test - fun `connections routes to ConnectionsGraph`() { - assertEquals(listOf(ConnectionsRoute.ConnectionsGraph), route("/connections")) + fun `connections routes to Connections`() { + assertEquals(listOf(ConnectionsRoute.Connections), route("/connections")) } // endregion @@ -129,21 +129,21 @@ class DeepLinkRouterTest { // region nodes @Test - fun `nodes root returns NodesGraph`() { - assertEquals(listOf(NodesRoute.NodesGraph), route("/nodes")) + fun `nodes root returns Nodes`() { + assertEquals(listOf(NodesRoute.Nodes), route("/nodes")) } @Test fun `nodes with destNum returns NodeDetail`() { - assertEquals(listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 1234)), route("/nodes/1234")) + assertEquals(listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 1234)), route("/nodes/1234")) } @Test fun `nodes with destNum and device-metrics sub-route`() { assertEquals( listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 1234), + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 1234), NodeDetailRoute.DeviceMetrics(destNum = 1234), ), route("/nodes/1234/device-metrics"), @@ -154,8 +154,8 @@ class DeepLinkRouterTest { fun `nodes with destNum and map sub-route`() { assertEquals( listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 5678), + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 5678), NodeDetailRoute.PositionLog(destNum = 5678), ), route("/nodes/5678/map"), @@ -165,11 +165,7 @@ class DeepLinkRouterTest { @Test fun `nodes with destNum and position sub-route`() { assertEquals( - listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), - NodeDetailRoute.PositionLog(destNum = 100), - ), + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.PositionLog(destNum = 100)), route("/nodes/100/position"), ) } @@ -178,8 +174,8 @@ class DeepLinkRouterTest { fun `nodes with destNum and environment sub-route`() { assertEquals( listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.EnvironmentMetrics(destNum = 100), ), route("/nodes/100/environment"), @@ -190,8 +186,8 @@ class DeepLinkRouterTest { fun `nodes with destNum and signal sub-route`() { assertEquals( listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.SignalMetrics(destNum = 100), ), route("/nodes/100/signal"), @@ -201,11 +197,7 @@ class DeepLinkRouterTest { @Test fun `nodes with destNum and power sub-route`() { assertEquals( - listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), - NodeDetailRoute.PowerMetrics(destNum = 100), - ), + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.PowerMetrics(destNum = 100)), route("/nodes/100/power"), ) } @@ -214,8 +206,8 @@ class DeepLinkRouterTest { fun `nodes with destNum and traceroute sub-route`() { assertEquals( listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.TracerouteLog(destNum = 100), ), route("/nodes/100/traceroute"), @@ -226,8 +218,8 @@ class DeepLinkRouterTest { fun `nodes with destNum and host-metrics sub-route`() { assertEquals( listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.HostMetricsLog(destNum = 100), ), route("/nodes/100/host-metrics"), @@ -237,11 +229,7 @@ class DeepLinkRouterTest { @Test fun `nodes with destNum and pax sub-route`() { assertEquals( - listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), - NodeDetailRoute.PaxMetrics(destNum = 100), - ), + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.PaxMetrics(destNum = 100)), route("/nodes/100/pax"), ) } @@ -250,8 +238,8 @@ class DeepLinkRouterTest { fun `nodes with destNum and neighbors sub-route`() { assertEquals( listOf( - NodesRoute.NodesGraph, - NodesRoute.NodeDetailGraph(destNum = 100), + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 100), NodeDetailRoute.NeighborInfoLog(destNum = 100), ), route("/nodes/100/neighbors"), @@ -260,20 +248,17 @@ class DeepLinkRouterTest { @Test fun `nodes with destNum and unknown sub-route falls back to NodeDetail`() { - assertEquals( - listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 1234)), - route("/nodes/1234/unknown-sub"), - ) + assertEquals(listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 1234)), route("/nodes/1234/unknown-sub")) } @Test - fun `nodes with non-numeric destNum returns NodesGraph only`() { - assertEquals(listOf(NodesRoute.NodesGraph), route("/nodes/not-a-number")) + fun `nodes with non-numeric destNum returns Nodes only`() { + assertEquals(listOf(NodesRoute.Nodes), route("/nodes/not-a-number")) } @Test fun `nodes with destNum query param`() { - assertEquals(listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 9999)), route("/nodes?destNum=9999")) + assertEquals(listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 9999)), route("/nodes?destNum=9999")) } // endregion @@ -281,31 +266,28 @@ class DeepLinkRouterTest { // region settings @Test - fun `settings root returns SettingsGraph`() { - assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null)), route("/settings")) + fun `settings root returns Settings`() { + assertEquals(listOf(SettingsRoute.Settings(destNum = null)), route("/settings")) } @Test fun `settings with destNum`() { - assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = 1234)), route("/settings/1234")) + assertEquals(listOf(SettingsRoute.Settings(destNum = 1234)), route("/settings/1234")) } @Test fun `settings with destNum and sub-route`() { - assertEquals( - listOf(SettingsRoute.SettingsGraph(destNum = 1234), SettingsRoute.About), - route("/settings/1234/about"), - ) + assertEquals(listOf(SettingsRoute.Settings(destNum = 1234), SettingsRoute.About), route("/settings/1234/about")) } @Test fun `settings with sub-route without destNum`() { - assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null), SettingsRoute.LoRa), route("/settings/lora")) + assertEquals(listOf(SettingsRoute.Settings(destNum = null), SettingsRoute.LoRa), route("/settings/lora")) } @Test - fun `settings with unknown sub-route returns SettingsGraph only`() { - assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null)), route("/settings/nonexistent-page")) + fun `settings with unknown sub-route returns Settings only`() { + assertEquals(listOf(SettingsRoute.Settings(destNum = null)), route("/settings/nonexistent-page")) } @Test @@ -349,7 +331,7 @@ class DeepLinkRouterTest { expectedSubRoutes.forEach { (slug, expectedRoute) -> assertEquals( - listOf(SettingsRoute.SettingsGraph(destNum = null), expectedRoute), + listOf(SettingsRoute.Settings(destNum = null), expectedRoute), route("/settings/$slug"), "Settings sub-route '$slug' did not resolve to $expectedRoute", ) @@ -361,8 +343,8 @@ class DeepLinkRouterTest { // region channels @Test - fun `channels routes to ChannelsGraph`() { - assertEquals(listOf(ChannelsRoute.ChannelsGraph), route("/channels")) + fun `channels routes to Channels`() { + assertEquals(listOf(ChannelsRoute.Channels), route("/channels")) } // endregion @@ -402,8 +384,8 @@ class DeepLinkRouterTest { @Test fun `route segments are case insensitive`() { - assertEquals(listOf(NodesRoute.NodesGraph), route("/Nodes")) - assertEquals(listOf(ConnectionsRoute.ConnectionsGraph), route("/CONNECTIONS")) + assertEquals(listOf(NodesRoute.Nodes), route("/Nodes")) + assertEquals(listOf(ConnectionsRoute.Connections), route("/CONNECTIONS")) } // endregion diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt index c36375356..4ec1f7139 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.navigation +import androidx.compose.runtime.mutableStateOf import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlin.test.Test @@ -23,10 +24,15 @@ import kotlin.test.assertEquals class MultiBackstackTest { + private fun createMultiBackstack(startTab: NavKey): MultiBackstack { + val tabRoute = TopLevelDestination.fromNavKey(startTab)?.route ?: startTab + return MultiBackstack(startTab, mutableStateOf(tabRoute)) + } + @Test fun `navigateTopLevel to different tab preserves previous tab stack and activates new tab stack`() { val startTab = TopLevelDestination.Nodes.route - val multiBackstack = MultiBackstack(startTab) + val multiBackstack = createMultiBackstack(startTab) val nodesStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoute.Nodes)) } @@ -48,7 +54,7 @@ class MultiBackstackTest { @Test fun `navigateTopLevel to same tab resets stack to root`() { val startTab = TopLevelDestination.Nodes.route - val multiBackstack = MultiBackstack(startTab) + val multiBackstack = createMultiBackstack(startTab) val nodesStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoute.Nodes)) } @@ -65,7 +71,7 @@ class MultiBackstackTest { @Test fun `goBack pops current stack if size is greater than 1`() { val startTab = TopLevelDestination.Nodes.route - val multiBackstack = MultiBackstack(startTab) + val multiBackstack = createMultiBackstack(startTab) val nodesStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoute.Nodes)) } @@ -80,7 +86,7 @@ class MultiBackstackTest { @Test fun `goBack on root of non-start tab returns to start tab`() { val startTab = TopLevelDestination.Connections.route - val multiBackstack = MultiBackstack(startTab) + val multiBackstack = createMultiBackstack(startTab) val mapStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Map.route)) } val connectionsStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Connections.route)) } @@ -99,7 +105,7 @@ class MultiBackstackTest { @Test fun `handleDeepLink sets target tab and populates stack`() { val startTab = TopLevelDestination.Nodes.route - val multiBackstack = MultiBackstack(startTab) + val multiBackstack = createMultiBackstack(startTab) val settingsStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Settings.route)) } multiBackstack.backStacks = mapOf(TopLevelDestination.Settings.route to settingsStack) @@ -116,7 +122,7 @@ class MultiBackstackTest { fun `handleDeepLink from different tab switches tab and sets stack`() { // Start on Connections tab val startTab = TopLevelDestination.Connections.route - val multiBackstack = MultiBackstack(startTab) + val multiBackstack = createMultiBackstack(startTab) val connectionsStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Connections.route)) } val nodesStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route)) } @@ -133,13 +139,13 @@ class MultiBackstackTest { // Deep-link to a TracerouteMap on the Nodes tab (this is the exact pattern // MeshtasticAppShell uses for traceroute alert "View on Map") val tracerouteMap = NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 42, logUuid = "abc") - multiBackstack.handleDeepLink(listOf(NodesRoute.NodesGraph, tracerouteMap)) + multiBackstack.handleDeepLink(listOf(NodesRoute.Nodes, tracerouteMap)) // Should have switched to the Nodes tab assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute) // Stack should contain the graph root + the traceroute map route assertEquals(2, multiBackstack.activeBackStack.size) - assertEquals(NodesRoute.NodesGraph, multiBackstack.activeBackStack.first()) + assertEquals(NodesRoute.Nodes, multiBackstack.activeBackStack.first()) assertEquals(tracerouteMap, multiBackstack.activeBackStack.last()) } } diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt index 2f013a39c..8ffb917e6 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt @@ -26,21 +26,21 @@ class NavBackStackExtTest { @Test fun `replaceLast on non-empty list replaces the last element`() { - val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes) + val stack = mutableListOf(NodesRoute.Nodes, NodesRoute.Nodes) stack.replaceLast(NodesRoute.NodeDetail(destNum = 42)) assertEquals(2, stack.size) - assertEquals(NodesRoute.NodesGraph, stack[0]) + assertEquals(NodesRoute.Nodes, stack[0]) assertEquals(NodesRoute.NodeDetail(destNum = 42), stack[1]) } @Test fun `replaceLast on single-element list replaces that element`() { - val stack = mutableListOf(NodesRoute.NodesGraph) - stack.replaceLast(SettingsRoute.SettingsGraph()) + val stack = mutableListOf(NodesRoute.Nodes) + stack.replaceLast(SettingsRoute.Settings()) assertEquals(1, stack.size) - assertEquals(SettingsRoute.SettingsGraph(), stack[0]) + assertEquals(SettingsRoute.Settings(), stack[0]) } @Test @@ -55,7 +55,7 @@ class NavBackStackExtTest { @Test fun `replaceLast with same element does not mutate`() { val route = NodesRoute.Nodes - val stack = mutableListOf(NodesRoute.NodesGraph, route) + val stack = mutableListOf(NodesRoute.Nodes, route) stack.replaceLast(route) assertEquals(2, stack.size) @@ -68,8 +68,8 @@ class NavBackStackExtTest { @Test fun `replaceAll replaces entire stack with new routes`() { - val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes) - val newRoutes = listOf(SettingsRoute.SettingsGraph(), SettingsRoute.About) + val stack = mutableListOf(NodesRoute.Nodes, NodesRoute.Nodes) + val newRoutes = listOf(SettingsRoute.Settings(), SettingsRoute.About) stack.replaceAll(newRoutes) @@ -78,19 +78,19 @@ class NavBackStackExtTest { @Test fun `replaceAll with shorter list trims excess elements`() { - val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 42)) - val newRoutes = listOf(SettingsRoute.SettingsGraph()) + val stack = mutableListOf(NodesRoute.Nodes, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 42)) + val newRoutes = listOf(SettingsRoute.Settings()) stack.replaceAll(newRoutes) assertEquals(1, stack.size) - assertEquals(SettingsRoute.SettingsGraph(), stack[0]) + assertEquals(SettingsRoute.Settings(), stack[0]) } @Test fun `replaceAll with longer list appends new elements`() { - val stack = mutableListOf(NodesRoute.NodesGraph) - val newRoutes = listOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 99)) + val stack = mutableListOf(NodesRoute.Nodes) + val newRoutes = listOf(NodesRoute.Nodes, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 99)) stack.replaceAll(newRoutes) @@ -99,7 +99,7 @@ class NavBackStackExtTest { @Test fun `replaceAll with empty list clears the stack`() { - val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes) + val stack = mutableListOf(NodesRoute.Nodes, NodesRoute.Nodes) stack.replaceAll(emptyList()) @@ -109,7 +109,7 @@ class NavBackStackExtTest { @Test fun `replaceAll on empty stack with new routes populates it`() { val stack = mutableListOf() - val newRoutes = listOf(ContactsRoute.ContactsGraph, ContactsRoute.Contacts) + val newRoutes = listOf(ContactsRoute.Contacts, ContactsRoute.Contacts) stack.replaceAll(newRoutes) @@ -118,7 +118,7 @@ class NavBackStackExtTest { @Test fun `replaceAll with identical routes does not mutate entries`() { - val routes = listOf(NodesRoute.NodesGraph, NodesRoute.Nodes) + val routes = listOf(NodesRoute.Nodes, NodesRoute.Nodes) val stack = routes.toMutableList() stack.replaceAll(routes) @@ -128,17 +128,17 @@ class NavBackStackExtTest { @Test fun `replaceAll with partial overlap only changes differing elements`() { - val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 1)) + val stack = mutableListOf(NodesRoute.Nodes, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 1)) val newRoutes = listOf( - NodesRoute.NodesGraph, // same + NodesRoute.Nodes, // same SettingsRoute.About, // different ) stack.replaceAll(newRoutes) assertEquals(2, stack.size) - assertEquals(NodesRoute.NodesGraph, stack[0]) + assertEquals(NodesRoute.Nodes, stack[0]) assertEquals(SettingsRoute.About, stack[1]) } diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt index 293c567fc..2f7ae59af 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt @@ -38,13 +38,10 @@ class NavigationConfigTest { private val allRouteInstances: List = listOf( // ChannelsRoute - ChannelsRoute.ChannelsGraph, ChannelsRoute.Channels, // ConnectionsRoute - ConnectionsRoute.ConnectionsGraph, ConnectionsRoute.Connections, // ContactsRoute - ContactsRoute.ContactsGraph, ContactsRoute.Contacts, ContactsRoute.Messages(contactKey = "test-contact", message = "hello"), ContactsRoute.Messages(contactKey = "test-contact"), @@ -54,10 +51,7 @@ class NavigationConfigTest { MapRoute.Map(), MapRoute.Map(waypointId = 42), // NodesRoute - NodesRoute.NodesGraph, NodesRoute.Nodes, - NodesRoute.NodeDetailGraph(destNum = 1234), - NodesRoute.NodeDetailGraph(), NodesRoute.NodeDetail(destNum = 5678), NodesRoute.NodeDetail(), // NodeDetailRoute @@ -73,8 +67,6 @@ class NavigationConfigTest { NodeDetailRoute.PaxMetrics(destNum = 100), NodeDetailRoute.NeighborInfoLog(destNum = 100), // SettingsRoute - SettingsRoute.SettingsGraph(), - SettingsRoute.SettingsGraph(destNum = 999), SettingsRoute.Settings(), SettingsRoute.Settings(destNum = 999), SettingsRoute.DeviceConfiguration, @@ -183,9 +175,7 @@ class NavigationConfigTest { val routesWithDefaults: List> = listOf( MapRoute.Map() to MapRoute.Map(waypointId = null), - NodesRoute.NodeDetailGraph() to NodesRoute.NodeDetailGraph(destNum = null), NodesRoute.NodeDetail() to NodesRoute.NodeDetail(destNum = null), - SettingsRoute.SettingsGraph() to SettingsRoute.SettingsGraph(destNum = null), SettingsRoute.Settings() to SettingsRoute.Settings(destNum = null), WifiProvisionRoute.WifiProvision() to WifiProvisionRoute.WifiProvision(address = null), ) diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml index ab18a483f..11672934f 100644 --- a/core/ui/detekt-baseline.xml +++ b/core/ui/detekt-baseline.xml @@ -127,7 +127,7 @@ PreviewPublic:SwitchPreference.kt:@Preview(showBackground = true) @Composable fun SwitchPreferencePreview PreviewPublic:TextDividerPreference.kt:@Preview(showBackground = true) @Composable fun TextDividerPreferencePreview PreviewPublic:TitledCard.kt:@PreviewLightDark @Composable fun TitledCardPreview - ViewModelForwarding:MeshtasticAppShell.kt:MeshtasticCommonAppSetup( uiViewModel = uiViewModel, onNavigateToTracerouteMap = { destNum, requestId, logUuid -> multiBackstack.handleDeepLink( listOf( NodesRoute.NodesGraph, NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), ), ) }, ) + ViewModelForwarding:MeshtasticAppShell.kt:MeshtasticCommonAppSetup( uiViewModel = uiViewModel, onNavigateToTracerouteMap = { destNum, requestId, logUuid -> multiBackstack.handleDeepLink( listOf( NodesRoute.Nodes, NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), ), ) }, ) ViewModelForwarding:MeshtasticCommonAppSetup.kt:FirmwareVersionCheck(viewModel = uiViewModel) ViewModelForwarding:MeshtasticCommonAppSetup.kt:SharedDialogs(uiViewModel = uiViewModel) ViewModelForwarding:MeshtasticCommonAppSetup.kt:TracerouteAlertHandler(uiViewModel = uiViewModel, onNavigateToMap = onNavigateToTracerouteMap) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt index 95a64ff95..9f6077e2d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt @@ -46,7 +46,7 @@ fun MeshtasticAppShell( onNavigateToTracerouteMap = { destNum, requestId, logUuid -> multiBackstack.handleDeepLink( listOf( - NodesRoute.NodesGraph, + NodesRoute.Nodes, NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), ), ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt index 08d63c605..c05cb71be 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavDisplay.kt @@ -18,6 +18,8 @@ package org.meshtastic.core.ui.component import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -68,7 +70,7 @@ fun MeshtasticNavDisplay( /** Shared [NavDisplay] wrapper for a single backstack. */ @Suppress("LongMethod") -@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalSharedTransitionApi::class) @Composable fun MeshtasticNavDisplay( backStack: NavBackStack, @@ -115,28 +117,32 @@ fun MeshtasticNavDisplay( val activeDecorators = remember(backStack, saveableDecorator, vmStoreDecorator) { listOf(saveableDecorator, vmStoreDecorator) } - NavDisplay( - backStack = backStack, - entryProvider = entryProvider, - entryDecorators = activeDecorators, - onBack = - onBack - ?: { - if (backStack.size > 1) { - backStack.removeLastOrNull() - } - }, - sceneStrategies = - listOf( - DialogSceneStrategy(), - listDetailSceneStrategy, - supportingPaneSceneStrategy, - SinglePaneSceneStrategy(), - ), - transitionSpec = meshtasticTransitionSpec(), - popTransitionSpec = meshtasticTransitionSpec(), - modifier = modifier, - ) + SharedTransitionLayout { + NavDisplay( + backStack = backStack, + entryProvider = entryProvider, + entryDecorators = activeDecorators, + onBack = + onBack + ?: { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } + }, + sceneStrategies = + listOf( + DialogSceneStrategy(), + listDetailSceneStrategy, + supportingPaneSceneStrategy, + SinglePaneSceneStrategy(), + ), + sharedTransitionScope = this@SharedTransitionLayout, + transitionSpec = meshtasticTransitionSpec(), + popTransitionSpec = meshtasticTransitionSpec(), + predictivePopTransitionSpec = meshtasticPredictivePopTransitionSpec(), + modifier = modifier, + ) + } } /** Shared crossfade [ContentTransform] used for both forward and pop navigation. */ @@ -146,3 +152,13 @@ private fun meshtasticTransitionSpec(): AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { + ContentTransform( + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)), + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)), + ) + } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt index f23f082b5..de477fc4a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt @@ -141,7 +141,7 @@ private fun handleNavigation( val currentKey = multiBackstack.activeBackStack.lastOrNull() when (destination) { TopLevelDestination.Nodes -> { - val onNodesList = currentKey is NodesRoute.NodesGraph || currentKey is NodesRoute.Nodes + val onNodesList = currentKey is NodesRoute.Nodes if (!onNodesList) { multiBackstack.navigateTopLevel(destination.route) } else { @@ -150,8 +150,7 @@ private fun handleNavigation( } TopLevelDestination.Conversations -> { - val onConversationsList = - currentKey is ContactsRoute.ContactsGraph || currentKey is ContactsRoute.Contacts + val onConversationsList = currentKey is ContactsRoute.Contacts if (!onConversationsList) { multiBackstack.navigateTopLevel(destination.route) } else { diff --git a/desktopApp/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt b/desktopApp/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt index b77d6a222..ac81053ef 100644 --- a/desktopApp/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt +++ b/desktopApp/src/test/kotlin/org/meshtastic/desktop/ui/DesktopTopLevelDestinationParityTest.kt @@ -41,11 +41,11 @@ class DesktopTopLevelDestinationParityTest { val androidParityRoutes: Set> = setOf( - ContactsRoute.ContactsGraph::class, - NodesRoute.NodesGraph::class, + ContactsRoute.Contacts::class, + NodesRoute.Nodes::class, MapRoute.Map::class, - SettingsRoute.SettingsGraph::class, - ConnectionsRoute.ConnectionsGraph::class, + SettingsRoute.Settings::class, + ConnectionsRoute.Connections::class, ) assertEquals( diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index 7b91f0975..23fa20d59 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -28,16 +28,6 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel /** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoute.Connections]. */ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) { - entry { - ConnectionsScreen( - scanModel = koinViewModel(), - radioConfigViewModel = koinViewModel(), - onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, - onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, - onConfigNavigate = { route -> backStack.add(route) }, - ) - } - entry { ConnectionsScreen( scanModel = koinViewModel(), diff --git a/feature/messaging/detekt-baseline.xml b/feature/messaging/detekt-baseline.xml index 2986c9292..6363e9375 100644 --- a/feature/messaging/detekt-baseline.xml +++ b/feature/messaging/detekt-baseline.xml @@ -35,6 +35,6 @@ PreviewPublic:QuickChatPreviews.kt:@PreviewLightDark @Composable fun EditQuickChatDialogPreview PreviewPublic:QuickChatPreviews.kt:@PreviewLightDark @Composable fun QuickChatItemPreview PreviewPublic:ReactionPreviews.kt:@PreviewLightDark @Composable fun ReactionItemPreview - ViewModelForwarding:AdaptiveContactsScreen.kt:ContactsScreen( onNavigateToShare = { backStack.add(ChannelsRoute.ChannelsGraph) }, sharedContactRequested = sharedContactRequested, requestChannelSet = requestChannelSet, onHandleDeepLink = onHandleDeepLink, onClearSharedContactRequested = onClearSharedContactRequested, onClearRequestChannelUrl = onClearRequestChannelUrl, viewModel = contactsViewModel, onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, onNavigateToMessages = { contactKey -> backStack.add(ContactsRoute.Messages(contactKey)) }, onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, scrollToTopEvents = scrollToTopEvents, activeContactKey = null, ) + ViewModelForwarding:AdaptiveContactsScreen.kt:ContactsScreen( onNavigateToShare = { backStack.add(ChannelsRoute.Channels) }, sharedContactRequested = sharedContactRequested, requestChannelSet = requestChannelSet, onHandleDeepLink = onHandleDeepLink, onClearSharedContactRequested = onClearSharedContactRequested, onClearRequestChannelUrl = onClearRequestChannelUrl, viewModel = contactsViewModel, onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, onNavigateToMessages = { contactKey -> backStack.add(ContactsRoute.Messages(contactKey)) }, onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, scrollToTopEvents = scrollToTopEvents, activeContactKey = null, ) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index f6c27c3c6..899ae7344 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -45,10 +45,6 @@ fun EntryProviderScope.contactsGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), ) { - entry(metadata = { ListDetailSceneStrategy.listPane() }) { - ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) - } - entry(metadata = { ListDetailSceneStrategy.listPane() }) { ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index c87b9143f..278ad2f45 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -40,7 +40,7 @@ fun AdaptiveContactsScreen( onClearRequestChannelUrl: () -> Unit, ) { ContactsScreen( - onNavigateToShare = { backStack.add(ChannelsRoute.ChannelsGraph) }, + onNavigateToShare = { backStack.add(ChannelsRoute.Channels) }, sharedContactRequested = sharedContactRequested, requestChannelSet = requestChannelSet, onHandleDeepLink = onHandleDeepLink, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index f10f456ac..882e078f3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -39,7 +39,7 @@ fun AdaptiveNodeListScreen( NodeListScreen( viewModel = nodeListViewModel, navigateToNodeDetails = { nodeId -> backStack.add(NodesRoute.NodeDetail(nodeId)) }, - onNavigateToChannels = { backStack.add(ChannelsRoute.ChannelsGraph) }, + onNavigateToChannels = { backStack.add(ChannelsRoute.Channels) }, scrollToTopEvents = scrollToTopEvents, activeNodeId = null, onHandleDeepLink = onHandleDeepLink, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 47d34c9c5..ee4955c32 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -76,15 +76,6 @@ fun EntryProviderScope.nodesGraph( onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, onNavigateToConnections: () -> Unit = {}, ) { - entry(metadata = { ListDetailSceneStrategy.listPane() }) { - AdaptiveNodeListScreen( - backStack = backStack, - scrollToTopEvents = scrollToTopEvents, - onHandleDeepLink = onHandleDeepLink, - onNavigateToConnections = onNavigateToConnections, - ) - } - entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( backStack = backStack, @@ -94,30 +85,16 @@ fun EntryProviderScope.nodesGraph( ) } - nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink, onNavigateToConnections) + nodeDetailGraph(backStack) } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Suppress("LongMethod") -fun EntryProviderScope.nodeDetailGraph( - backStack: NavBackStack, - scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, - onNavigateToConnections: () -> Unit = {}, -) { - entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> - AdaptiveNodeListScreen( - backStack = backStack, - scrollToTopEvents = scrollToTopEvents, - onHandleDeepLink = onHandleDeepLink, - onNavigateToConnections = onNavigateToConnections, - ) - } - +fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) { entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> val nodeDetailViewModel: NodeDetailViewModel = koinViewModel() val compassViewModel: CompassViewModel = koinViewModel() - val destNum = args.destNum ?: 0 // Handle nullable destNum if needed + val destNum = args.destNum ?: 0 NodeDetailScreen( nodeId = destNum, viewModel = nodeDetailViewModel, @@ -149,7 +126,12 @@ fun EntryProviderScope.nodeDetailGraph( entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current - tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() } + tracerouteMapScreen( + args.destNum, + args.requestId, + args.logUuid, + dropUnlessResumed { backStack.removeLastOrNull() }, + ) } NodeDetailScreen.entries.forEach { routeInfo -> diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index d0e4f4a00..18e2707bb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -73,14 +73,12 @@ import org.meshtastic.feature.settings.radio.component.UserConfigScreen import kotlin.reflect.KClass @Composable -fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewModel { +fun getRadioConfigViewModel(backStack: NavBackStack, destNumOverride: Int? = null): RadioConfigViewModel { val destNum = - remember(backStack.toList()) { - backStack.lastOrNull { it is SettingsRoute.Settings }?.let { (it as SettingsRoute.Settings).destNum } - ?: backStack - .lastOrNull { it is SettingsRoute.SettingsGraph } - ?.let { (it as SettingsRoute.SettingsGraph).destNum } - } + destNumOverride + ?: remember(backStack.toList()) { + backStack.lastOrNull { it is SettingsRoute.Settings }?.let { (it as SettingsRoute.Settings).destNum } + } return koinViewModel(key = destNum?.toString()) { parametersOf(SavedStateHandle(mapOf("destNum" to destNum))) } @@ -88,22 +86,14 @@ fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewMod @Suppress("LongMethod", "CyclomaticComplexMethod") fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { - entry { + entry { args -> + val isTabRoot = backStack.firstOrNull() == args SettingsMainScreen( settingsViewModel = koinViewModel(), - radioConfigViewModel = getRadioConfigViewModel(backStack), + radioConfigViewModel = getRadioConfigViewModel(backStack, destNumOverride = args.destNum), onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, onNavigate = { backStack.add(it) }, - ) - } - - entry { - SettingsMainScreen( - settingsViewModel = koinViewModel(), - radioConfigViewModel = getRadioConfigViewModel(backStack), - onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, - onNavigate = { backStack.add(it) }, - onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + onBack = if (isTabRoot) null else dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -236,7 +226,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { } } - entry { TakServerScreen(onBack = { backStack.removeLastOrNull() }) } + entry { TakServerScreen(onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } entry { val viewModel: DebugViewModel = koinViewModel() diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt index 428b6602c..6218a0a90 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt @@ -26,14 +26,6 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel /** Navigation graph for for the top level ChannelScreen - [ChannelsRoute.Channels]. */ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { - entry { - ChannelScreen( - radioConfigViewModel = koinViewModel(), - onNavigate = { route -> backStack.add(route) }, - onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, - ) - } - entry { ChannelScreen( radioConfigViewModel = koinViewModel(),