From c17a5699b3cd56526b8d2183db5c13813774ace8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 11:35:34 -0400 Subject: [PATCH 1/5] feat(navigation): add parent/flow-scope hierarchy fields to CodeNavigator - Convert CodeNavigator from data class to class (copy()/componentN() unused) - Add optional parent, isFlowNavigator, flowScope fields (all defaulted) - Add FlowScope interface with exitWithResult + dismiss contract - Thread new params through rememberCodeNavigator (existing callers unaffected) - Stand up JVM unit-test harness: TestNav fixtures + CodeNavigatorHarnessTest - Add kotlin("test") + bundles.unit.testing to ui:navigation test deps --- ui/navigation/build.gradle.kts | 3 + .../getcode/navigation/core/CodeNavigator.kt | 20 +++++- .../com/getcode/navigation/core/FlowScope.kt | 26 +++++++ .../kotlin/com/getcode/navigation/TestNav.kt | 70 +++++++++++++++++++ .../core/CodeNavigatorHarnessTest.kt | 21 ++++++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt create mode 100644 ui/navigation/src/test/kotlin/com/getcode/navigation/TestNav.kt create mode 100644 ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorHarnessTest.kt 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..888e8d63a 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 @@ -37,17 +37,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) + 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 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..83ee238eb --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt @@ -0,0 +1,26 @@ +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. The CodeNavigator methods that delegate here + * (navigateBackWithResult / dismiss) are added in a later task, and this interface's + * 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. + */ + fun dismiss() +} 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/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()) + } +} From 7456ecff89cf8eb0c00c8667652a064af23a0e31 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 11:48:50 -0400 Subject: [PATCH 2/5] feat(navigation): dispatch navigate() by route type through the parent chain FlowSteps land on the nearest flow navigator; all other routes bubble up to the app-root stack via the parent chain, so feature screens need only one navigator handle regardless of context. --- .../getcode/navigation/core/CodeNavigator.kt | 25 +++++- .../core/CodeNavigatorDispatchTest.kt | 78 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt 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 888e8d63a..1d111e76e 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 @@ -97,9 +98,29 @@ 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) @@ -172,14 +193,14 @@ 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) } } 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()) + } +} From 8a42ec91de87edea38f3ed87bca529e504e5aeaf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 12:01:46 -0400 Subject: [PATCH 3/5] feat(navigation): add navigateBackWithResult() and dismiss() intents --- .../getcode/navigation/core/CodeNavigator.kt | 33 ++++++++ .../core/CodeNavigatorIntentTest.kt | 82 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorIntentTest.kt 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 1d111e76e..0282c594f 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 @@ -217,6 +217,39 @@ 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() + 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/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..afa4c02c7 --- /dev/null +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorIntentTest.kt @@ -0,0 +1,82 @@ +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()) + } +} From 439c03f6ab09bf449b6164bf1ad6e50392e63be0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 12:11:07 -0400 Subject: [PATCH 4/5] feat(navigation): wire FlowHost inner navigator with parent + FlowScope --- .../com/getcode/navigation/core/FlowScope.kt | 4 +++ .../com/getcode/navigation/flow/FlowHost.kt | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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 index 83ee238eb..a3fca5064 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt @@ -21,6 +21,10 @@ interface FlowScope { /** * 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..706c833ae 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 (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) { From 51ba2f966f9aa5533c7e480f420273564569d747 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 Jul 2026 22:01:53 -0400 Subject: [PATCH 5/5] fix(navigation): guard sheet dismiss on isSheetRoot; polish from final review --- .../com/getcode/navigation/core/CodeNavigator.kt | 4 +++- .../kotlin/com/getcode/navigation/core/FlowScope.kt | 9 ++++----- .../kotlin/com/getcode/navigation/flow/FlowHost.kt | 2 +- .../navigation/core/CodeNavigatorIntentTest.kt | 12 ++++++++++++ 4 files changed, 20 insertions(+), 7 deletions(-) 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 0282c594f..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 @@ -43,7 +43,7 @@ fun rememberCodeNavigator( flowScope: FlowScope? = null, ): CodeNavigator { val navResultStore = rememberNavResultStore(resultStateRegistry = resultStateRegistry) - return remember(navResultStore, onRootReached) { + return remember(navResultStore, onRootReached, flowScope) { CodeNavigator( backStack = backStack, resultStore = navResultStore, @@ -245,6 +245,8 @@ class CodeNavigator( 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() } 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 index a3fca5064..4fb06fdfc 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/FlowScope.kt @@ -5,11 +5,10 @@ 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. The CodeNavigator methods that delegate here - * (navigateBackWithResult / dismiss) are added in a later task, and this interface's - * 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. + * 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. */ 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 706c833ae..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 @@ -267,7 +267,7 @@ private fun FlowHostImpl( // 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 (nav != null) { + if (isSheetRoot && nav != null) { nav.pendingSheetDismiss = { currentOnExit.value(FlowExitReason.BackedOutOfRoot, isSheetRoot) } 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 index afa4c02c7..57fd93b10 100644 --- a/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorIntentTest.kt +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorIntentTest.kt @@ -79,4 +79,16 @@ class CodeNavigatorIntentTest { 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()) + } }