From 917a444bf0c56d7bdfad0415274e71d604a31c68 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Mon, 21 Jul 2025 15:16:50 +0100 Subject: [PATCH 1/3] Adding tests for Navigator --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 5 + .../navigator/basic/NavigatorActivity.kt | 184 ++++++++++++++++++ .../navigator/basic/NavigatorTest.kt | 76 ++++++++ 4 files changed, 266 insertions(+) create mode 100644 app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt create mode 100644 app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1b2051..e42693a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,4 +91,5 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + testImplementation(kotlin("test")) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e99f46a..dafb614 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,11 @@ android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.Nav3Recipes"/> + = listOf(Home, ChatList, Camera) + +class NavigatorActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val navigator = remember { Navigator(Home) } + + Scaffold( + bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { topLevelRoute -> + + val isSelected = topLevelRoute == navigator.topLevelRoute + NavigationBarItem( + selected = isSelected, + onClick = { + navigator.navigate(topLevelRoute) + }, + icon = { + Icon( + imageVector = topLevelRoute.icon, + contentDescription = null + ) + } + ) + } + } + } + ) { _ -> + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider { + entry{ + ContentRed("Home screen") + } + entry{ + ContentGreen("Chat list screen"){ + Button(onClick = { navigator.navigate(ChatDetail) }) { + Text("Go to conversation") + } + } + } + entry{ + ContentBlue("Chat detail screen") + } + entry{ + ContentPurple("Camera screen") + } + }, + ) + } + } + } +} + +class Navigator( + startRoute: T, + private val canStartRouteMove: Boolean = false, + private val shouldPopOtherTopLevelRoutesWhenNavigatingToTopLevelRoute: Boolean = true, + private val shouldRemoveChildRoutesWhenNavigatingBack: Boolean = false +) { + + // Maintain a stack for each top level route + private var topLevelStacks : LinkedHashMap> = linkedMapOf( + startRoute to mutableStateListOf(startRoute) + ) + + // Expose the current top level route for consumers + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Expose the back stack so it can be rendered by the NavDisplay + val backStack = mutableStateListOf(startRoute) + + private fun updateBackStack() = + backStack.apply { + clear() + addAll(topLevelStacks.flatMap { it.value }) + } + + private fun navigateToTopLevel(key: T){ + + // Remove any other top level stacks first + if (shouldPopOtherTopLevelRoutesWhenNavigatingToTopLevelRoute) + topLevelStacks.keys.filter { it != key }.forEach { topLevelStacks.remove(it) } + + val doesStackExist = topLevelStacks.keys.contains(key) + + if (doesStackExist){ + // Move it to the end of the stacks + topLevelStacks.apply { + remove(key)?.let { + put(key, it) + } + } + } else { + topLevelStacks.put(key, mutableStateListOf(key)) + } + topLevelRoute = key + updateBackStack() + } + + fun navigate(key: T){ + if (key is Route.TopLevel){ + navigateToTopLevel(key) + } else { + topLevelStacks[topLevelRoute]?.add(key) + } + updateBackStack() + } + + fun goBack(){ + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + } + + interface Route { + interface TopLevel + interface Unique // Non-top level route that is unique on the back stack (can move between top level stacks) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt new file mode 100644 index 0000000..5433f7b --- /dev/null +++ b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt @@ -0,0 +1,76 @@ +package com.example.nav3recipes.navigator.basic + +import androidx.compose.foundation.layout.add +import org.junit.Test +import kotlin.test.assertEquals + +class NavigatorTest { + + private data object Home : Navigator.Route.TopLevel + private data object ChatList : Navigator.Route.TopLevel + private data object ChatDetail + private data object Camera : Navigator.Route.TopLevel + private data object Search : Navigator.Route.Unique + + @Test + fun backStackContainsStartKey(){ + val navigator = Navigator(startRoute = Home) + assert(navigator.backStack.contains(Home)) + } + + @Test + fun navigatingToTopRoute_addsRouteToTopOfStack(){ + val navigator = Navigator(startRoute = Home) + + // Back stack start state [Home] + // Navigate to ChatList + // Expected back stack state [Home, ChatList] + navigator.navigate(ChatList) + assertEquals(listOf(Home, ChatList), navigator.backStack) + } + + @Test + fun addingNonTopLevelRoute_addsToCurrentTopLevelStack() { + val navigator = Navigator(startRoute = Home) // Current: Home, Stack: [Home] + + // Navigate to ChatList, making it the current top-level route + navigator.navigate(ChatList) + // Current: ChatList, Stack: [Home, ChatList] + assertEquals(listOf(Home, ChatList), navigator.backStack, "Backstack after adding ChatList") + assertEquals(ChatList, navigator.topLevelRoute, "Current top level route should be ChatList") + + // Add ChatDetail (non-top-level) to the ChatList stack + navigator.navigate(ChatDetail) + // Current: ChatList, Stack: [Home, ChatList, ChatDetail] + assertEquals(listOf(Home, ChatList, ChatDetail), navigator.backStack, "Backstack after adding ChatDetail") + } + + @Test + fun navigatingToNewTopLevel_withDefaultConfig_popsOtherTopLevelAndItsChildren() { + // Default config: shouldPopOtherTopLevelRoutesWhenNavigatingToTopLevelRoute = true + val navigator = Navigator(startRoute = Home) + navigator.navigate(Search) // BackStack: [Home, Search] + navigator.navigate(Camera) // BackStack: [Home, Search, Camera] + navigator.navigate(ChatList) + val expected = listOf(Home, Search, ChatList) // Camera is popped before ChatList is added + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingToNewTopLevel_whenStartRouteCannotMove_popsOtherTopLevelStacksExceptStartRoute() { + val navigator = Navigator(startRoute = Home) + navigator.navigate(Camera) + val expected = listOf(Home, Camera) // Home is locked in place + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingToNewTopLevel_whenStartRouteCanMove_popsAllOtherTopLevelStacks() { + val navigator = Navigator(startRoute = Home, canStartRouteMove = true) + navigator.navigate(Camera) + val expected = listOf(Camera) // Home is popped + assertEquals(expected, navigator.backStack) + } + + +} \ No newline at end of file From e88d4707dab0273f5f4eb3f1714916ca5d4c42e6 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Wed, 30 Jul 2025 23:23:33 +0100 Subject: [PATCH 2/3] First draft of Navigator for nested and shared destinations --- .../nav3recipes/navigator/basic/Navigator.kt | 123 ++++++++++++++++ .../navigator/basic/NavigatorActivity.kt | 129 +++++++---------- .../example/nav3recipes/ExampleUnitTest.kt | 33 ----- .../navigator/basic/NavigatorTest.kt | 132 ++++++++++++------ 4 files changed, 261 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt delete mode 100644 app/src/test/java/com/example/nav3recipes/ExampleUnitTest.kt diff --git a/app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt b/app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt new file mode 100644 index 0000000..d1d751a --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt @@ -0,0 +1,123 @@ +package com.example.nav3recipes.navigator.basic + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * This class models navigation behavior. It provides a back stack + * as a Compose snapshot-state backed list that can be used with a `NavDisplay`. + * + * It supports a single level of nested navigation. Top level + * routes can be defined using the `Route` class and setting + * `isTopLevel` to `true`. It also supports shared routes. + * These are routes that can be nested under multiple top level + * routes, though only one instance of the route will ever be + * present in the stack. Shared routes can be defined using + * `Route.isShared`. + * + * The start route is always the first item in the back stack and + * cannot be moved. Navigating to the start route removes all other + * top level routes and their associated stacks. + * + * @param startRoute - The start route for the back stack. + * @param canTopLevelRoutesExistTogether - Determines whether other + * top level routes can exist together on the back stack. Default `false`, + * meaning other top level routes (and their stacks) will be popped off + * the back stack when navigating to a top level route. + * + * For example, if A, B and C are all top level routes: + * + * ``` + * val navigator = Navigator(startRoute = A) // back stack is [A] + * navigator.navigate(B) // back stack [A, B] + * navigator.navigate(C) // back stack [A, C] - B is popped before C is added + * + * When set to `true`, the resulting back stack would be [A, B, C] + * ``` + * + * @see `NavigatorTest`. + */ +class Navigator( + private val startRoute: T, + private val canTopLevelRoutesExistTogether: Boolean = false +) { + + val backStack = mutableStateListOf(startRoute) + var topLevelRoute by mutableStateOf(startRoute) + private set + + // Maintain a stack for each top level route + private var topLevelStacks : LinkedHashMap> = linkedMapOf( + startRoute to mutableListOf(startRoute) + ) + + private fun updateBackStack() = + backStack.apply { + clear() + addAll(topLevelStacks.flatMap { it.value }) + } + + private fun navigateToTopLevel(route: T){ + + if (route == startRoute){ + clearAllExceptStartStack() + } else { + + // Get the existing stack or create a new one. + val topLevelStack = topLevelStacks.remove(route) ?: mutableListOf(route) + + if (!canTopLevelRoutesExistTogether) { + clearAllExceptStartStack() + } + + topLevelStacks.put(route, topLevelStack) + } + + topLevelRoute = route + } + + private fun clearAllExceptStartStack(){ + // Remove all other top level stacks, except the start stack + val startStack = topLevelStacks[startRoute]!! + topLevelStacks.clear() + topLevelStacks.put(startRoute, startStack) + } + + /** + * Navigate to the given route. + */ + fun navigate(route: T){ + if (route.isTopLevel){ + navigateToTopLevel(route) + } else { + if (route.isShared){ + // If the key is already in a stack, remove it + topLevelStacks.forEach { stack -> + if (stack.value.contains(route)){ + topLevelStacks[stack.key]?.remove(route) + } + } + } + topLevelStacks[topLevelRoute]?.add(route) + } + updateBackStack() + } + + /** + * Go back to the previous route. + */ + fun goBack(){ + val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelRoute = topLevelStacks.keys.last() + updateBackStack() + } +} + +abstract class Route( + val isTopLevel : Boolean = false, + val isShared : Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt b/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt index ce40ec4..decda42 100644 --- a/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt @@ -19,41 +19,46 @@ package com.example.nav3recipes.navigator.basic import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Face import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.entry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentPink import com.example.nav3recipes.content.ContentPurple import com.example.nav3recipes.content.ContentRed import com.example.nav3recipes.ui.setEdgeToEdgeConfig -import kotlin.collections.remove - -private sealed interface NavBarItem : Navigator.Route.TopLevel { - val icon: ImageVector -} -private data object Home : NavBarItem { override val icon = Icons.Default.Home } -private data object ChatList : NavBarItem { override val icon = Icons.Default.Face } -private data object ChatDetail -private data object Camera : NavBarItem { override val icon = Icons.Default.PlayArrow } +private abstract class NavBarItem(val icon: ImageVector): Route(isTopLevel = true) +private data object Home : NavBarItem(icon = Icons.Default.Home) +private data object ChatList : NavBarItem(icon = Icons.Default.Face) +private data object ChatDetail : Route() +private data object Camera : NavBarItem(icon = Icons.Default.PlayArrow) +private data object Search : Route(isShared = true) private val TOP_LEVEL_ROUTES : List = listOf(Home, ChatList, Camera) @@ -62,9 +67,12 @@ class NavigatorActivity : ComponentActivity() { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { - val navigator = remember { Navigator(Home) } + val navigator = remember { Navigator(Home) } Scaffold( + topBar = { + TopAppBarWithSearch { navigator.navigate(Search) } + }, bottomBar = { NavigationBar { TOP_LEVEL_ROUTES.forEach { topLevelRoute -> @@ -85,8 +93,9 @@ class NavigatorActivity : ComponentActivity() { } } } - ) { _ -> + ) { paddingValues -> NavDisplay( + modifier = Modifier.padding(paddingValues), backStack = navigator.backStack, onBack = { navigator.goBack() }, entryProvider = entryProvider { @@ -102,10 +111,22 @@ class NavigatorActivity : ComponentActivity() { } entry{ ContentBlue("Chat detail screen") + } entry{ ContentPurple("Camera screen") } + entry{ + ContentPink("Search screen"){ + var text by rememberSaveable { mutableStateOf("") } + TextField( + value = text, + onValueChange = { newText -> text = newText}, + label = { Text("Enter search here") }, + singleLine = true + ) + } + } }, ) } @@ -113,72 +134,24 @@ class NavigatorActivity : ComponentActivity() { } } -class Navigator( - startRoute: T, - private val canStartRouteMove: Boolean = false, - private val shouldPopOtherTopLevelRoutesWhenNavigatingToTopLevelRoute: Boolean = true, - private val shouldRemoveChildRoutesWhenNavigatingBack: Boolean = false +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBarWithSearch( + onSearchClick: () -> Unit ) { - - // Maintain a stack for each top level route - private var topLevelStacks : LinkedHashMap> = linkedMapOf( - startRoute to mutableStateListOf(startRoute) - ) - - // Expose the current top level route for consumers - var topLevelRoute by mutableStateOf(startRoute) - private set - - // Expose the back stack so it can be rendered by the NavDisplay - val backStack = mutableStateListOf(startRoute) - - private fun updateBackStack() = - backStack.apply { - clear() - addAll(topLevelStacks.flatMap { it.value }) - } - - private fun navigateToTopLevel(key: T){ - - // Remove any other top level stacks first - if (shouldPopOtherTopLevelRoutesWhenNavigatingToTopLevelRoute) - topLevelStacks.keys.filter { it != key }.forEach { topLevelStacks.remove(it) } - - val doesStackExist = topLevelStacks.keys.contains(key) - - if (doesStackExist){ - // Move it to the end of the stacks - topLevelStacks.apply { - remove(key)?.let { - put(key, it) - } + TopAppBar( + title = { + Text("Navigator Activity") + }, + actions = { + IconButton(onClick = onSearchClick) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search" + ) } - } else { - topLevelStacks.put(key, mutableStateListOf(key)) - } - topLevelRoute = key - updateBackStack() - } - fun navigate(key: T){ - if (key is Route.TopLevel){ - navigateToTopLevel(key) - } else { - topLevelStacks[topLevelRoute]?.add(key) - } - updateBackStack() - } - - fun goBack(){ - val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() - // If the removed key was a top level key, remove the associated top level stack - topLevelStacks.remove(removedKey) - topLevelRoute = topLevelStacks.keys.last() - updateBackStack() - } + }, + ) +} - interface Route { - interface TopLevel - interface Unique // Non-top level route that is unique on the back stack (can move between top level stacks) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/example/nav3recipes/ExampleUnitTest.kt b/app/src/test/java/com/example/nav3recipes/ExampleUnitTest.kt deleted file mode 100644 index 32b7a81..0000000 --- a/app/src/test/java/com/example/nav3recipes/ExampleUnitTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2025 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.nav3recipes - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt index 5433f7b..783a202 100644 --- a/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt +++ b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt @@ -1,76 +1,118 @@ package com.example.nav3recipes.navigator.basic -import androidx.compose.foundation.layout.add import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class NavigatorTest { - private data object Home : Navigator.Route.TopLevel - private data object ChatList : Navigator.Route.TopLevel - private data object ChatDetail - private data object Camera : Navigator.Route.TopLevel - private data object Search : Navigator.Route.Unique + private data object A : Route(isTopLevel = true) + + private data object A1 : Route() + private data object B : Route(isTopLevel = true) + private data object B1 : Route() + private data object C : Route(isTopLevel = true) + private data object D : Route(isShared = true) @Test - fun backStackContainsStartKey(){ - val navigator = Navigator(startRoute = Home) - assert(navigator.backStack.contains(Home)) + fun backStackContainsOnlyStartRoute(){ + val navigator = Navigator(startRoute = A) + assertEquals(listOf(A), navigator.backStack) } @Test - fun navigatingToTopRoute_addsRouteToTopOfStack(){ - val navigator = Navigator(startRoute = Home) - - // Back stack start state [Home] - // Navigate to ChatList - // Expected back stack state [Home, ChatList] - navigator.navigate(ChatList) - assertEquals(listOf(Home, ChatList), navigator.backStack) + fun navigatingToTopLevelRoute_addsRouteToTopOfStack(){ + val navigator = Navigator(startRoute = A) + navigator.navigate(B) + assertEquals(listOf(A, B), navigator.backStack) } @Test - fun addingNonTopLevelRoute_addsToCurrentTopLevelStack() { - val navigator = Navigator(startRoute = Home) // Current: Home, Stack: [Home] - - // Navigate to ChatList, making it the current top-level route - navigator.navigate(ChatList) - // Current: ChatList, Stack: [Home, ChatList] - assertEquals(listOf(Home, ChatList), navigator.backStack, "Backstack after adding ChatList") - assertEquals(ChatList, navigator.topLevelRoute, "Current top level route should be ChatList") - - // Add ChatDetail (non-top-level) to the ChatList stack - navigator.navigate(ChatDetail) - // Current: ChatList, Stack: [Home, ChatList, ChatDetail] - assertEquals(listOf(Home, ChatList, ChatDetail), navigator.backStack, "Backstack after adding ChatDetail") + fun navigatingToChildRoute_addsToCurrentTopLevelStack() { + val navigator = Navigator(startRoute = A) + navigator.navigate(B) + navigator.navigate(B1) + assertEquals(listOf(A, B, B1), navigator.backStack) + } + + @Test + fun navigatingToNewTopLevelRoute_popsOtherTopLevelStacks() { + val navigator = Navigator(startRoute = A) + navigator.navigate(A1) // [A, A1] + navigator.navigate(C) // [A, A1, C] + navigator.navigate(B) // [A, A1, B] + val expected = listOf(A, A1, B) + assertEquals(expected, navigator.backStack) } @Test - fun navigatingToNewTopLevel_withDefaultConfig_popsOtherTopLevelAndItsChildren() { - // Default config: shouldPopOtherTopLevelRoutesWhenNavigatingToTopLevelRoute = true - val navigator = Navigator(startRoute = Home) - navigator.navigate(Search) // BackStack: [Home, Search] - navigator.navigate(Camera) // BackStack: [Home, Search, Camera] - navigator.navigate(ChatList) - val expected = listOf(Home, Search, ChatList) // Camera is popped before ChatList is added + fun navigatingToSharedRoute_whenItsAlreadyOnStack_movesItToNewStack() { + val navigator = Navigator(startRoute = A) + navigator.navigate(D) // [A, D] + navigator.navigate(C) // [A, D, C] + navigator.navigate(D) // [A, C, D] + val expected = listOf(A, C, D) assertEquals(expected, navigator.backStack) } @Test - fun navigatingToNewTopLevel_whenStartRouteCannotMove_popsOtherTopLevelStacksExceptStartRoute() { - val navigator = Navigator(startRoute = Home) - navigator.navigate(Camera) - val expected = listOf(Home, Camera) // Home is locked in place + fun navigatingToStartRoute_whenOtherRoutesAreOnStack_popsAllOtherRoutes() { + val navigator = Navigator(startRoute = A) + navigator.navigate(B) // [A, B] + navigator.navigate(C) // [A, B, C] + navigator.navigate(A) // [A] + val expected : List = listOf(A) assertEquals(expected, navigator.backStack) } @Test - fun navigatingToNewTopLevel_whenStartRouteCanMove_popsAllOtherTopLevelStacks() { - val navigator = Navigator(startRoute = Home, canStartRouteMove = true) - navigator.navigate(Camera) - val expected = listOf(Camera) // Home is popped + fun navigatingToStartRoute_whenItHasSubRoutes_retainsSubRoutes() { + val navigator = Navigator(startRoute = A) + navigator.navigate(A1) // [A, A1] + navigator.navigate(B) // [A, A1, B] + navigator.navigate(A) // [A, A1] + val expected : List = listOf(A, A1) assertEquals(expected, navigator.backStack) } + @Test + fun repeatedlyNavigatingToTopLevelRoute_retainsSubRoutes(){ + val navigator = Navigator(startRoute = A) + navigator.navigate(B) + navigator.navigate(B1) + navigator.navigate(B) + + val expected = listOf(A, B, B1) + assertEquals(expected, navigator.backStack) + } + @Test + fun navigatingToTopLevelRoute_whenTopLevelRoutesCanExistTogether_retainsSubRoutes(){ + val navigator = Navigator(startRoute = A, canTopLevelRoutesExistTogether = true) + navigator.navigate(A) + navigator.navigate(A1) + navigator.navigate(B) + navigator.navigate(B1) + navigator.navigate(C) + navigator.navigate(B) + + val expected = listOf(A, A1, C, B, B1) + assertEquals(expected, navigator.backStack) + } + + @Test + fun navigatingBack_isChronological(){ + val navigator = Navigator(startRoute = A) + navigator.navigate(A1) + navigator.navigate(B) + navigator.navigate(B1) + assertEquals(listOf(A, A1, B, B1), navigator.backStack) + navigator.goBack() + assertEquals(listOf(A, A1, B), navigator.backStack) + navigator.goBack() + assertEquals(listOf(A, A1), navigator.backStack) + navigator.goBack() + assertEquals(listOf(A), navigator.backStack) + + } } \ No newline at end of file From 1b9681caf825c45d491508c030b51b94ad0342bd Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 31 Jul 2025 11:32:43 +0100 Subject: [PATCH 3/3] Address AI feedback --- .../nav3recipes/navigator/basic/Navigator.kt | 16 +++++++++++----- .../navigator/basic/NavigatorActivity.kt | 13 ++++++++----- .../nav3recipes/navigator/basic/NavigatorTest.kt | 4 ++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt b/app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt index d1d751a..9a6854e 100644 --- a/app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt +++ b/app/src/main/java/com/example/nav3recipes/navigator/basic/Navigator.kt @@ -53,6 +53,9 @@ class Navigator( startRoute to mutableListOf(startRoute) ) + // Maintain a map of shared routes to their parent stacks + private var sharedRoutes : MutableMap = mutableMapOf() + private fun updateBackStack() = backStack.apply { clear() @@ -80,7 +83,7 @@ class Navigator( private fun clearAllExceptStartStack(){ // Remove all other top level stacks, except the start stack - val startStack = topLevelStacks[startRoute]!! + val startStack = topLevelStacks[startRoute] ?: mutableListOf(startRoute) topLevelStacks.clear() topLevelStacks.put(startRoute, startStack) } @@ -94,11 +97,11 @@ class Navigator( } else { if (route.isShared){ // If the key is already in a stack, remove it - topLevelStacks.forEach { stack -> - if (stack.value.contains(route)){ - topLevelStacks[stack.key]?.remove(route) - } + val oldParent = sharedRoutes[route] + if (oldParent != null) { + topLevelStacks[oldParent]?.remove(route) } + sharedRoutes[route] = topLevelRoute } topLevelStacks[topLevelRoute]?.add(route) } @@ -109,6 +112,9 @@ class Navigator( * Go back to the previous route. */ fun goBack(){ + if (backStack.size <= 1){ + return + } val removedKey = topLevelStacks[topLevelRoute]?.removeLastOrNull() // If the removed key was a top level key, remove the associated top level stack topLevelStacks.remove(removedKey) diff --git a/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt b/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt index decda42..aa79ae5 100644 --- a/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/navigator/basic/NavigatorActivity.kt @@ -53,11 +53,14 @@ import com.example.nav3recipes.content.ContentPurple import com.example.nav3recipes.content.ContentRed import com.example.nav3recipes.ui.setEdgeToEdgeConfig -private abstract class NavBarItem(val icon: ImageVector): Route(isTopLevel = true) -private data object Home : NavBarItem(icon = Icons.Default.Home) -private data object ChatList : NavBarItem(icon = Icons.Default.Face) +private abstract class NavBarItem( + val icon: ImageVector, + val description: String +): Route(isTopLevel = true) +private data object Home : NavBarItem(icon = Icons.Default.Home, description = "Home") +private data object ChatList : NavBarItem(icon = Icons.Default.Face, description = "Chat list") private data object ChatDetail : Route() -private data object Camera : NavBarItem(icon = Icons.Default.PlayArrow) +private data object Camera : NavBarItem(icon = Icons.Default.PlayArrow, description = "Camera") private data object Search : Route(isShared = true) private val TOP_LEVEL_ROUTES : List = listOf(Home, ChatList, Camera) @@ -86,7 +89,7 @@ class NavigatorActivity : ComponentActivity() { icon = { Icon( imageVector = topLevelRoute.icon, - contentDescription = null + contentDescription = topLevelRoute.description ) } ) diff --git a/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt index 783a202..8c051f1 100644 --- a/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt +++ b/app/src/test/java/com/example/nav3recipes/navigator/basic/NavigatorTest.kt @@ -24,7 +24,7 @@ class NavigatorTest { fun navigatingToTopLevelRoute_addsRouteToTopOfStack(){ val navigator = Navigator(startRoute = A) navigator.navigate(B) - assertEquals(listOf(A, B), navigator.backStack) + assertEquals(listOf(A, B), navigator.backStack) } @Test @@ -36,7 +36,7 @@ class NavigatorTest { } @Test - fun navigatingToNewTopLevelRoute_popsOtherTopLevelStacks() { + fun navigatingToNewTopLevelRoute_popsOtherStacksExceptStartStack() { val navigator = Navigator(startRoute = A) navigator.navigate(A1) // [A, A1] navigator.navigate(C) // [A, A1, C]