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