diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index 2f2f111cd..2560af122 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -36,7 +36,7 @@ import com.flipcash.app.android.BuildConfig import com.flipcash.app.bill.customization.BillPlaygroundScaffold import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager -import com.flipcash.app.core.extensions.navigateTo +import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.verification.email.LocalEmailCodeChannel import com.flipcash.app.featureflags.FeatureFlag @@ -258,7 +258,7 @@ internal fun App( } else false if (!delivered) { - codeNavigator.navigateTo(action.routes) + codeNavigator.navigateAll(action.routes) } } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 24f7bbe3f..652e54a24 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -31,7 +31,6 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositDestinationScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen -import com.flipcash.app.discovery.TokenDiscoverySheet import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen import com.flipcash.app.lab.StandaloneLabsScreen @@ -100,7 +99,7 @@ fun appEntryProvider( annotatedEntry { ShareAppScreen() } annotatedEntry { MenuScreen() } annotatedEntry { StandaloneLabsScreen() } - annotatedEntry { TokenDiscoverySheet() } + // Tokens annotatedEntry { key -> diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index 8ec53433a..a3c37f471 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -25,7 +25,7 @@ import com.flipcash.app.android.R import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction -import com.flipcash.app.core.extensions.navigateTo +import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.resolveRoutes import com.flipcash.app.router.LocalRouter import com.flipcash.app.router.Router @@ -128,7 +128,7 @@ internal fun MainRoot(deepLink: () -> DeepLink?) { if (!current.startsWith(target)) { navigator.replaceAll(launch.baseRoutes) if (launch.deeplinkRoutes.isNotEmpty()) { - navigator.navigateTo(launch.deeplinkRoutes) + navigator.navigateAll(launch.deeplinkRoutes) } } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index a95624d91..73ff3b2e6 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -76,7 +76,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable @Parcelize data class Sheet( - val initialRoute: Sheets, + val initialRoute: AppRoute, val innerRoutes: List = emptyList(), ) : Main, com.getcode.navigation.Sheet } @@ -114,9 +114,6 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object Lab : Sheets - @Serializable - data object TokenDiscovery: Sheets - @Serializable data object ShareApp : Sheets } @@ -196,8 +193,6 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object MyAccount : Menu @Serializable - data class Deposit(val mint: Mint) : Menu - @Serializable data object BackupKey : Menu @Serializable data object AppSettings : Menu 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 ee224e920..a2ee19cf0 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 @@ -7,27 +7,24 @@ import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.NavOptions /** - * Navigate to a route, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet] - * so the [ModalBottomSheetSceneStrategy] renders them in a bottom sheet. + * Open any [AppRoute] as a modal bottom sheet. + * + * Wraps [route] in [AppRoute.Main.Sheet] and navigates to it. If a sheet is already + * open, the current sheet is animated closed before the new one opens. */ -fun CodeNavigator.navigateTo(route: NavKey, options: NavOptions = NavOptions()) { - val destination = if (route is AppRoute.Sheets) { - AppRoute.Main.Sheet(route) - } else { - route - } - val needsSheet = destination is AppRoute.Main.Sheet +fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List = emptyList()) { + val destination = AppRoute.Main.Sheet(route, innerRoutes) val hasSheet = backStack.any { it is AppRoute.Main.Sheet } - if (hasSheet && needsSheet) { + if (hasSheet) { pendingSheetDismiss = { Snapshot.withMutableSnapshot { sheetGeneration++ - navigate(destination, options) + navigate(destination) } } } else { - navigate(destination, options) + navigate(destination) } } @@ -37,10 +34,9 @@ fun CodeNavigator.navigateTo(route: NavKey, options: NavOptions = NavOptions()) * so they appear inside the sheet rather than on the root backstack. * * If a sheet is already open and the new routes include a sheet, the current sheet - * is animated closed before the new one opens. For direct navigation without - * dismiss handling, use [navigate] directly. + * is animated closed before the new one opens. */ -fun CodeNavigator.navigateTo(routes: List, options: NavOptions = NavOptions()) { +fun CodeNavigator.navigateAll(routes: List, options: NavOptions = NavOptions()) { if (routes.isEmpty()) return val resolved = resolveRoutes(routes) @@ -48,9 +44,6 @@ fun CodeNavigator.navigateTo(routes: List, options: NavOptions = NavOpti val hasSheet = backStack.any { it is AppRoute.Main.Sheet } if (hasSheet && needsSheet) { - // Animate the current sheet down, then open the new one. - // The callback is invoked by ModalBottomSheetScene after the dismiss - // animation completes and the old entry is removed from the backstack. pendingSheetDismiss = { Snapshot.withMutableSnapshot { sheetGeneration++ diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt index adc50da96..1b522fc59 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt @@ -36,7 +36,7 @@ fun VerificationFlowScreen( FlowHost( initialStack = initialStack, resultStateRegistry = resultStateRegistry, - onExit = { reason -> + onExit = { reason, _ -> val result: VerificationResult = when (reason) { is FlowExitReason.Completed -> reason.result FlowExitReason.Canceled, diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt index a07a2598b..7f9387515 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt @@ -142,7 +142,7 @@ fun CurrencyCreatorFlowScreen( FlowHost( initialStack = initialStack, resultStateRegistry = resultStateRegistry, - onExit = { reason -> + onExit = { reason, _ -> val result: CurrencyCreatorResult = when (reason) { is FlowExitReason.Completed -> reason.result FlowExitReason.Canceled, 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 fab8e157d..e32eee7b7 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 @@ -57,22 +57,26 @@ fun DepositFlowScreen( FlowHost( initialStack = initialStack, resultStateRegistry = resultStateRegistry, - onExit = { reason -> + onExit = { reason, isSheetRoot -> val result: DepositResult = when (reason) { is FlowExitReason.Completed -> reason.result FlowExitReason.Canceled, FlowExitReason.BackedOutOfRoot -> DepositResult.Canceled } - outerNavigator.deliverFlowResult( - route = route, - value = NavResultOrCanceled.ReturnValue(result), - ) - when (result) { - DepositResult.Success -> { - outerNavigator.popUntil { it == AppRoute.Sheets.Menu } - } - DepositResult.Canceled -> { - outerNavigator.pop() + if (isSheetRoot) { + outerNavigator.pop() + } else { + outerNavigator.deliverFlowResult( + route = route, + value = NavResultOrCanceled.ReturnValue(result), + ) + when (result) { + DepositResult.Success -> { + outerNavigator.popUntil { it == AppRoute.Sheets.Menu } + } + DepositResult.Canceled -> { + outerNavigator.pop() + } } } }, diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt index c9b6376a0..a62346445 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,46 +23,34 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach - -@Composable -fun TokenDiscoverySheet() { - val navigator = LocalCodeNavigator.current - val viewModel = hiltViewModel() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_discoverCurrencies), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - endContent = { - AppBarDefaults.Close { navigator.hide() } - }, - ) - TokenDiscoveryScreen(viewModel) - } - - TokenDiscoveryEventHandler(viewModel, navigator) -} - @Composable fun TokenDiscoveryScreen() { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() + val isSheetRoot = remember { navigator.backStack.size <= 1 } Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppBarWithTitle( - title = stringResource(R.string.title_discoverCurrencies), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) + if (isSheetRoot) { + AppBarWithTitle( + title = stringResource(R.string.title_discoverCurrencies), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + endContent = { + AppBarDefaults.Close { navigator.hide() } + }, + ) + } else { + AppBarWithTitle( + title = stringResource(R.string.title_discoverCurrencies), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + } TokenDiscoveryScreen(viewModel) } diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt index cef9788b8..109a21681 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.navigateTo import com.flipcash.app.featureflags.FlagOption import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.featureflags.message @@ -122,7 +121,7 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { headline = stringResource(R.string.subtitle_settingsUserFlags), icon = rememberVectorPainter(Icons.Default.Token), ) { - navigator.navigateTo(AppRoute.UserFlags) + navigator.navigate(AppRoute.UserFlags) } } } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginRouter.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginRouter.kt index f9e4bd920..f80b26d01 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginRouter.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginRouter.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.navigateTo +import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.login.internal.LoginRouterScreenContent import com.getcode.navigation.core.LocalCodeNavigator import kotlinx.coroutines.delay @@ -83,7 +83,7 @@ fun LoginRouter( login = { navigator.push(AppRoute.Onboarding.SeedInput) }, isLabsOpen = state.betaOptionsVisible, onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, - openBetaFlags = { navigator.navigateTo(AppRoute.Sheets.Lab) } + openBetaFlags = { navigator.openAsSheet(AppRoute.Sheets.Lab) } ) } } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 2e22f310d..077a7fc16 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -33,7 +33,8 @@ import com.getcode.utils.ErrorUtils import com.kik.kikx.kikcodes.implementation.KikCodeResult import dev.theolm.rinku.DeepLink import timber.log.Timber -import com.flipcash.app.core.extensions.navigateTo +import com.flipcash.app.core.extensions.navigateAll +import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.navigation.DeeplinkType import com.getcode.manager.BottomBarAction @@ -95,17 +96,17 @@ internal fun Scanner() { BottomBarAction( text = context.getString(R.string.action_discoverCurrencies) ) { - navigator.navigateTo(AppRoute.Sheets.TokenDiscovery) + navigator.openAsSheet(AppRoute.Token.Discovery) }, ), showCancel = true, ) - return@BillContainer + return@BillContainer } } else -> Unit } - navigator.navigateTo(it.screen) + navigator.openAsSheet(it.screen) }, scannerView = { CodeScanner( @@ -143,7 +144,7 @@ internal fun Scanner() { else -> emptyList() } if (routes.isNotEmpty()) { - navigator.navigateTo(routes) + navigator.navigateAll(routes) } } is DeeplinkType.Login -> Unit diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt index b4cfd30d6..78a182089 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt @@ -9,5 +9,5 @@ sealed class ScannerDecorItem(val screen: AppRoute) { data object Wallet : ScannerDecorItem(AppRoute.Sheets.Wallet) data object Menu : ScannerDecorItem(AppRoute.Sheets.Menu) data object Logo: ScannerDecorItem(AppRoute.Sheets.ShareApp) - data object Discover: ScannerDecorItem(AppRoute.Sheets.TokenDiscovery) + data object Discover: ScannerDecorItem(AppRoute.Token.Discovery) } \ No newline at end of file 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 73c18e075..0645cb027 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 @@ -28,7 +28,7 @@ fun SwapFlowScreen( FlowHost( initialStack = initialStack, resultStateRegistry = resultStateRegistry, - onExit = { reason -> + onExit = { reason, _ -> val result = when (reason) { is FlowExitReason.Completed -> reason.result FlowExitReason.Canceled, 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 3996cd608..63f97f5be 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 @@ -40,7 +40,7 @@ fun WithdrawalFlowScreen( FlowHost( initialStack = initialStack, resultStateRegistry = resultStateRegistry, - onExit = { reason -> + onExit = { reason, _ -> val result: WithdrawalResult = when (reason) { is FlowExitReason.Completed -> reason.result FlowExitReason.Canceled, diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt index 169058851..b8d4a6cf5 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt @@ -3,7 +3,8 @@ package com.flipcash.app.router.internal import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.navigateTo +import com.flipcash.app.core.extensions.navigateAll +import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.extensions.resolveRoutes import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.EmptyCodeNavigator @@ -41,7 +42,7 @@ class NavigateToTest { val navigator = createNavigator(AppRoute.Main.Scanner) val mint = Mint("So11111111111111111111111111111111111111112") - navigator.navigateTo( + navigator.navigateAll( listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -58,7 +59,7 @@ class NavigateToTest { AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), ) - navigator.navigateTo(listOf(AppRoute.Menu.MyAccount), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Menu.MyAccount), options = quietOptions) assertNull(navigator.pendingSheetDismiss) } @@ -75,7 +76,7 @@ class NavigateToTest { ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") - navigator.navigateTo( + navigator.navigateAll( listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -93,7 +94,7 @@ class NavigateToTest { ) val initialGeneration = navigator.sheetGeneration - navigator.navigateTo(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) // Simulate what ModalBottomSheetScene does: remove old sheet, then invoke callback navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -110,7 +111,7 @@ class NavigateToTest { ) val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") - navigator.navigateTo( + navigator.navigateAll( listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), options = quietOptions, ) @@ -133,13 +134,13 @@ class NavigateToTest { ) // First replace - navigator.navigateTo(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() assertEquals(1, navigator.sheetGeneration) // Second replace - navigator.navigateTo(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) navigator.backStack.removeAt(navigator.backStack.lastIndex) navigator.pendingSheetDismiss!!.invoke() assertEquals(2, navigator.sheetGeneration) @@ -153,7 +154,7 @@ class NavigateToTest { fun `empty routes is a no-op`() { val navigator = createNavigator(AppRoute.Main.Scanner) - navigator.navigateTo(emptyList(), options = quietOptions) + navigator.navigateAll(emptyList(), options = quietOptions) assertEquals(1, navigator.backStack.size) assertNull(navigator.pendingSheetDismiss) @@ -170,7 +171,7 @@ class NavigateToTest { ), ) - navigator.navigateTo( + navigator.navigateAll( listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)), options = quietOptions, ) @@ -190,7 +191,7 @@ class NavigateToTest { AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), ) - navigator.navigateTo(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) // Simulate: a route is pushed during the dismiss animation navigator.backStack.add(AppRoute.Menu.MyAccount) @@ -213,7 +214,7 @@ class NavigateToTest { AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), ) - navigator.navigateTo(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.Wallet), options = quietOptions) // Simulate ModalBottomSheetScene dismiss: remove old sheet, then fire callback navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -256,11 +257,11 @@ class NavigateToTest { ) // First navigate sets pendingSheetDismiss - navigator.navigateTo(listOf(AppRoute.Sheets.Menu), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.Menu), options = quietOptions) assertNotNull(navigator.pendingSheetDismiss) // Second navigate overwrites pendingSheetDismiss - navigator.navigateTo(listOf(AppRoute.Sheets.Menu), options = quietOptions) + navigator.navigateAll(listOf(AppRoute.Sheets.Menu), options = quietOptions) // Simulate: onBack removes old sheet, then callback fires navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -278,16 +279,16 @@ class NavigateToTest { // endregion - // region Single-route navigateTo dismiss-then-replace + // region openAsSheet dismiss-then-replace @Test - fun `single-route navigateTo with existing sheet sets pendingSheetDismiss`() { + fun `openAsSheet with existing sheet sets pendingSheetDismiss`() { val navigator = createNavigator( AppRoute.Main.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), ) - navigator.navigateTo(AppRoute.Sheets.TokenDiscovery, options = quietOptions) + navigator.openAsSheet(AppRoute.Sheets.Lab) assertNotNull(navigator.pendingSheetDismiss) // Backstack unchanged until the callback fires @@ -295,13 +296,13 @@ class NavigateToTest { } @Test - fun `single-route navigateTo callback navigates to new sheet after dismiss`() { + fun `openAsSheet callback navigates to new sheet after dismiss`() { val navigator = createNavigator( AppRoute.Main.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), ) - navigator.navigateTo(AppRoute.Sheets.TokenDiscovery, options = quietOptions) + navigator.openAsSheet(AppRoute.Sheets.Lab) // Simulate dismiss: remove old sheet entry, then callback fires navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -309,18 +310,18 @@ class NavigateToTest { val last = navigator.backStack.last() assertIs(last) - assertEquals(AppRoute.Sheets.TokenDiscovery, last.initialRoute) + assertEquals(AppRoute.Sheets.Lab, last.initialRoute) } @Test - fun `single-route navigateTo increments sheetGeneration on dismiss-replace`() { + fun `openAsSheet increments sheetGeneration on dismiss-replace`() { val navigator = createNavigator( AppRoute.Main.Scanner, AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), ) val initialGeneration = navigator.sheetGeneration - navigator.navigateTo(AppRoute.Sheets.TokenDiscovery, options = quietOptions) + navigator.openAsSheet(AppRoute.Sheets.Lab) // Simulate dismiss navigator.backStack.removeAt(navigator.backStack.lastIndex) @@ -330,10 +331,10 @@ class NavigateToTest { } @Test - fun `single-route navigateTo without existing sheet navigates directly`() { + fun `openAsSheet without existing sheet navigates directly`() { val navigator = createNavigator(AppRoute.Main.Scanner) - navigator.navigateTo(AppRoute.Sheets.TokenDiscovery, options = quietOptions) + navigator.openAsSheet(AppRoute.Sheets.Lab) assertNull(navigator.pendingSheetDismiss) assertEquals(2, navigator.backStack.size) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt index 0053c6df4..36434a523 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.getcode.navigation.flow.FlowDismissStyle +import com.getcode.navigation.flow.LocalFlowDismissStyle import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem import com.getcode.ui.core.addIf @@ -164,12 +166,15 @@ fun AppBarWithTitle( onBackIconClicked: () -> Unit = {}, endContent: @Composable () -> Unit = { }, ) { + val flowDismissStyle = LocalFlowDismissStyle.current + val showClose = backButton && flowDismissStyle == FlowDismissStyle.Close + TopAppBarBase( modifier = modifier, isInModal = isInModal, contentPadding = contentPadding, leftIcon = { - if (backButton) { + if (backButton && !showClose) { AppBarDefaults.UpNavigation { onBackIconClicked() } } }, @@ -177,7 +182,11 @@ fun AppBarWithTitle( AppBarDefaults.Title(text = title) }, titleAlignment = titleAlignment, - rightContents = endContent + rightContents = if (showClose) { + { AppBarDefaults.Close { onBackIconClicked() } } + } else { + endContent + }, ) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowDismissStyle.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowDismissStyle.kt new file mode 100644 index 000000000..8be8e8269 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowDismissStyle.kt @@ -0,0 +1,7 @@ +package com.getcode.navigation.flow + +import androidx.compose.runtime.compositionLocalOf + +enum class FlowDismissStyle { Default, BackArrow, Close } + +val LocalFlowDismissStyle = compositionLocalOf { FlowDismissStyle.Default } 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 3f5c8ff20..8932a3294 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 @@ -89,14 +89,18 @@ private val DefaultFlowPopTransitionSpec: * FlowHost( * initialStack = route.initialStack.filterIsInstance(), * resultStateRegistry = resultStateRegistry, - * onExit = { reason -> + * onExit = { reason, isSheetRoot -> * val result = when (reason) { * is FlowExitReason.Completed -> reason.result * FlowExitReason.Canceled, * FlowExitReason.BackedOutOfRoot -> MyResult.Canceled * } - * outer.deliverFlowResult(route, NavResultOrCanceled.ReturnValue(result)) - * outer.pop() + * if (isSheetRoot) { + * outer.pop() + * } else { + * outer.deliverFlowResult(route, NavResultOrCanceled.ReturnValue(result)) + * outer.pop() + * } * }, * entryProvider = myEntryProvider(route), * ) @@ -112,7 +116,7 @@ private val DefaultFlowPopTransitionSpec: fun FlowHost( initialStack: List, resultStateRegistry: NavResultStateRegistry, - onExit: (FlowExitReason) -> Unit, + onExit: (reason: FlowExitReason, isSheetRoot: Boolean) -> Unit, entryProvider: (NavKey) -> NavEntry, decorators: List> = emptyList(), sceneStrategies: List> = listOf(SinglePaneSceneStrategy()), @@ -130,10 +134,15 @@ fun FlowHost( val currentOnExit = rememberUpdatedState(onExit) if (initialStack.isEmpty()) { - LaunchedEffect(Unit) { currentOnExit.value(FlowExitReason.BackedOutOfRoot) } + LaunchedEffect(Unit) { currentOnExit.value(FlowExitReason.BackedOutOfRoot, false) } return } + // Capture outer navigator and sheet context before overriding locals below. + val outerNavigator = LocalCodeNavigator.current + val sheetNavigator = LocalSheetNavigator.current + val isSheetRoot = remember { sheetNavigator != null && outerNavigator.backStack.size <= 1 } + // Seed the inner back stack from the initial step list. // Uses rememberSaveable so the stack survives composition removal (e.g. when a // sheet-level screen like RegionSelection is pushed on top via the outer navigator). @@ -160,19 +169,18 @@ fun FlowHost( val innerNavigator = rememberCodeNavigator( backStack = innerBackStack, resultStateRegistry = resultStateRegistry, - onRootReached = remember { { currentOnExit.value(FlowExitReason.BackedOutOfRoot) } }, + onRootReached = remember { { currentOnExit.value(FlowExitReason.BackedOutOfRoot, isSheetRoot) } }, ) val flowNavigator = remember(innerNavigator) { InnerFlowNavigator( navigator = innerNavigator, - onExit = { reason -> currentOnExit.value(reason) }, + onExit = { reason -> currentOnExit.value(reason, isSheetRoot) }, ) } // Propagate NonDismissableRoute / NonDraggableRoute from the current inner step // to the enclosing sheet so that drag-to-dismiss is blocked when a step requires it. - val sheetNavigator = LocalSheetNavigator.current if (sheetNavigator != null) { val currentInnerRoute by remember { derivedStateOf { innerBackStack.lastOrNull() } @@ -189,15 +197,20 @@ fun FlowHost( } } - // Expose the outer navigator so that flowAnnotatedEntry (or manual overrides) - // can restore it for steps that need to push routes onto the app-level nav graph. - val outerNavigator = LocalCodeNavigator.current + // Compute dismiss style for AppBarWithTitle auto-swap (back arrow vs close X). + val dismissStyle by remember { + derivedStateOf { + val atRoot = innerBackStack.size <= 1 + if (isSheetRoot && atRoot) FlowDismissStyle.Close else FlowDismissStyle.BackArrow + } + } CompositionLocalProvider( LocalOuterCodeNavigator provides outerNavigator, LocalCodeNavigator provides innerNavigator, LocalFlowNavigator provides flowNavigator, LocalFlowViewModelStoreOwner provides flowOwner, + LocalFlowDismissStyle provides dismissStyle, ) { AppNavHost( navigator = innerNavigator, diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index a231e791d..0191fbc69 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView @@ -78,13 +79,17 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c @OptIn(ExperimentalMaterial3Api::class) override val content: @Composable (() -> Unit) = { - // Scope composition by the scene key + generation counter so that - // rememberModalBottomSheetState (which uses rememberSaveable) creates a fresh - // SheetState when the route changes OR when the same route is re-opened via - // dismiss-then-replace. Without the generation counter, Compose reuses the - // Hidden SheetState because onBack + navigateTo happen in the same snapshot. + // Scope composition by the scene key so that rememberModalBottomSheetState + // (which uses rememberSaveable) creates a fresh SheetState when the route changes. + // + // NOTE: sheetGeneration is intentionally NOT part of this key. Putting it here + // causes the key() block to re-key while the old scene is still composing + // (during the pendingDismiss finally block), which creates a second + // ModalBottomSheet popup for the same SaveableStateProvider key → crash. + // Instead, sheetGeneration is used as the LaunchedEffect key for show() so + // same-route dismiss-replace re-shows the sheet without recreating the scope. val navigator = LocalCodeNavigator.current - key(key, navigator.sheetGeneration) { + key(key) { val isNonDismissable = (metadata[NavMetadataKeys.IsNonDismissable.key] as? Boolean ?: false) || navigator.sheetDismissDisabled @@ -116,11 +121,10 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c } ) - // Ensure the sheet shows when entering composition. Material3's internal - // LaunchedEffect is keyed on sheetState, which may be the same object when - // rememberSaveable restores a Hidden state for the same route key. Keying on - // Unit guarantees show() fires on every fresh composition entry. - LaunchedEffect(Unit) { + // Show the sheet on initial composition AND after same-route + // dismiss-replace (where sheetGeneration increments but the scene + // key stays the same, so rememberSaveable restores the Hidden state). + LaunchedEffect(navigator.sheetGeneration) { sheetState.show() } @@ -149,10 +153,19 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c try { sheetState.hide() } finally { - handleBackResult() - onBack() - navigator.pendingSheetDismiss = null - pendingDismiss() + // Apply all changes atomically so Compose never sees an + // intermediate state where the old sheet is removed but + // sheetGeneration hasn't incremented yet. Without this, + // the key(key, sheetGeneration) block can recompose and + // create a second ModalBottomSheet popup for the same key + // before the old one is destroyed → SaveableStateProvider + // duplicate-key crash. + Snapshot.withMutableSnapshot { + handleBackResult() + onBack() + navigator.pendingSheetDismiss = null + pendingDismiss() + } } } } @@ -171,7 +184,14 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c sheetState = sheetState, onDismissRequest = { if (!isNonDismissable) { - if (confirmHiddenCalled) { + if (navigator.pendingSheetDismiss != null) { + // External dismiss in progress (e.g. sheet replacement via + // openAsSheet). The pendingDismiss handler's finally block + // owns the onBack() call — don't let onDismissRequest also + // call it, which would remove the sheet prematurely while + // the handler is still running. + confirmHiddenCalled = false + } else if (confirmHiddenCalled) { confirmHiddenCalled = false dismiss(false) } else {