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

@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 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 org.meshtastic.core.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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) {
var backStacks: Map<NavKey, NavBackStack<NavKey>> = emptyMap()
var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab)
private set
val activeBackStack: NavBackStack<NavKey>
get() = backStacks[currentTabRoute] ?: error("Stack for $currentTabRoute not found")
/** Switches to a new top-level tab route. */
fun navigateTopLevel(route: NavKey) {
val rootKey = TopLevelDestination.fromNavKey(route)?.route ?: route
if (currentTabRoute == rootKey) {
// Repressing the same tab resets its stack to just the root
activeBackStack.replaceAll(listOf(rootKey))
} else {
// Switching to a different tab
currentTabRoute = rootKey
}
}
/** Handles back navigation according to the "exit through home" pattern. */
fun goBack() {
val currentStack = activeBackStack
if (currentStack.size > 1) {
currentStack.removeLastOrNull()
return
}
// If we're at the root of a non-start tab, switch back to the start tab
if (currentTabRoute != startTab) {
currentTabRoute = startTab
}
}
/** Sets the active tab and replaces its stack with the provided route path. */
fun handleDeepLink(navKeys: List<NavKey>) {
val rootKey = navKeys.firstOrNull() ?: return
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey
currentTabRoute = topLevel
val stack = backStacks[topLevel] ?: return
stack.replaceAll(navKeys)
}
}
/** Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. */
@Composable
fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack {
val stacks = mutableMapOf<NavKey, NavBackStack<NavKey>>()
TopLevelDestination.entries.forEach { dest ->
key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) }
}
val multiBackstack = remember { MultiBackstack(initialTab) }
multiBackstack.backStacks = stacks
return multiBackstack
}

View file

@ -19,16 +19,38 @@ package org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavKey
/**
* Replaces the current back stack with the given top-level route. Clears the back stack and sets the new route as the
* root destination.
* 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>.navigateTopLevel(route: NavKey) {
fun MutableList<NavKey>.replaceLast(route: NavKey) {
if (isNotEmpty()) {
this[0] = route
while (size > 1) {
removeAt(lastIndex)
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

@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 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 org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlin.test.Test
import kotlin.test.assertEquals
class MultiBackstackTest {
@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 nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
multiBackstack.backStacks =
mapOf(TopLevelDestination.Nodes.route to nodesStack, TopLevelDestination.Map.route to mapStack)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute)
assertEquals(2, multiBackstack.activeBackStack.size)
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(2, nodesStack.size)
}
@Test
fun `navigateTopLevel to same tab resets stack to root`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
assertEquals(2, multiBackstack.activeBackStack.size)
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
}
@Test
fun `goBack pops current stack if size is greater than 1`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val nodesStack =
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
multiBackstack.goBack()
assertEquals(1, multiBackstack.activeBackStack.size)
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
}
@Test
fun `goBack on root of non-start tab returns to start tab`() {
val startTab = TopLevelDestination.Connections.route
val multiBackstack = MultiBackstack(startTab)
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
val connectionsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Connections.route)) }
multiBackstack.backStacks =
mapOf(TopLevelDestination.Map.route to mapStack, TopLevelDestination.Connections.route to connectionsStack)
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
multiBackstack.goBack()
assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute)
}
@Test
fun `handleDeepLink sets target tab and populates stack`() {
val startTab = TopLevelDestination.Nodes.route
val multiBackstack = MultiBackstack(startTab)
val settingsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Settings.route)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Settings.route to settingsStack)
val deepLinkPath = listOf(TopLevelDestination.Settings.route, SettingsRoutes.About)
multiBackstack.handleDeepLink(deepLinkPath)
assertEquals(TopLevelDestination.Settings.route, multiBackstack.currentTabRoute)
assertEquals(2, multiBackstack.activeBackStack.size)
assertEquals(SettingsRoutes.About, multiBackstack.activeBackStack.last())
}
}