From 9e6d740cd7bfc5242634ee1ba2047bc1b62cd0c7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 11:35:34 -0400 Subject: [PATCH 01/20] 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 c0d0e77fca9f9586f50ffc463db41163745672f4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 11:48:50 -0400 Subject: [PATCH 02/20] 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 f9020ac997af039e688659f9ea325f34a9641b9b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 12:01:46 -0400 Subject: [PATCH 03/20] 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 e391120615a52207299e36cb60df172f507108e2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 9 Jul 2026 12:11:07 -0400 Subject: [PATCH 04/20] 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 3839ff183a8039662046205770377099bf2702fe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 Jul 2026 22:01:53 -0400 Subject: [PATCH 05/20] 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()) + } } From c7ce2f99eb41007f6ad26e3a092ccfddf020b5b6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 Jul 2026 22:20:24 -0400 Subject: [PATCH 06/20] refactor(navigation): make FlowNavigator a thin adapter over CodeNavigator InnerFlowNavigator.back(), exitWithResult(), and navigate() now delegate to the hierarchical CodeNavigator methods (navigateBack, navigateBackWithResult, navigate) rather than manipulating state directly or holding a separate outerNavigator reference. The class is promoted to internal so JVM tests can construct it directly. Characterization tests added covering all six delegation paths. --- .../com/getcode/navigation/flow/FlowHost.kt | 26 ++--- .../navigation/flow/InnerFlowNavigatorTest.kt | 99 +++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt 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 97d802c64..d224ecac1 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 @@ -290,7 +290,6 @@ private fun FlowHostImpl( val flowNavigator = remember(innerNavigator) { InnerFlowNavigator( navigator = innerNavigator, - outerNavigator = outerNavigator, onExit = { reason -> currentOnExit.value(reason, isSheetRoot) }, steps = { currentSteps.value }, completedResult = { currentCompletedResult.value }, @@ -346,9 +345,8 @@ private fun FlowHostImpl( } } -private class InnerFlowNavigator( +internal class InnerFlowNavigator( private val navigator: CodeNavigator, - private val outerNavigator: CodeNavigator, private val onExit: (FlowExitReason) -> Unit, private val steps: () -> List, private val completedResult: () -> R?, @@ -381,17 +379,17 @@ private class InnerFlowNavigator( } override fun back(): Boolean { - return if (canGoBack) { - navigator.backStack.removeAt(navigator.backStack.lastIndex) - true - } else { - onExit(FlowExitReason.BackedOutOfRoot) - false - } + // Delegate to the hierarchical navigator: it pops when possible, otherwise its + // onRootReached fires the same onExit(BackedOutOfRoot) this used to call directly. + val couldGoBack = canGoBack + navigator.navigateBack() + return couldGoBack } override fun exitWithResult(result: R) { - onExit(FlowExitReason.Completed(result)) + // navigateBackWithResult delegates to FlowScope.exitWithResult, which reaches the same + // onExit(Completed(result)) as before. + navigator.navigateBackWithResult(result) } override fun exitCanceled() { @@ -429,7 +427,11 @@ private class InnerFlowNavigator( } override fun navigate(route: NavKey) { - outerNavigator.push(route) + // For app-level routes (the documented use — see FlowNavigator.navigate), route-type + // dispatch bubbles the route up the parent chain to the app-root stack, the same + // destination as the old outerNavigator.push(route). Callers must pass app routes only: + // a FlowStep here would instead be dispatched onto the flow's own stack. + navigator.navigate(route) } } diff --git a/ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt b/ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt new file mode 100644 index 000000000..fdbb1c3e8 --- /dev/null +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/flow/InnerFlowNavigatorTest.kt @@ -0,0 +1,99 @@ +package com.getcode.navigation.flow + +import com.getcode.navigation.AppHome +import com.getcode.navigation.AppRegion +import com.getcode.navigation.DemoResult +import com.getcode.navigation.RecordingFlowScope +import com.getcode.navigation.StepOne +import com.getcode.navigation.StepTwo +import com.getcode.navigation.testNavigator +import com.getcode.navigation.core.CodeNavigator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class InnerFlowNavigatorTest { + + private class Harness(vararg steps: FlowStep) { + val exits = mutableListOf>() + val scope = RecordingFlowScope() + val root: CodeNavigator = testNavigator(AppHome) + val flow: CodeNavigator = testNavigator( + *steps, + parent = root, + isFlow = true, + scope = scope, + // In production both onRootReached and onExit funnel into currentOnExit(BackedOutOfRoot); + // wire them to the same recorder here so a root-level back() is observable. + onRootReached = { exits += FlowExitReason.BackedOutOfRoot }, + ) + val nav = InnerFlowNavigator( + navigator = flow, + onExit = { exits += it }, + steps = { emptyList() }, + completedResult = { null }, + onProceed = null, + ) + } + + @Test + fun `back pops the flow stack and returns true when not at root`() { + val h = Harness(StepOne, StepTwo) + val result = h.nav.back() + assertTrue(result) + assertEquals(listOf(StepOne), h.flow.backStack.toList()) + assertEquals(emptyList(), h.exits) + } + + @Test + fun `back at the flow root exits with BackedOutOfRoot and returns false`() { + val h = Harness(StepOne) + val result = h.nav.back() + assertFalse(result) + assertEquals(listOf>(FlowExitReason.BackedOutOfRoot), h.exits) + assertEquals(listOf(StepOne), h.flow.backStack.toList()) + } + + @Test + fun `exitWithResult delivers the result through the flow scope`() { + val h = Harness(StepOne) + h.nav.exitWithResult(DemoResult("done")) + assertEquals(listOf("exitWithResult"), h.scope.calls) + assertEquals(DemoResult("done"), h.scope.deliveredResult) + } + + @Test + fun `exitCanceled exits with Canceled`() { + val h = Harness(StepOne) + h.nav.exitCanceled() + assertEquals(listOf>(FlowExitReason.Canceled), h.exits) + } + + @Test + fun `navigate sends an app route up to the parent stack`() { + val h = Harness(StepOne) + h.nav.navigate(AppRegion) + assertEquals(listOf(AppHome, AppRegion), h.root.backStack.toList()) + assertEquals(listOf(StepOne), h.flow.backStack.toList()) + } + + @Test + fun `navigateTo pushes a step onto the flow stack`() { + val h = Harness(StepOne) + h.nav.navigateTo(StepTwo) + assertEquals(listOf(StepOne, StepTwo), h.flow.backStack.toList()) + assertEquals(listOf(AppHome), h.root.backStack.toList()) + } + + @Test + fun `navigate with a FlowStep lands on the flow stack (callers must pass app routes)`() { + val h = Harness(StepOne) + + // Misuse: navigate() is for app-level routes; a FlowStep is dispatched to the flow stack. + h.nav.navigate(StepTwo) + + assertEquals(listOf(StepOne, StepTwo), h.flow.backStack.toList()) + assertEquals(listOf(AppHome), h.root.backStack.toList()) + } +} From 0efb44626a74905953e4c86475362fa5f578d12b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 10:28:58 -0400 Subject: [PATCH 07/20] refactor(navigation): route chat flow through single dispatching navigator --- .../flipcash/app/messenger/ChatFlowScreen.kt | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index bf4342c31..1a2ef0874 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -2,7 +2,6 @@ package com.flipcash.app.messenger import android.os.Parcelable import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -19,9 +18,7 @@ import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost -import com.getcode.navigation.flow.LocalOuterCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel -import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry @@ -29,7 +26,6 @@ import com.getcode.navigation.results.navigateForResult import com.getcode.navigation.results.resultBackNavigator import com.getcode.navigation.scenes.LocalSheetNavigator import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.map @Composable fun ChatFlowScreen( @@ -62,12 +58,10 @@ private fun chatEntryProvider( private fun FlowConversationScreen(identifier: ChatIdentifier) { val viewModel = flowSharedViewModel() val navigator = LocalCodeNavigator.current - // The outer app/sheet nav host. Its entryProvider (appEntryProvider) registers - // full AppRoutes like Token.Swap/Info/Discovery; the inner [navigator] only knows - // this flow's ChatStep keys, so full routes must be pushed onto the outer navigator. - val outerNavigator = LocalOuterCodeNavigator.current - // The sheet-owning (root) navigator — the one whose back stack holds this chat's - // Main.Sheet and whose pendingSheetDismiss the dismiss animation observes. + // The sheet-owning (root) navigator — the one whose back stack holds this chat's Main.Sheet and + // whose pendingSheetDismiss the dismiss animation observes. openAsSheet is stack-local (it + // inspects/mutates this navigator's own back stack), so it can't ride the type dispatcher and + // must target the sheet navigator explicitly. val sheetNavigator = LocalSheetNavigator.current LaunchedEffect(viewModel, identifier) { @@ -78,6 +72,8 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) { viewModel.eventFlow .filterIsInstance() .collect { + // ChatStep.AmountEntry is a FlowStep -> the dispatcher keeps this push on the inner + // flow stack and registers the result callback on the inner store (intra-flow). navigator.navigateForResult(ChatStep.AmountEntry) { result -> if (result is NavResultOrCanceled.ReturnValue) { viewModel.dispatchEvent(ChatViewModel.Event.OnStartMessageInput) @@ -91,14 +87,15 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) { .filterIsInstance() .collect { (route, asSheet) -> if (asSheet) { - // Dismiss this chat sheet and open [route] as a fresh sheet. - // openAsSheet on the sheet-owning navigator animates the current - // sheet closed (via pendingSheetDismiss) before opening the new one. + // Dismiss this chat sheet and open [route] as a fresh sheet. openAsSheet on the + // sheet-owning navigator animates the current sheet closed (via pendingSheetDismiss) + // before opening the new one. sheetNavigator?.openAsSheet(route) } else { - // Push onto the outer nav host (which registers full AppRoutes) rather - // than the inner flow navigator, whose backstack only handles ChatSteps. - outerNavigator.navigate(route) + // A full AppRoute (never a ChatStep) -> the dispatcher bubbles it up the parent + // chain to the outer app nav host, the same destination as the old + // outerNavigator.navigate(route). + navigator.navigate(route) } } } @@ -110,21 +107,19 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) { private fun FlowAmountEntryScreen() { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current val resultBack = resultBackNavigator() - // Re-provide the outer navigator so ChatAmountEntryContent can push - // app-level routes (RegionSelection, TokenSelection, etc.) - val outerNavigator = LocalOuterCodeNavigator.current - CompositionLocalProvider(LocalCodeNavigator provides outerNavigator) { - ChatAmountEntryContent( - amountDelegate = viewModel.amountDelegate, - resolveState = state.resolveState, - token = state.token, - eventFlow = viewModel.eventFlow, - onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) }, - onSendComplete = { resultBack.returnValue(ChatSendResult) }, - onExit = { flowNavigator.back() }, - ) - } + // No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator, and its + // navigator.push(AppRoute.Main.RegionSelection) / push(AppRoute.Sheets.TokenSelection) + // bubble to the app navigator through the dispatcher. + ChatAmountEntryContent( + amountDelegate = viewModel.amountDelegate, + resolveState = state.resolveState, + token = state.token, + eventFlow = viewModel.eventFlow, + onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) }, + onSendComplete = { resultBack.returnValue(ChatSendResult) }, // intra-flow result -> Conversation + onExit = { navigator.navigateBack() }, // pop the AmountEntry step + ) } From e68aaa5acb1297abf0d49770c58df99c7bcb715a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 10:33:59 -0400 Subject: [PATCH 08/20] refactor(navigation): drop inert flow re-shadow on deposit informational step --- .../main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt index e9c4ec52b..643c5a3a5 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt @@ -35,7 +35,6 @@ import com.getcode.navigation.flow.PreviewFlowNavigator import com.getcode.navigation.flow.deliverFlowResult import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.flow.rememberInitialStack -import com.getcode.navigation.flowAnnotatedEntry import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.ui.components.AppBarWithTitle @@ -86,7 +85,7 @@ fun DepositFlowScreen( private fun depositEntryProvider( showOtherOptions: Boolean, ): (NavKey) -> NavEntry = entryProvider { - flowAnnotatedEntry { + annotatedEntry { UsdcDepositInformationScreen(showOtherOptions) } annotatedEntry { From e97fb29f6896acf7cc8ac4f5a7f2b1efd35fb471 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 10:37:42 -0400 Subject: [PATCH 09/20] refactor(navigation): route withdrawal amount step through dispatching navigator --- .../kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt index 2e52df7fa..932391875 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalFlowScreen.kt @@ -37,7 +37,6 @@ import com.getcode.navigation.flow.PreviewFlowNavigator import com.getcode.navigation.flow.deliverFlowResult import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.flow.rememberInitialStack -import com.getcode.navigation.flowAnnotatedEntry import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.ui.components.AppBarWithTitle @@ -91,7 +90,7 @@ private fun withdrawalEntryProvider( annotatedEntry { WithdrawalSelectTokenScreen() } - flowAnnotatedEntry { step -> + annotatedEntry { step -> WithdrawalEntryScreen(step.mint) } annotatedEntry { From db33d426c53e836c18442a8c2d3e43be1b222764 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 10:41:23 -0400 Subject: [PATCH 10/20] refactor(navigation): use single flow navigator handle in contact list --- .../app/directsend/internal/screens/ContactListScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt index a4753ea1d..3582a085c 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt @@ -30,7 +30,6 @@ import com.flipcash.app.permissions.ContactAccessResult import com.flipcash.app.permissions.rememberContactAccessHandle import com.flipcash.features.directsend.R import com.getcode.libs.analytics.LocalAnalytics -import com.getcode.navigation.flow.LocalOuterCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -48,7 +47,6 @@ import kotlinx.coroutines.flow.filterIsInstance internal fun ContactListScreen() { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() - val navigator = LocalOuterCodeNavigator.current val analytics = LocalAnalytics.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -57,7 +55,9 @@ internal fun ContactListScreen() { viewModel.eventFlow .filterIsInstance() .collect { event -> - navigator.show(AppRoute.Main.InviteContact(event.contact.e164)) + // App route -> flowNavigator.navigate bubbles it to the app stack (identical to the + // old outerNavigator.show, since show(r) == navigate(r)). + flowNavigator.navigate(AppRoute.Main.InviteContact(event.contact.e164)) } } From 808411521f2d762da69aefdda9b310ad268365d7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 10:46:02 -0400 Subject: [PATCH 11/20] refactor(navigation): make swap->verification result nav explicitly app-scoped --- .../kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt | 10 ++++++---- .../kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index fc12fd267..16af62b3b 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -20,7 +20,7 @@ import com.flipcash.app.onramp.LocalCoinbaseOnRampController import com.flipcash.app.tokens.internal.SwapEntryScreenContent import com.flipcash.app.tokens.ui.SwapViewModel import com.flipcash.features.tokens.R -import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.LocalOuterCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.results.NavResultOrCanceled @@ -38,7 +38,9 @@ internal fun SwapEntryScreen( initialAmount: Fiat? = null, ) { val flowNavigator = rememberFlowNavigator() - val navigator = LocalCodeNavigator.current + // Result nav crosses the flow boundary: the callback must register on the same (app) store the + // Verification screen returns to, so this is explicitly the outer/app navigator. + val appNavigator = LocalOuterCodeNavigator.current val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val coinbaseOnRampController = LocalCoinbaseOnRampController.current @@ -105,7 +107,7 @@ internal fun SwapEntryScreen( .onEach { event -> val (phone, email) = event val mint = (viewModel.stateFlow.value.purpose as? SwapPurpose.Buy)?.mint ?: return@onEach - navigator.navigateForResult( + appNavigator.navigateForResult( AppRoute.Verification( origin = AppRoute.Token.Swap(SwapPurpose.Buy(mint)), includePhone = phone, @@ -149,7 +151,7 @@ internal fun SwapEntryScreen( LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { navigator.push(it.screen) } + .onEach { appNavigator.push(it.screen) } .launchIn(this) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 210f14423..4348b3060 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -13,7 +13,6 @@ import com.flipcash.app.core.tokens.SwapStep import com.getcode.opencode.model.financial.Fiat import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.flowAnnotatedEntry import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.flow.FlowExitReason import com.getcode.navigation.flow.FlowHost @@ -72,7 +71,10 @@ private fun swapEntryProvider( val depositFirstPurpose = (route.purpose as? SwapPurpose.Buy) ?.takeIf { it.fundingSource == FundingSource.Phantom } - flowAnnotatedEntry { step -> + // annotatedEntry (not flowAnnotatedEntry): SwapEntryScreen no longer reads LocalCodeNavigator — + // it targets the outer navigator explicitly (LocalOuterCodeNavigator) for its cross-boundary + // navigateForResult/push — so the old outer re-shadow is inert. + annotatedEntry { step -> SwapEntryScreen(step.purpose, step.initialAmount) } annotatedEntry { SellReceiptScreen() } From 2a5cefdf5d52736ef2604224e06ffb987ec194f5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 12:28:19 -0400 Subject: [PATCH 12/20] fix(chat): eliminate message-list reflow on open and back-navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation screen was rebuilt from zero-initialized layout state on every open and every pop-back from the amount-entry step, causing a visible reflow of the message list: - ChatInputScaffold measured its top/bottom bars via onSizeChanged into mutableStateOf(0.dp), so the message LazyColumn received (0,0) contentPadding on the first frame and snapped to the real bar heights on the next — a ~70dp vertical jump. Replaced with a SubcomposeLayout that measures the bars before the content in the same pass, so padding is correct on frame 1. - refreshSettled was a plain remember(false) that re-armed the content gate on pop-back; made it rememberSaveable so it survives the round-trip. - OnChatOpened re-dispatched on re-entry with no idempotency guard, re-running loadMessages() and invalidating Paging. Skip the redundant reload when re-entering the same already-open chat; live delivery via the app-scoped event stream keeps Room current independently, so no messages are missed. --- .../app/messenger/internal/ChatViewModel.kt | 11 ++++ .../internal/screens/MessengerScreen.kt | 56 +++++++++++-------- .../screens/components/MessageList.kt | 6 +- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 1c9cc1c1f..56d69793e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -271,6 +271,17 @@ internal class ChatViewModel @Inject constructor( is ChatIdentifier.ByChatId -> identifier.chatId } + // Re-entering the same, already-open chat (e.g. returning from the amount-entry + // step) re-dispatches OnChatOpened. The chat is already resolved and its messages + // are cached in Room, so skip the re-resolve + network reload that would invalidate + // Paging and reflow the message list. Still keep the chat active and clear + // notifications. + if (chatId != null && stateFlow.value.chatId == chatId) { + chatCoordinator.setActiveChatId(chatId) + chatCoordinator.dismissNotifications(chatId) + return@onEach + } + if (chatId != null) { dispatchEvent(Event.ChatFound(chatId)) chatCoordinator.setActiveChatId(chatId) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 0a35a0a0b..b33aa5755 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -35,8 +35,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -348,30 +347,39 @@ private fun ChatInputScaffold( bottomBar: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit, ) { - val density = LocalDensity.current - var topBarHeight by remember { mutableStateOf(0.dp) } - var bottomBarHeight by remember { mutableStateOf(0.dp) } + // Overlay scaffold: the content fills the whole area (drawn behind the blurred bars) and is + // inset by the bar heights. SubcomposeLayout measures the bars BEFORE the content in the same + // layout pass, so the content receives correct padding on the very first frame. The previous + // onSizeChanged approach fed 0 padding on frame 1 and snapped to the real heights on frame 2, + // which made the message list visibly jump on every open and every pop-back. + SubcomposeLayout( + modifier = Modifier + .imePadding() + .testTag("chat_screen"), + ) { constraints -> + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) - Box(modifier = Modifier.imePadding().testTag("chat_screen")) { - content( - PaddingValues( - top = topBarHeight, - bottom = bottomBarHeight, - ) + val topPlaceables = subcompose(ChatScaffoldSlot.Top, topBar).map { it.measure(looseConstraints) } + val bottomPlaceables = subcompose(ChatScaffoldSlot.Bottom, bottomBar).map { it.measure(looseConstraints) } + val topHeight = topPlaceables.maxOfOrNull { it.height } ?: 0 + val bottomHeight = bottomPlaceables.maxOfOrNull { it.height } ?: 0 + + val padding = PaddingValues( + top = topHeight.toDp(), + bottom = bottomHeight.toDp(), ) - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .onSizeChanged { topBarHeight = with(density) { it.height.toDp() } } - ) { - topBar() - } - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .onSizeChanged { bottomBarHeight = with(density) { it.height.toDp() } } - ) { - bottomBar() + + val contentPlaceables = subcompose(ChatScaffoldSlot.Content) { content(padding) } + .map { it.measure(constraints) } + + layout(constraints.maxWidth, constraints.maxHeight) { + contentPlaceables.forEach { it.place(0, 0) } + topPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, 0) } + bottomPlaceables.forEach { + it.place((constraints.maxWidth - it.width) / 2, constraints.maxHeight - it.height) + } } } } + +private enum class ChatScaffoldSlot { Top, Bottom, Content } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index f78bad633..0472ddbf4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot @@ -108,7 +109,10 @@ internal fun MessageList( // Track when the initial Paging refresh has truly completed (Loading → NotLoading). // This avoids showing the ContactInfoContainer before messages arrive, // which would cause the list to start scrolled to the wrong position. - var refreshSettled by remember { mutableStateOf(false) } + // rememberSaveable so it survives the screen being torn down and rebuilt when the + // amount-entry step is pushed over the conversation — otherwise the content gate + // re-arms on pop-back and the list re-settles (visible reflow). + var refreshSettled by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { snapshotFlow { messages.loadState.refresh } .dropWhile { it !is LoadState.Loading } From c0d36a65f20b7353f64e415f42c413f666a3c2c3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 13:12:48 -0400 Subject: [PATCH 13/20] feat(navigation): add dispatchTarget() and rootNavigator to CodeNavigator --- .../getcode/navigation/core/CodeNavigator.kt | 22 ++++++++++++-- .../core/CodeNavigatorDispatchTest.kt | 29 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 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 a84696201..4cbe0229f 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 @@ -103,13 +103,15 @@ class CodeNavigator( * 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. */ + private fun belongsHere(route: NavKey): Boolean = + if (route is FlowStep) isFlowNavigator else parent == null + fun navigate( route: NavKey, options: NavOptions = NavOptions(), ) { - val belongsHere = if (route is FlowStep) isFlowNavigator else parent == null when { - belongsHere -> navigateHere(route, options) + belongsHere(route) -> navigateHere(route, options) parent != null -> parent.navigate(route, options) else -> trace( "Dropped navigation to FlowStep $route: no flow host on the stack", @@ -118,6 +120,22 @@ class CodeNavigator( } } + /** + * The navigator a [route] will actually land on under [navigate]'s route-type dispatch: + * a [FlowStep] stays on the nearest flow navigator, anything else bubbles to the root. + * Returns [this] for the unresolvable case (a [FlowStep] with no flow host), matching + * [navigate], which traces and drops it. + */ + fun dispatchTarget(route: NavKey): CodeNavigator = when { + belongsHere(route) -> this + parent != null -> parent.dispatchTarget(route) + else -> this + } + + /** The root of the [parent] chain — the app-level navigator that hosts sheets. */ + val rootNavigator: CodeNavigator + get() = parent?.rootNavigator ?: this + private fun navigateHere( route: NavKey, options: NavOptions = NavOptions(), 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 index 8eb0b74c8..2e911c486 100644 --- a/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt @@ -7,6 +7,7 @@ import com.getcode.navigation.StepTwo import com.getcode.navigation.testNavigator import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertSame class CodeNavigatorDispatchTest { @@ -75,4 +76,32 @@ class CodeNavigatorDispatchTest { assertEquals(listOf(StepTwo, StepOne), flow.backStack.toList()) assertEquals(listOf(AppHome), root.backStack.toList()) } + + @Test + fun `dispatchTarget returns this for a route that belongs here`() { + val root = testNavigator(AppHome) + assertSame(root, root.dispatchTarget(AppRegion)) + } + + @Test + fun `dispatchTarget bubbles a non-FlowStep route to the root`() { + val root = testNavigator(AppHome) + val flow = testNavigator(StepOne, parent = root, isFlow = true) + assertSame(root, flow.dispatchTarget(AppRegion)) + } + + @Test + fun `dispatchTarget keeps a FlowStep on the flow navigator`() { + val root = testNavigator(AppHome) + val flow = testNavigator(StepOne, parent = root, isFlow = true) + assertSame(flow, flow.dispatchTarget(StepTwo)) + } + + @Test + fun `rootNavigator walks the parent chain to the top`() { + val root = testNavigator(AppHome) + val flow = testNavigator(StepOne, parent = root, isFlow = true) + assertSame(root, flow.rootNavigator) + assertSame(root, root.rootNavigator) + } } From 6187ea0aebd6c59cc4c8722ac4ad08f6fcc9ceef Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 13:19:56 -0400 Subject: [PATCH 14/20] fix(navigation): register navigateForResult callback on the dispatch target --- .../navigation/results/NavResultStore.kt | 11 ++++++--- .../core/CodeNavigatorDispatchTest.kt | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt index 8d4ca31ba..ce6c8170c 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt @@ -294,9 +294,14 @@ inline fun CodeNavigator.navigateForResult( trace("navigateForResult: route=$route, currentRouteKey=$currentRouteKey", type = TraceType.Navigation) } coroutineScope.launchOrRun { - val curKey = currentRouteKey ?: error("No current route to register result callback with") - this.resultStore.registerCallback(curKey, route.asKey(), onResult) - navigate(route = route, options = options.copy(navigatingForResult = true)) + // Register the callback on the navigator the route will actually land on (the dispatch + // target up the parent chain), so the returning screen — which delivers through that same + // navigator's resultStore — is matched. For an intra-flow FlowStep result the target is + // this navigator, unchanged. + val target = dispatchTarget(route) + val curKey = target.currentRouteKey ?: error("No current route to register result callback with") + target.resultStore.registerCallback(curKey, route.asKey(), onResult) + target.navigate(route = route, options = options.copy(navigatingForResult = true)) } } 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 index 2e911c486..1b984ab13 100644 --- a/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt +++ b/ui/navigation/src/test/kotlin/com/getcode/navigation/core/CodeNavigatorDispatchTest.kt @@ -2,9 +2,12 @@ package com.getcode.navigation.core import com.getcode.navigation.AppHome import com.getcode.navigation.AppRegion +import com.getcode.navigation.DemoFlow +import com.getcode.navigation.DemoResult import com.getcode.navigation.StepOne import com.getcode.navigation.StepTwo import com.getcode.navigation.testNavigator +import com.getcode.navigation.results.navigateForResult import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertSame @@ -104,4 +107,24 @@ class CodeNavigatorDispatchTest { assertSame(root, flow.rootNavigator) assertSame(root, root.rootNavigator) } + + @Test + fun `navigateForResult from a flow nav pushes the route onto the root, not the flow stack`() { + val root = testNavigator(AppHome) + val flow = testNavigator(StepOne, parent = root, isFlow = true) + + flow.navigateForResult(DemoFlow()) { /* no-op */ } + + // The result route lands on the root (where the returning screen will deliver its result), + // not on the flow navigator's own stack. + assertEquals(DemoFlow(), root.backStack.last()) + assertEquals(listOf(StepOne), flow.backStack.toList()) + } + + // Coverage for dispatchTarget's else branch (FlowStep with no flow host), mirroring navigate's drop test. + @Test + fun `dispatchTarget returns this for a FlowStep with no flow host`() { + val root = testNavigator(AppHome) + assertSame(root, root.dispatchTarget(StepTwo)) + } } From 9c0eeefff906519be8cf51669b6b8c625fe37f70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 13:24:17 -0400 Subject: [PATCH 15/20] fix(navigation): route openAsSheet through the sheet-hosting root navigator --- .../flipcash/app/core/extensions/CodeNavigator.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt index a2ee19cf0..d82199885 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt @@ -13,18 +13,23 @@ import com.getcode.navigation.core.NavOptions * open, the current sheet is animated closed before the new one opens. */ fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List = emptyList()) { + // Sheets are hosted by the parent-chain root navigator — the one whose NavDisplay runs the + // ModalBottomSheetSceneStrategy and observes pendingSheetDismiss/sheetGeneration. Route the + // operation there so it works even when called from a flow's inner navigator. For a caller + // that is already the root, `host` is `this`, so behavior is unchanged. + val host = rootNavigator val destination = AppRoute.Main.Sheet(route, innerRoutes) - val hasSheet = backStack.any { it is AppRoute.Main.Sheet } + val hasSheet = host.backStack.any { it is AppRoute.Main.Sheet } if (hasSheet) { - pendingSheetDismiss = { + host.pendingSheetDismiss = { Snapshot.withMutableSnapshot { - sheetGeneration++ - navigate(destination) + host.sheetGeneration++ + host.navigate(destination) } } } else { - navigate(destination) + host.navigate(destination) } } From 7b9837a7984b9434b050550361ae202f4ca4290b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 13:27:14 -0400 Subject: [PATCH 16/20] refactor(navigation): swap uses single navigator for cross-boundary result nav --- .../kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 16af62b3b..fc12fd267 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -20,7 +20,7 @@ import com.flipcash.app.onramp.LocalCoinbaseOnRampController import com.flipcash.app.tokens.internal.SwapEntryScreenContent import com.flipcash.app.tokens.ui.SwapViewModel import com.flipcash.features.tokens.R -import com.getcode.navigation.flow.LocalOuterCodeNavigator +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.results.NavResultOrCanceled @@ -38,9 +38,7 @@ internal fun SwapEntryScreen( initialAmount: Fiat? = null, ) { val flowNavigator = rememberFlowNavigator() - // Result nav crosses the flow boundary: the callback must register on the same (app) store the - // Verification screen returns to, so this is explicitly the outer/app navigator. - val appNavigator = LocalOuterCodeNavigator.current + val navigator = LocalCodeNavigator.current val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val coinbaseOnRampController = LocalCoinbaseOnRampController.current @@ -107,7 +105,7 @@ internal fun SwapEntryScreen( .onEach { event -> val (phone, email) = event val mint = (viewModel.stateFlow.value.purpose as? SwapPurpose.Buy)?.mint ?: return@onEach - appNavigator.navigateForResult( + navigator.navigateForResult( AppRoute.Verification( origin = AppRoute.Token.Swap(SwapPurpose.Buy(mint)), includePhone = phone, @@ -151,7 +149,7 @@ internal fun SwapEntryScreen( LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { appNavigator.push(it.screen) } + .onEach { navigator.push(it.screen) } .launchIn(this) } From 373744df068d94c40485620ab192e920b593e43b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 13:30:06 -0400 Subject: [PATCH 17/20] refactor(navigation): login opens the lab sheet via the single navigator --- .../kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 6ffb6df5e..073f43068 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -54,7 +54,6 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.core.NavOptions import com.getcode.navigation.flow.FlowExitReason import com.getcode.navigation.flow.FlowHost -import com.getcode.navigation.flow.LocalOuterCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.results.NavResultStateRegistry @@ -282,7 +281,7 @@ private fun LoginStepContent(seed: String?) { val vm = hiltViewModel() val state by vm.stateFlow.collectAsStateWithLifecycle() val flowNavigator = rememberFlowNavigator() - val outerNavigator = LocalOuterCodeNavigator.current + val navigator = LocalCodeNavigator.current var visible by remember { mutableStateOf(false) } val activity = LocalActivity.current @@ -354,7 +353,7 @@ private fun LoginStepContent(seed: String?) { login = { flowNavigator.navigateTo(OnboardingStep.SeedInput) }, isLabsOpen = state.betaOptionsVisible, onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, - openBetaFlags = { outerNavigator.openAsSheet(AppRoute.Menu.Lab(onboarding = true)) }, + openBetaFlags = { navigator.openAsSheet(AppRoute.Menu.Lab(onboarding = true)) }, ) } } From bf12706fd27129b64095a02adf89224fa5fe71fe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 13:33:38 -0400 Subject: [PATCH 18/20] chore(navigation): delete unused flowAnnotatedEntry DSL --- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 5 ++--- .../com/getcode/navigation/NavMetadata.kt | 19 ------------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 4348b3060..7b1bdff52 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -71,9 +71,8 @@ private fun swapEntryProvider( val depositFirstPurpose = (route.purpose as? SwapPurpose.Buy) ?.takeIf { it.fundingSource == FundingSource.Phantom } - // annotatedEntry (not flowAnnotatedEntry): SwapEntryScreen no longer reads LocalCodeNavigator — - // it targets the outer navigator explicitly (LocalOuterCodeNavigator) for its cross-boundary - // navigateForResult/push — so the old outer re-shadow is inert. + // SwapEntryScreen reads only the inner LocalCodeNavigator; its cross-boundary result nav + // resolves to the app navigator via the dispatcher, so a plain annotatedEntry is correct. annotatedEntry { step -> SwapEntryScreen(step.purpose, step.initialAmount) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt index 866156a22..bbbc78971 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -2,13 +2,10 @@ package com.getcode.navigation import android.os.Parcelable import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.getcode.navigation.results.NavResultKey import com.getcode.navigation.results.NavigationRetVal -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.flow.LocalOuterCodeNavigator import java.lang.reflect.ParameterizedType import kotlin.reflect.KClass @@ -30,22 +27,6 @@ inline fun EntryProviderScope.annotatedEntry( entry(metadata = T::class.metadata(), content = content) } -/** - * Like [annotatedEntry] but re-provides the outer app-level [CodeNavigator][com.getcode.navigation.core.CodeNavigator] - * as [LocalCodeNavigator] inside [content]. Use this for flow steps that need to push routes onto the - * outer/app nav graph (e.g. region selection) while running inside a [FlowHost][com.getcode.navigation.flow.FlowHost]. - */ -inline fun EntryProviderScope.flowAnnotatedEntry( - noinline content: @Composable (T) -> Unit -) { - entry(metadata = T::class.metadata()) { step: T -> - val outerNavigator = LocalOuterCodeNavigator.current - CompositionLocalProvider(LocalCodeNavigator provides outerNavigator) { - content(step) - } - } -} - /** * Compute metadata from a [KClass] by inspecting its marker interfaces. * From c26eb217b00e04dcf598fbfe396eb1445a14ddd6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 13:37:29 -0400 Subject: [PATCH 19/20] refactor(navigation): remove LocalOuterCodeNavigator --- .../kotlin/com/getcode/navigation/flow/FlowHost.kt | 1 - .../com/getcode/navigation/flow/FlowNavigator.kt | 10 ---------- 2 files changed, 11 deletions(-) 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 d224ecac1..3ccd04c98 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 @@ -324,7 +324,6 @@ private fun FlowHostImpl( } CompositionLocalProvider( - LocalOuterCodeNavigator provides outerNavigator, LocalCodeNavigator provides innerNavigator, LocalFlowNavigator provides flowNavigator, LocalFlowViewModelStoreOwner provides flowOwner, diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt index 32d0f1b93..4a38171e7 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt @@ -66,16 +66,6 @@ interface FlowNavigator { val LocalFlowNavigator = staticCompositionLocalOf> { error("No FlowNavigator provided") } -/** - * The app-level [CodeNavigator] captured by [com.getcode.navigation.flow.FlowHost] *before* it - * overrides [com.getcode.navigation.core.LocalCodeNavigator] with the inner flow navigator. - * - * Use [flowAnnotatedEntry] to automatically re-provide this as [com.getcode.navigation.core.LocalCodeNavigator] - * for flow steps that need to push routes onto the outer/app nav graph (e.g. region selection). - */ -val LocalOuterCodeNavigator = - staticCompositionLocalOf { error("No outer CodeNavigator provided — are you inside a FlowHost?") } - /** * A no-op [FlowNavigator] for use in Compose `@Preview` functions. * All navigation calls are silently ignored. From 722fdf7c1a1419a5a090dfcfb5ca71ec64d9efd4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 13 Jul 2026 14:13:18 -0400 Subject: [PATCH 20/20] chore(navigation): remove unused Voyager alias overloads (show, push/List) --- .../com/getcode/navigation/core/CodeNavigator.kt | 13 ------------- 1 file changed, 13 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 4cbe0229f..e609baf03 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 @@ -291,11 +291,6 @@ class CodeNavigator( /** Push a route onto the back stack (equivalent to Voyager Navigator.push). */ fun push(route: NavKey) = navigate(route) - /** Push multiple routes onto the back stack. */ - fun push(routes: List) { - routes.forEach { navigate(it) } - } - /** Pop the current route (equivalent to Voyager Navigator.pop). */ fun pop() = navigateBack() @@ -305,14 +300,6 @@ class CodeNavigator( /** Replace entire back stack with multiple routes. */ fun replaceAll(routes: List) = restoreRouting(routes) - /** Show a sheet route (sheets are identified by metadata in Nav3). */ - fun show(route: NavKey) = navigate(route) - - /** Show multiple sheet routes (pushes each in order). */ - fun show(routes: List) { - routes.forEach { navigate(it) } - } - /** Hide/dismiss a sheet (pops the current route). */ fun hide() { val isSheetOpen = backStack.any { it is Sheet }