diff --git a/ui/navigation/build.gradle.kts b/ui/navigation/build.gradle.kts index b18bd1ff5..cab531aac 100644 --- a/ui/navigation/build.gradle.kts +++ b/ui/navigation/build.gradle.kts @@ -32,4 +32,7 @@ dependencies { api(libs.lifecycle.viewmodel.navigation3) api(libs.hilt.nav.compose) api(libs.rinku) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CodeNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CodeNavigator.kt index 12d1aa18e..a84696201 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CodeNavigator.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CodeNavigator.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.getcode.navigation.Sheet +import com.getcode.navigation.flow.FlowStep import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.results.NavResultStore import com.getcode.navigation.results.NavResultStoreImpl @@ -37,17 +38,33 @@ fun rememberCodeNavigator( backStack: NavBackStack, resultStateRegistry: NavResultStateRegistry, onRootReached: () -> Unit, + parent: CodeNavigator? = null, + isFlowNavigator: Boolean = false, + flowScope: FlowScope? = null, ): CodeNavigator { val navResultStore = rememberNavResultStore(resultStateRegistry = resultStateRegistry) - return remember(navResultStore, onRootReached) { - CodeNavigator(backStack = backStack, resultStore = navResultStore, onRootReached = onRootReached) + return remember(navResultStore, onRootReached, flowScope) { + CodeNavigator( + backStack = backStack, + resultStore = navResultStore, + onRootReached = onRootReached, + parent = parent, + isFlowNavigator = isFlowNavigator, + flowScope = flowScope, + ) } } -data class CodeNavigator( +class CodeNavigator( val backStack: NavBackStack, val resultStore: NavResultStore, val onRootReached: () -> Unit, + /** The navigator this one is nested inside (the app-level navigator for a flow). Null at the app root. */ + val parent: CodeNavigator? = null, + /** True when this navigator owns a flow's inner back stack (its stack holds [com.getcode.navigation.flow.FlowStep]s). */ + val isFlowNavigator: Boolean = false, + /** Non-null only when this is a flow's inner navigator. See [FlowScope]. */ + val flowScope: FlowScope? = null, ) { /** * When set, signals the active sheet to animate its dismiss. The callback @@ -81,9 +98,29 @@ data class CodeNavigator( val currentRouteKey: NavKey? get() = backStack.lastOrNull() + /** + * Route-type-aware navigation. [com.getcode.navigation.flow.FlowStep]s land on the nearest + * flow stack; every other route bubbles up to the app-root stack. A screen calls this the same + * way whether it is top-level, in a flow, or in a sheet. + */ fun navigate( route: NavKey, options: NavOptions = NavOptions(), + ) { + val belongsHere = if (route is FlowStep) isFlowNavigator else parent == null + when { + belongsHere -> navigateHere(route, options) + parent != null -> parent.navigate(route, options) + else -> trace( + "Dropped navigation to FlowStep $route: no flow host on the stack", + type = TraceType.Error, + ) + } + } + + private fun navigateHere( + route: NavKey, + options: NavOptions = NavOptions(), ) { if (options.debugRouting) { trace("Navigating to $route from ${backStack.lastOrNull()} with $options", type = TraceType.Navigation) @@ -156,14 +193,14 @@ data class CodeNavigator( fun restoreRouting(routes: List) { val list = routes.toMutableList() val base = list.removeAt(0) - navigate( + navigateHere( route = base, options = NavOptions( popUpTo = NavOptions.PopUpTo.ClearAll ), ) list.forEach { - navigate(it) + navigateHere(it) } } @@ -180,6 +217,41 @@ data class CodeNavigator( backStack.removeAt(backStack.lastIndex) } + /** + * Delivers [result] to the enclosing flow's caller by exiting via [FlowScope.exitWithResult]. + * The flow host tears down the flow after receiving this signal. + * No-op (with a trace) when called outside a flow — there is no caller to return to. + */ + fun navigateBackWithResult(result: android.os.Parcelable) { + val scope = flowScope + if (scope != null) { + scope.exitWithResult(result) + } else { + trace( + "navigateBackWithResult($result) called outside a flow scope; ignored", + type = TraceType.Error, + ) + } + } + + /** + * Leave the current bounded scope, regardless of depth: + * - inside a flow -> delegates to [FlowScope.dismiss]; FlowHost animates the sheet out first + * when the flow is a sheet root; + * - a plain sheet -> animate the sheet out via [pendingSheetDismiss]; the scene pops it on completion; + * - otherwise -> [navigateBack]. + */ + fun dismiss() { + val scope = flowScope + when { + scope != null -> scope.dismiss() + // Empty lambda = "animate the sheet out, no post-dismiss action"; the sheet scene + // observes pendingSheetDismiss, animates to Hidden, then pops the entry. + currentRouteKey is Sheet -> pendingSheetDismiss = {} + else -> navigateBack() + } + } + fun clearToFirst(routeClass: KClass): Boolean { Timber.d("Clearing backstack to first instance of $routeClass") var routeFound = false diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt new file mode 100644 index 000000000..4fb06fdfc --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt @@ -0,0 +1,29 @@ +package com.getcode.navigation.core + +import android.os.Parcelable + +/** + * The bounded navigation scope a [CodeNavigator] belongs to when it is a flow's inner navigator. + * + * A step never touches this directly — it calls CodeNavigator.navigateBackWithResult / + * CodeNavigator.dismiss, which delegate here. The implementation lives in + * [com.getcode.navigation.flow.FlowHost], where the flow-exit callback and enclosing-sheet + * context are already available. This keeps [CodeNavigator] free of flow/sheet mechanics. + */ +interface FlowScope { + /** True when this flow is the root content of a bottom sheet. */ + val isSheetRoot: Boolean + + /** Complete the flow, delivering [result] to whoever launched it. */ + fun exitWithResult(result: Parcelable) + + /** + * Leave the entire scope (the whole flow, animating the sheet out first when the flow is a + * sheet root). Equivalent to a user pressing the close-X. + * + * For a sheet-root flow, the enclosing sheet entry is removed from the outer backstack by the + * animation handler before the flow's exit callback runs — so the host's onExit must not pop + * the sheet entry again. + */ + fun dismiss() +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt index 0345cb15d..97d802c64 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt @@ -40,6 +40,7 @@ import com.getcode.navigation.core.rememberCodeNavigator import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.results.asKey +import com.getcode.navigation.core.FlowScope import com.getcode.navigation.scenes.LocalSheetNavigator /** @@ -247,10 +248,43 @@ private fun FlowHostImpl( // onRootReached and onExit read through rememberUpdatedState so the references // never change — preventing unnecessary recompositions of children that read // the composition locals. + + // FlowScope exposes flow-exit + sheet-aware dismiss to the inner CodeNavigator, so steps can + // call navigator.navigateBackWithResult(...) / navigator.dismiss() instead of reaching for + // FlowNavigator or the outer navigator. + val currentSheetNavigator = rememberUpdatedState(sheetNavigator) + val flowScope = remember(isSheetRoot) { + object : FlowScope { + override val isSheetRoot: Boolean = isSheetRoot + + override fun exitWithResult(result: Parcelable) { + @Suppress("UNCHECKED_CAST") + currentOnExit.value(FlowExitReason.Completed(result as R), isSheetRoot) + } + + override fun dismiss() { + val nav = currentSheetNavigator.value + // The sheet scene removes the sheet entry from the outer backstack when the + // animation completes, then runs this lambda — so onExit must not pop the + // sheet entry again. + if (isSheetRoot && nav != null) { + nav.pendingSheetDismiss = { + currentOnExit.value(FlowExitReason.BackedOutOfRoot, isSheetRoot) + } + } else { + currentOnExit.value(FlowExitReason.BackedOutOfRoot, isSheetRoot) + } + } + } + } + val innerNavigator = rememberCodeNavigator( backStack = innerBackStack, resultStateRegistry = resultStateRegistry, onRootReached = remember { { currentOnExit.value(FlowExitReason.BackedOutOfRoot, isSheetRoot) } }, + parent = outerNavigator, + isFlowNavigator = true, + flowScope = flowScope, ) val flowNavigator = remember(innerNavigator) { diff --git a/ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt b/ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt new file mode 100644 index 000000000..7c9712b77 --- /dev/null +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt @@ -0,0 +1,70 @@ +package com.getcode.navigation + +import android.os.Parcelable +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.EmptyCodeNavigator +import com.getcode.navigation.core.FlowScope +import com.getcode.navigation.flow.FlowRouteWithResult +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize + +// --- App-level routes (NOT FlowSteps) --- +data object AppHome : NavKey +data object AppRegion : NavKey +data object CallerScreen : NavKey + +// --- A sheet route --- +data object DemoSheet : Sheet + +// --- Flow steps --- +data object StepOne : FlowStep +data object StepTwo : FlowStep + +// --- A flow-host route that returns DemoResult --- +@Parcelize +data class DemoResult(val value: String) : Parcelable + +data class DemoFlow( + override val initialStack: List = listOf(StepOne), +) : FlowRouteWithResult + +/** Records what a flow scope was asked to do, without any real flow/sheet plumbing. */ +class RecordingFlowScope( + override val isSheetRoot: Boolean = false, +) : FlowScope { + val calls = mutableListOf() + var deliveredResult: Parcelable? = null + + override fun exitWithResult(result: Parcelable) { + deliveredResult = result + calls += "exitWithResult" + } + + override fun dismiss() { + calls += "dismiss" + } +} + +/** Builds a [CodeNavigator] over a real [NavBackStack] seeded with [keys]. */ +fun testNavigator( + vararg keys: NavKey, + parent: CodeNavigator? = null, + isFlow: Boolean = false, + scope: FlowScope? = null, + onRootReached: () -> Unit = {}, +): CodeNavigator { + require(keys.isNotEmpty()) { "seed the navigator with at least one key" } + val backStack = NavBackStack(keys.first()).apply { + keys.drop(1).forEach { add(it) } + } + return CodeNavigator( + backStack = backStack, + resultStore = EmptyCodeNavigator.resultStore, + onRootReached = onRootReached, + parent = parent, + isFlowNavigator = isFlow, + flowScope = scope, + ) +} diff --git a/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt new file mode 100644 index 000000000..8eb0b74c8 --- /dev/null +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt @@ -0,0 +1,78 @@ +package com.getcode.navigation.core + +import com.getcode.navigation.AppHome +import com.getcode.navigation.AppRegion +import com.getcode.navigation.StepOne +import com.getcode.navigation.StepTwo +import com.getcode.navigation.testNavigator +import kotlin.test.Test +import kotlin.test.assertEquals + +class CodeNavigatorDispatchTest { + + @Test + fun `flow step lands on the flow stack`() { + val root = testNavigator(AppHome) + val flow = testNavigator(StepOne, parent = root, isFlow = true) + + flow.navigate(StepTwo) + + assertEquals(listOf(StepOne, StepTwo), flow.backStack.toList()) + assertEquals(listOf(AppHome), root.backStack.toList()) + } + + @Test + fun `app route from a flow bubbles up to the root stack`() { + val root = testNavigator(AppHome) + val flow = testNavigator(StepOne, parent = root, isFlow = true) + + flow.navigate(AppRegion) + + assertEquals(listOf(StepOne), flow.backStack.toList()) + assertEquals(listOf(AppHome, AppRegion), root.backStack.toList()) + } + + @Test + fun `app route from a nested flow bubbles all the way to the root`() { + val root = testNavigator(AppHome) + val outerFlow = testNavigator(StepOne, parent = root, isFlow = true) + val innerFlow = testNavigator(StepOne, parent = outerFlow, isFlow = true) + + innerFlow.navigate(AppRegion) + + assertEquals(listOf(AppHome, AppRegion), root.backStack.toList()) + assertEquals(listOf(StepOne), outerFlow.backStack.toList()) + assertEquals(listOf(StepOne), innerFlow.backStack.toList()) + } + + @Test + fun `app route at the root lands on the root stack`() { + val root = testNavigator(AppHome) + + root.navigate(AppRegion) + + assertEquals(listOf(AppHome, AppRegion), root.backStack.toList()) + } + + @Test + fun `flow step at the root is dropped`() { + val root = testNavigator(AppHome) + + root.navigate(StepTwo) + + assertEquals(listOf(AppHome), root.backStack.toList()) + } + + @Test + fun `restoreRouting rebuilds this stack directly without type dispatch`() { + val root = testNavigator(AppHome) + val flow = testNavigator(StepOne, parent = root, isFlow = true) + + // Even though these are FlowSteps and the parent exists, restoreRouting must + // rebuild the flow navigator's OWN stack, not bubble to the root. + flow.replaceAll(listOf(StepTwo, StepOne)) + + assertEquals(listOf(StepTwo, StepOne), flow.backStack.toList()) + assertEquals(listOf(AppHome), root.backStack.toList()) + } +} diff --git a/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorHarnessTest.kt b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorHarnessTest.kt new file mode 100644 index 000000000..6ac7fd1b2 --- /dev/null +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorHarnessTest.kt @@ -0,0 +1,21 @@ +package com.getcode.navigation.core + +import com.getcode.navigation.AppHome +import com.getcode.navigation.AppRegion +import com.getcode.navigation.testNavigator +import kotlin.test.Test +import kotlin.test.assertEquals + +class CodeNavigatorHarnessTest { + + @Test + fun `push and pop mutate the backstack headlessly`() { + val nav = testNavigator(AppHome) + + nav.navigate(AppRegion) + assertEquals(listOf(AppHome, AppRegion), nav.backStack.toList()) + + nav.navigateBack() + assertEquals(listOf(AppHome), nav.backStack.toList()) + } +} diff --git a/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorIntentTest.kt b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorIntentTest.kt new file mode 100644 index 000000000..57fd93b10 --- /dev/null +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorIntentTest.kt @@ -0,0 +1,94 @@ +package com.getcode.navigation.core + +import com.getcode.navigation.AppHome +import com.getcode.navigation.CallerScreen +import com.getcode.navigation.DemoFlow +import com.getcode.navigation.DemoResult +import com.getcode.navigation.DemoSheet +import com.getcode.navigation.RecordingFlowScope +import com.getcode.navigation.StepOne +import com.getcode.navigation.testNavigator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CodeNavigatorIntentTest { + + @Test + fun `navigateBackWithResult delivers through the flow scope`() { + val scope = RecordingFlowScope() + val root = testNavigator(CallerScreen, DemoFlow()) + val flow = testNavigator(StepOne, parent = root, isFlow = true, scope = scope) + + flow.navigateBackWithResult(DemoResult("done")) + + assertEquals(listOf("exitWithResult"), scope.calls) + assertEquals(DemoResult("done"), scope.deliveredResult) + } + + @Test + fun `navigateBackWithResult outside a flow scope is a no-op`() { + val root = testNavigator(AppHome) + + // Must not throw. + root.navigateBackWithResult(DemoResult("ignored")) + + assertEquals(listOf(AppHome), root.backStack.toList()) + } + + @Test + fun `dismiss in a flow delegates to the scope`() { + val scope = RecordingFlowScope() + val flow = testNavigator(StepOne, isFlow = true, scope = scope) + + flow.dismiss() + + assertEquals(listOf("dismiss"), scope.calls) + } + + @Test + fun `dismiss on a plain sheet requests an animated dismiss`() { + val nav = testNavigator(AppHome, DemoSheet) + assertNull(nav.pendingSheetDismiss) + + nav.dismiss() + + assertNotNull(nav.pendingSheetDismiss) + // Backstack is untouched — the sheet scene pops it after the animation completes. + assertEquals(listOf(AppHome, DemoSheet), nav.backStack.toList()) + } + + @Test + fun `dismiss with no flow and no sheet pops the stack`() { + val nav = testNavigator(AppHome, CallerScreen) + + nav.dismiss() + + assertEquals(listOf(AppHome), nav.backStack.toList()) + } + + @Test + fun `dismiss at the app root reaches root instead of popping`() { + var rootReached = false + val nav = testNavigator(AppHome, onRootReached = { rootReached = true }) + + nav.dismiss() + + assertTrue(rootReached) + assertEquals(listOf(AppHome), nav.backStack.toList()) + } + + @Test + fun `navigateBackWithResult with a parent but no flow scope is a no-op`() { + val root = testNavigator(AppHome) + val child = testNavigator(CallerScreen, parent = root) // parent set, but scope = null + + // Must not throw and must not mutate either stack. + child.navigateBackWithResult(DemoResult("ignored")) + + assertEquals(listOf(CallerScreen), child.backStack.toList()) + assertEquals(listOf(AppHome), root.backStack.toList()) + } +}