mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor nav3 architecture and enhance adaptive layouts (#4944)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
3feec759a1
commit
f2d09ff79d
29 changed files with 740 additions and 617 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue