From 292d428723be4a9dc4f77b73ef18b7d0f1ff5b47 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 18 Mar 2026 10:04:10 -0400 Subject: [PATCH 1/7] feat: migrate to Nav3 Signed-off-by: Brandon McAnsh --- apps/flipcash/app/build.gradle.kts | 1 + .../com/flipcash/app/internal/ui/App.kt | 300 +++++-------- .../ui/navigation/AppRestrictedScreen.kt | 43 +- .../ui/navigation/AppScreenContent.kt | 257 +++++++---- .../app/internal/ui/navigation/MainRoot.kt | 231 +++++----- .../decorators/NavMessagingEntryDecorator.kt | 35 ++ apps/flipcash/core/build.gradle.kts | 1 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 90 ++-- .../app/core/extensions/CodeNavigator.kt | 30 ++ .../app/core/money/RegionSelectionKind.kt | 3 + .../app/core/navigation/DeeplinkType.kt | 11 +- .../WalletDeeplinkConnectionResult.kt | 26 +- .../flipcash/app/core/tokens/TokenPurpose.kt | 10 +- .../app/core/tokens/TokenSwapPurpose.kt | 8 +- .../app/advanced/AdvancedFeaturesScreen.kt | 91 ++-- .../app/appsettings/AppSettingsScreen.kt | 48 +- .../app/updates/UpdateRequiredView.kt | 2 +- .../flipcash/app/backupkey/BackupKeyScreen.kt | 53 +-- .../com/flipcash/app/balance/BalanceScreen.kt | 104 ++--- .../com/flipcash/app/cash/CashScreen.kt | 130 +++--- .../app/cash/internal/CashScreenContent.kt | 9 +- .../verification/VerificationFlowScreen.kt | 314 ++++++++------ .../email/EmailMagicLinkScreen.kt | 135 +++--- .../email/EmailVerificationScreen.kt | 104 ++--- .../internal/VerificationFlowIntroScreen.kt | 58 +-- .../verification/phone/PhoneCodeScreen.kt | 92 ++-- .../phone/PhoneCountryCodeScreen.kt | 69 ++- .../phone/PhoneVerificationScreen.kt | 124 +++--- .../com/flipcash/app/deposit/DepositScreen.kt | 60 +-- .../kotlin/com/flipcash/app/lab/LabsScreen.kt | 107 ++--- .../app/login/accesskey/AccessKeyScreen.kt | 60 +-- .../login/accesskey/PhotoAccessKeyScreen.kt | 35 +- .../login/internal/AccessKeyScreenContent.kt | 5 +- .../app/login/internal/SeedInputContent.kt | 5 +- .../flipcash/app/login/router/LoginRouter.kt | 116 +++-- .../app/login/seed/SeedInputScreen.kt | 46 +- .../app/login/seed/SeedInputViewModel.kt | 7 +- .../com/flipcash/app/menu/MenuScreen.kt | 77 ++-- .../flipcash/app/menu/internal/MenuItems.kt | 3 +- .../flipcash/app/myaccount/MyAccountScreen.kt | 145 +++---- .../app/onramp/OnRampCustomAmountScreen.kt | 110 ++--- .../app/onramp/OnRampProviderListScreen.kt | 115 ++--- .../screens/OnRampAmountScreenContent.kt | 9 +- .../app/purchase/PurchaseAccountScreen.kt | 47 +- .../internal/PurchaseAccountScreenContent.kt | 5 +- .../com/flipcash/app/scanner/ScannerScreen.kt | 23 +- .../internal/NavigationStateRestorer.kt | 57 +-- .../flipcash/app/scanner/internal/Scanner.kt | 15 +- .../app/scanner/internal/ScannerDecorItem.kt | 2 +- .../internal/bills/BillContainerView.kt | 5 +- .../flipcash/app/shareapp/ShareAppScreen.kt | 60 +-- .../internal/ShareAppScreenContent.kt | 5 +- .../app/tokens/TokenBuySellEntryScreen.kt | 144 +++--- .../flipcash/app/tokens/TokenInfoScreen.kt | 183 ++++---- .../flipcash/app/tokens/TokenSelectScreen.kt | 125 +++--- .../app/tokens/TokenSellReceiptScreen.kt | 85 ++-- .../app/tokens/TokenTxProcessingScreen.kt | 127 +++--- .../internal/TokenBuySellEntryScreen.kt | 7 +- .../app/tokens/internal/TokenInfoScreen.kt | 4 +- .../transactions/TransactionHistoryScreen.kt | 56 +-- .../WithdrawalConfirmationScreen.kt | 48 +- .../withdrawal/WithdrawalDestinationScreen.kt | 48 +- .../app/withdrawal/WithdrawalEntryScreen.kt | 54 +-- .../WithdrawalConfirmationScreen.kt | 6 +- .../WithdrawalDestinationScreen.kt | 3 +- .../internal/entry/WithdrawalEntryScreen.kt | 11 +- .../app/currency/RegionSelectionScreen.kt | 61 +-- .../internal/components/ListRowItem.kt | 3 +- .../app/onramp/ExternalWalletLocal.kt | 7 +- .../app/onramp/ExternalWalletOnRampHandler.kt | 35 +- .../app/permissions/CameraPermissionScreen.kt | 20 +- .../NotificationPermissionScreen.kt | 21 +- .../internal/PermissionScreenContent.kt | 11 +- .../kotlin/com/flipcash/app/router/Router.kt | 6 +- .../flipcash/app/router/internal/AppRouter.kt | 31 +- .../transfers/TransferInformationalScreen.kt | 172 ++++---- .../com/flipcash/app/web/WebViewScreen.kt | 21 +- gradle/libs.versions.toml | 7 + .../utils/serializer/ByteListSerializer.kt | 21 + .../opencode/internal/solana/model/SwapId.kt | 5 +- .../ui/analytics/AnalyticsScreenWatcher.kt | 9 +- .../ui/components/emojis/EmojiModal.kt | 64 ++- ui/core/build.gradle.kts | 1 + .../com/getcode/ui/core/RestrictionType.kt | 3 + ui/navigation/build.gradle.kts | 30 +- .../com/getcode/navigation/AppNavHost.kt | 102 +++++ .../com/getcode/navigation/NavContentKey.kt | 9 + .../com/getcode/navigation/NavMetadata.kt | 43 ++ .../kotlin/com/getcode/navigation/Types.kt | 7 + .../navigation/core/BottomSheetNavigator.kt | 354 --------------- .../getcode/navigation/core/CodeNavigator.kt | 271 ++++++++---- .../navigation/core/CombinedNavigator.kt | 212 --------- .../com/getcode/navigation/core/NavOptions.kt | 29 ++ .../NavResultScopeEntryDecorator.kt | 65 +++ .../getcode/navigation/extensions/Voyager.kt | 97 ++--- .../getcode/navigation/results/NavResult.kt | 33 ++ .../navigation/results/NavResultController.kt | 106 +++++ .../navigation/results/NavResultKey.kt | 24 + .../navigation/results/NavResultStore.kt | 409 ++++++++++++++++++ .../com/getcode/navigation/results/RetVal.kt | 17 + .../scenes/ModalBottomSheetSceneStrategy.kt | 184 ++++++++ .../getcode/navigation/screens/ChildNavTab.kt | 9 - .../navigation/screens/ContextSheet.kt | 115 ----- .../com/getcode/navigation/screens/Screens.kt | 100 ----- .../transitions/SheetSlideTransition.kt | 95 ---- .../navigation/utils/KClassSerializer.kt | 49 +++ .../utils/lifecycle}/RepeatOnLifecycle.kt | 28 +- .../kotlin/com/getcode/view/BaseViewModel.kt | 2 +- .../kotlin/com/getcode/view/BaseViewModel2.kt | 2 +- .../main/kotlin/com/getcode/theme/Theme.kt | 6 +- 110 files changed, 3477 insertions(+), 3818 deletions(-) create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt create mode 100644 libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/NavContentKey.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt delete mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/core/BottomSheetNavigator.kt delete mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/core/CombinedNavigator.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/core/NavOptions.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/decorators/NavResultScopeEntryDecorator.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResult.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultController.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultKey.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/results/RetVal.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt delete mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ChildNavTab.kt delete mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ContextSheet.kt delete mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/screens/Screens.kt delete mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/transitions/SheetSlideTransition.kt create mode 100644 ui/navigation/src/main/kotlin/com/getcode/navigation/utils/KClassSerializer.kt rename ui/navigation/src/main/kotlin/com/getcode/{ui/utils => navigation/utils/lifecycle}/RepeatOnLifecycle.kt (50%) diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 3f5eb4730..e5032a483 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -200,6 +200,7 @@ dependencies { implementation(project(":libs:quickresponse")) implementation(project(":ui:biometrics")) implementation(project(":ui:components")) + implementation(project(":ui:navigation")) implementation(project(":ui:scanner")) implementation(project(":ui:resources")) implementation(project(":ui:theme")) 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 21e534486..6a51a9caa 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 @@ -2,8 +2,6 @@ package com.flipcash.app.internal.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -13,7 +11,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId @@ -21,23 +18,18 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.startup.AppInitializer -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.stack.StackEvent -import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.currentOrThrow -import cafe.adriel.voyager.transitions.CrossfadeTransition -import cafe.adriel.voyager.transitions.SlideTransition +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.android.BuildConfig import com.flipcash.app.bill.customization.BillPlaygroundScaffold import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.app.internal.startup.DiscreteBondingCurveInitializer -import com.flipcash.app.internal.ui.navigation.AppScreenContent -import com.flipcash.app.internal.ui.navigation.MainRoot +import com.flipcash.app.internal.ui.navigation.AppPreloads +import com.flipcash.app.internal.ui.navigation.appEntryProvider +import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.onramp.ExternalWalletOnRampHandler import com.flipcash.app.onramp.LocalExternalWalletState import com.flipcash.app.onramp.OnRampAmountScaffold @@ -48,28 +40,23 @@ import com.flipcash.app.session.LocalSessionController import com.flipcash.app.theme.FlipcashTheme import com.flipcash.app.updates.UpdateRequiredBlockingView import com.flipcash.features.shareapp.R -import com.flipcash.services.modals.ModalManager import com.flipcash.services.user.AuthState import com.getcode.libs.biometrics.BiometricsError import com.getcode.libs.qr.rememberQrBitmapPainter -import com.getcode.navigation.core.BottomSheetNavigator -import com.getcode.navigation.core.CombinedNavigator +import com.getcode.navigation.AppNavHost import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.core.rememberCodeNavigator import com.getcode.navigation.extensions.getActivityScopedViewModel -import com.getcode.navigation.transitions.SheetSlideTransition +import com.getcode.navigation.results.rememberNavResultStateRegistry +import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.solana.rpc.RpcConfig import com.getcode.theme.CodeTheme -import com.getcode.theme.LocalCodeColors -import com.getcode.ui.biometrics.BiometricsState import com.getcode.ui.biometrics.LocalBiometricsState import com.getcode.ui.biometrics.rememberBiometricsState import com.getcode.ui.biometrics.views.BiometricsBlockingView import com.getcode.ui.components.OnLifecycleEvent -import com.getcode.ui.components.bars.BottomBarContainer -import com.getcode.ui.components.bars.TopBarContainer import com.getcode.ui.components.bars.rememberBarManager import com.getcode.ui.core.RestrictionType -import com.getcode.ui.theme.CodeScaffold import dev.bmcreations.tipkit.TipScaffold import dev.bmcreations.tipkit.engines.TipsEngine import dev.theolm.rinku.DeepLink @@ -80,7 +67,7 @@ internal fun App( tipsEngine: TipsEngine, solanaRpcConfig: RpcConfig, ) { - val router = LocalRouter.currentOrThrow + val router = LocalRouter.current!! val analytics = rememberAnalytics() val viewModel = getActivityScopedViewModel() val requireBiometrics by viewModel.requireBiometrics.collectAsStateWithLifecycle() @@ -99,11 +86,9 @@ internal fun App( } } - // We are obtaining deep link here to handle a login request while already logged in to - // present the option for the user to switch accounts var deepLink by remember { mutableStateOf(null) } var loginRequest by remember { mutableStateOf(null) } - val userManager = LocalUserManager.currentOrThrow + val userManager = LocalUserManager.current!! DeepLinkListener { analytics.deeplinkOpened(it.data) val type = router.processType(it) @@ -114,11 +99,10 @@ internal fun App( deepLink = it } - val session = LocalSessionController.currentOrThrow + val session = LocalSessionController.current!! val userState by userManager.state.collectAsState() FlipcashTheme { - // save download QR early rememberQrBitmapPainter( content = stringResource( R.string.app_download_link, @@ -134,123 +118,122 @@ internal fun App( CompositionLocalProvider( LocalExternalWalletState provides externalWalletOnRamp ) { - AppScreenContent { - PaymentScaffold { - OnRampAmountScaffold { - BillPlaygroundScaffold { - TipScaffold(tipsEngine = tipsEngine) { - AppNavHost(biometricsState) { - val codeNavigator = LocalCodeNavigator.current + AppPreloads() + + PaymentScaffold { + OnRampAmountScaffold { + BillPlaygroundScaffold { + TipScaffold(tipsEngine = tipsEngine) { + val backStack = remember { NavBackStack(AppRoute.Loading) } + val resultStateRegistry = rememberNavResultStateRegistry() + val codeNavigator = rememberCodeNavigator( + backStack = backStack, + resultStateRegistry = resultStateRegistry, + onRootReached = { /* handled by activity back press */ }, + ) + + val semanticsModifier = if (BuildConfig.DEBUG) { + Modifier.semantics { testTagsAsResourceId = true } + } else Modifier + + Box(modifier = semanticsModifier) { + CompositionLocalProvider( + LocalCodeNavigator provides codeNavigator, + LocalBiometricsState provides biometricsState, + ) { ExternalWalletOnRampHandler( state = externalWalletOnRamp, lifecycleOwner = LocalLifecycleOwner.current, navigator = codeNavigator, router = router, - deepLink = deepLink + deepLink = deepLink, ) { - CodeScaffold { innerPaddingModifier -> - Navigator( - screen = MainRoot { deepLink }, - ) { navigator -> - LaunchedEffect(navigator.lastItemOrNull) { - // update global navigator for platform access to support push/pop from a single - // navigator current - codeNavigator.screensNavigator = navigator - } - - Box( - modifier = Modifier - .padding(innerPaddingModifier) - ) { - - when (navigator.lastEvent) { - StackEvent.Replace -> { - when (navigator.lastItemOrNull) { - ScreenRegistry.get(AppRoute.Onboarding.SeedInput), - ScreenRegistry.get(AppRoute.Onboarding.AccessKey) -> { - CrossfadeTransition(navigator = navigator) - } - - else -> CurrentScreen() - } - } - else -> SlideTransition(navigator = navigator) - } - } - - LaunchedEffect(deepLink) { - if (codeNavigator.lastItem !is MainRoot) { - if (deepLink != null) { - val screenSet = - router.processDestination(deepLink) - if (screenSet.isNotEmpty()) { - codeNavigator.replaceAll(screenSet) - } + AppNavHost( + navigator = codeNavigator, + resultStateRegistry = resultStateRegistry, + decorators = listOf( + rememberNavMessagingEntryDecorator( + codeNavigator.backStack, + barManager + ) + ), + sceneStrategy = ModalBottomSheetSceneStrategy( + codeNavigator.resultStore + ) { + codeNavigator.backStack.getOrNull( + codeNavigator.backStack.lastIndex - 1 + ) + } then SinglePaneSceneStrategy(), + onBack = { codeNavigator.navigateBack() }, + entryProvider = { key -> + appEntryProvider( + key = key, + resultStateRegistry = resultStateRegistry, + barManager = barManager, + deepLink = { deepLink }, + ) + }, + ) + } - deepLink = null - } - } - } + LaunchedEffect(deepLink) { + if (codeNavigator.currentRouteKey is AppRoute.Loading) return@LaunchedEffect + if (deepLink != null) { + val routes = router.processDestination(deepLink) + if (routes.isNotEmpty()) { + codeNavigator.replaceAll(routes) + } + deepLink = null + } + } - LaunchedEffect( - loginRequest, - codeNavigator.lastItem, - userManager.authState - ) { - if (codeNavigator.lastItem is MainRoot) return@LaunchedEffect - if (userManager.authState !is AuthState.LoggedInWithUser) { - // reset login request here - // if we are not currently logged in, then the deeplink - // is most likely being processed in [MainRoot] during launch - loginRequest = null - return@LaunchedEffect - } - loginRequest?.let { entropy -> - viewModel.handleLoginEntropy( + LaunchedEffect( + loginRequest, + codeNavigator.lastItem, + userManager.authState + ) { + if (codeNavigator.currentRouteKey is AppRoute.Loading) return@LaunchedEffect + if (userManager.authState !is AuthState.LoggedInWithUser) { + loginRequest = null + return@LaunchedEffect + } + loginRequest?.let { entropy -> + viewModel.handleLoginEntropy( + entropy, + onSwitchAccount = { + loginRequest = null + codeNavigator.replaceAll( + AppRoute.Onboarding.Login( entropy, - onSwitchAccount = { - loginRequest = null - codeNavigator.replaceAll( - ScreenRegistry.get( - AppRoute.Onboarding.Login( - entropy, - fromDeeplink = true - ) - ) - ) - }, - onDismissed = { loginRequest = null } - ) - } - } - - LaunchedEffect(userState.isTimelockUnlocked) { - if (userState.isTimelockUnlocked) { - codeNavigator.replaceAll( - ScreenRegistry.get( - AppRoute.Main.AppRestricted( - RestrictionType.TIMELOCK_UNLOCKED - ) - ) + fromDeeplink = true ) - } - } - - OnLifecycleEvent { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - session.onAppInForeground() - } + ) + }, + onDismissed = { loginRequest = null } + ) + } + } - Lifecycle.Event.ON_STOP, - Lifecycle.Event.ON_DESTROY -> { - session.onAppInBackground() - } + LaunchedEffect(userState.isTimelockUnlocked) { + if (userState.isTimelockUnlocked) { + codeNavigator.replaceAll( + AppRoute.Main.AppRestricted( + RestrictionType.TIMELOCK_UNLOCKED + ) + ) + } + } - else -> Unit - } - } + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + session.onAppInForeground() + } + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_DESTROY -> { + session.onAppInBackground() } + else -> Unit } } } @@ -262,57 +245,6 @@ internal fun App( BiometricsBlockingView(modifier = Modifier.fillMaxSize(), biometricsState) UpdateRequiredBlockingView(modifier = Modifier.fillMaxSize(), biometricsState = biometricsState) - TopBarContainer(barManager.barMessages) - BottomBarContainer(barManager.barMessages) } } } - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun AppNavHost( - biometricsState: BiometricsState, - content: @Composable () -> Unit -) { - var combinedNavigator by remember { - mutableStateOf(null) - } - - val semanticsModifier = if (BuildConfig.DEBUG) { - Modifier.semantics { testTagsAsResourceId = true } - } else Modifier - - Box(modifier = semanticsModifier) { - BottomSheetNavigator( - modifier = Modifier.fillMaxSize(), - sheetBackgroundColor = LocalCodeColors.current.background, - sheetContentColor = LocalCodeColors.current.onBackground, - sheetContent = { sheetNav -> - if (combinedNavigator == null) { - combinedNavigator = CombinedNavigator(sheetNav) - } - combinedNavigator?.let { - CompositionLocalProvider( - LocalCodeNavigator provides it, - LocalBiometricsState provides biometricsState, - ) { - SheetSlideTransition(navigator = it) - } - } - }, - onHide = ModalManager::clear - ) { sheetNav -> - if (combinedNavigator == null) { - combinedNavigator = CombinedNavigator(sheetNav) - } - combinedNavigator?.let { - CompositionLocalProvider( - LocalCodeNavigator provides it, - LocalBiometricsState provides biometricsState, - ) { - content() - } - } - } - } -} \ No newline at end of file diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt index 1ccc5e168..d08c0e4cf 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt @@ -1,43 +1,28 @@ package com.flipcash.app.internal.ui.navigation -import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.Screen import com.flipcash.app.core.AppRoute import com.flipcash.app.internal.ui.HomeViewModel import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.extensions.getActivityScopedViewModel -import com.getcode.navigation.screens.AppScreen import com.getcode.ui.components.restrictions.ContentRestrictedView import com.getcode.ui.core.RestrictionType import kotlinx.coroutines.launch -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class AppRestrictedScreen(private val restrictionType: RestrictionType): AppScreen, Parcelable { - - @IgnoredOnParcel - override val testTag: String = "app_restricted_screen" - - @Composable - override fun ScreenContent() { - val homeViewModel = getActivityScopedViewModel() - val navigator = LocalCodeNavigator.current - val coroutineScope = rememberCoroutineScope() - ContentRestrictedView(restrictionType) { - coroutineScope.launch { - homeViewModel.logout() - .onSuccess { - navigator.replaceAll( - ScreenRegistry.get( - AppRoute.Onboarding.Login() - ) - ) - } - } +@Composable +fun AppRestrictedScreen(restrictionType: RestrictionType) { + val homeViewModel = getActivityScopedViewModel() + val navigator = LocalCodeNavigator.current + val coroutineScope = rememberCoroutineScope() + ContentRestrictedView(restrictionType) { + coroutineScope.launch { + homeViewModel.logout() + .onSuccess { + navigator.replaceAll( + AppRoute.Onboarding.Login() + ) + } } } -} \ No newline at end of file +} 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 fd3bbd4f1..a41c169c1 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 @@ -1,8 +1,16 @@ package com.flipcash.app.internal.ui.navigation +import androidx.activity.compose.BackHandler +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.Screen +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.advanced.AdvancedFeaturesScreen import com.flipcash.app.appsettings.AppSettingsScreen import com.flipcash.app.backupkey.BackupKeyScreen @@ -13,6 +21,7 @@ import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.core.AppRoute import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositScreen +import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen import com.flipcash.app.lab.PreloadLabs import com.flipcash.app.lab.StandaloneLabsScreen @@ -39,151 +48,211 @@ import com.flipcash.app.withdrawal.WithdrawalConfirmationScreen import com.flipcash.app.withdrawal.WithdrawalDestinationScreen import com.flipcash.app.withdrawal.WithdrawalEntryScreen import com.flipcash.app.withdrawal.WithdrawalFlow -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.AppNavHost +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.core.rememberCodeNavigator +import com.getcode.navigation.metadata +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher +import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy +import com.getcode.ui.components.bars.BarManager +import dev.theolm.rinku.DeepLink @Composable -internal fun AppScreenContent(content: @Composable () -> Unit) { - ScreenRegistry { - register { - LoginRouter(it.seed, it.fromDeeplink) +fun AppPreloads() { + PreloadBalance() + PreloadLabs() +} + +fun appEntryProvider( + key: NavKey, + resultStateRegistry: NavResultStateRegistry, + barManager: BarManager, + deepLink: () -> DeepLink?, +): NavEntry { + return when (key) { + is AppRoute.Loading -> NavEntry(key = key, metadata = key.metadata()) { + MainRoot(deepLink) } - register { + is AppRoute.Onboarding.Login -> NavEntry(key = key, metadata = key.metadata()) { + LoginRouter(key.seed, key.fromDeeplink) + } + is AppRoute.Onboarding.SeedInput -> NavEntry(key = key, metadata = key.metadata()) { SeedInputScreen() } - - register { + is AppRoute.Onboarding.AccessKey -> NavEntry(key = key, metadata = key.metadata()) { AccessKeyScreen() } - - register { + is AppRoute.Onboarding.AccessKeySavedLocation -> NavEntry(key = key, metadata = key.metadata()) { PhotoAccessKeyScreen() } - - register { - PurchaseAccountScreen(it.fromLogin) + is AppRoute.Onboarding.Purchase -> NavEntry(key = key, metadata = key.metadata()) { + PurchaseAccountScreen(key.fromLogin) } - - register { - StandaloneLabsScreen() + is AppRoute.Onboarding.NotificationPermission, + is AppRoute.Onboarding.CameraPermission -> NavEntry(key = key, metadata = key.metadata()) { + // Deprecated — permissions requested at time of use } - register { - AppRestrictedScreen(it.restrictionType) + is AppRoute.Main.Sheet -> sheetEntry(key, resultStateRegistry, barManager) + is AppRoute.Main.AppRestricted -> NavEntry(key = key, metadata = key.metadata()) { + AppRestrictedScreen(key.restrictionType) } - - register { - ScannerScreen(it.deeplink) + is AppRoute.Main.Scanner -> NavEntry(key = key, metadata = key.metadata()) { + ScannerScreen(key.deeplink) } - - register { - CashScreen(it.mint, it.fromTokenInfo) + is AppRoute.Sheets.Give -> NavEntry(key = key, metadata = key.metadata()) { + CashScreen(key.mint, key.fromTokenInfo) } - - register { - TokenInfoScreen(it.mint, it.forNeededFunds, it.fromDeeplink) + is AppRoute.Main.RegionSelection -> NavEntry(key = key, metadata = key.metadata()) { + RegionSelectionScreen(key.kind) } - register { - TransactionHistoryScreen(it.mint) + is AppRoute.Token.Info -> NavEntry(key = key, metadata = key.metadata()) { + TokenInfoScreen(key.mint, key.forNeededFunds, key.fromDeeplink) } - - register { - BuySellFlow.start(it.forNeededFunds) - TokenBuySellEntryScreen(it.purpose) + is AppRoute.Token.Transactions -> NavEntry(key = key, metadata = key.metadata()) { + TransactionHistoryScreen(key.mint) } - - register { - TokenTxProcessingScreen(it.swapId, it.awaitExternalWallet) + is AppRoute.Token.SwapTransact -> NavEntry(key = key, metadata = key.metadata()) { + remember { BuySellFlow.start(key.forNeededFunds) } + TokenBuySellEntryScreen(key.purpose) } - - register { + is AppRoute.Token.TxProcessing -> NavEntry(key = key, metadata = key.metadata()) { + TokenTxProcessingScreen(key.swapId, key.awaitExternalWallet) + } + is AppRoute.Token.SellReceipt -> NavEntry(key = key, metadata = key.metadata()) { TokenSellReceiptScreen() } - register { - TokenSelectScreen(it.purpose) + is AppRoute.Sheets.TokenSelection -> NavEntry(key = key, metadata = key.metadata()) { + TokenSelectScreen(key.purpose) } - - register { + is AppRoute.Sheets.Wallet -> NavEntry(key = key, metadata = key.metadata()) { BalanceScreen() } - - register { - RegionSelectionScreen(it.kind) - } - - register { + is AppRoute.Sheets.ShareApp -> NavEntry(key = key, metadata = key.metadata()) { ShareAppScreen() } + is AppRoute.Sheets.Menu -> NavEntry(key = key, metadata = key.metadata()) { + MenuScreen() + } + is AppRoute.Sheets.Lab -> NavEntry(key = key, metadata = key.metadata()) { + StandaloneLabsScreen() + } - register { + is AppRoute.Verification -> NavEntry(key = key, metadata = key.metadata()) { VerificationFlowScreen( - origin = it.origin, - target = it.target, - includePhone = it.includePhone, - includeEmail = it.includeEmail, - emailAddress = it.email, - emailVerificationCode = it.emailVerificationCode + origin = key.origin, + target = key.target, + includePhone = key.includePhone, + includeEmail = key.includeEmail, + emailAddress = key.email, + emailVerificationCode = key.emailVerificationCode, ) } - register { - OnRampFlowTracker.start(it.from) + is AppRoute.OnRamp.ProviderList -> NavEntry(key = key, metadata = key.metadata()) { + remember { OnRampFlowTracker.start(key.from) } OnRampProviderListScreen( - neededAmount = it.neededAmount?.quarks, - neededCurrency = it.neededAmount?.currencyCode + neededAmount = key.neededAmount?.quarks, + neededCurrency = key.neededAmount?.currencyCode, ) } - - register { + is AppRoute.OnRamp.AmountEntry -> NavEntry(key = key, metadata = key.metadata()) { OnRampCustomAmountScreen() } - register { - MenuScreen() - } - - register { + is AppRoute.Menu.AppSettings -> NavEntry(key = key, metadata = key.metadata()) { AppSettingsScreen() } - - register { + is AppRoute.Menu.Lab -> NavEntry(key = key, metadata = key.metadata()) { LabsScreen() } - - register { - WithdrawalFlow.start() - WithdrawalEntryScreen(it.mint) + is AppRoute.Menu.MyAccount -> NavEntry(key = key, metadata = key.metadata()) { + MyAccountScreen() + } + is AppRoute.Menu.Deposit -> NavEntry(key = key, metadata = key.metadata()) { + DepositScreen(key.mint) + } + is AppRoute.Menu.BackupKey -> NavEntry(key = key, metadata = key.metadata()) { + BackupKeyScreen() + } + is AppRoute.Menu.AdvancedFeatures -> NavEntry(key = key, metadata = key.metadata()) { + AdvancedFeaturesScreen() } - register { + is AppRoute.Transfers.Withdrawal.Amount -> NavEntry(key = key, metadata = key.metadata()) { + remember { WithdrawalFlow.start() } + WithdrawalEntryScreen(key.mint) + } + is AppRoute.Transfers.Withdrawal.Destination -> NavEntry(key = key, metadata = key.metadata()) { WithdrawalDestinationScreen() } - - register { + is AppRoute.Transfers.Withdrawal.Confirmation -> NavEntry(key = key, metadata = key.metadata()) { WithdrawalConfirmationScreen() } - register { - MyAccountScreen() - } - - register { - DepositScreen(it.mint) + else -> error("Unknown route: $key") + } +} + +/** + * Sheet entry with nested [AppNavHost] for inner-sheet navigation. + * Uses slide transitions for intra-sheet navigation. + */ +private fun sheetEntry( + key: AppRoute.Main.Sheet, + resultStateRegistry: NavResultStateRegistry, + barManager: BarManager, +): NavEntry { + return NavEntry(key = key, metadata = key.metadata()) { + val sheetDismissDispatcher = LocalBottomSheetDismissDispatcher.current + val backStack = rememberNavBackStack(key.initialRoute) + val navigator = rememberCodeNavigator( + backStack = backStack, + resultStateRegistry = resultStateRegistry, + onRootReached = { sheetDismissDispatcher() }, + ) + + val onBack = { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } else { + sheetDismissDispatcher() + } } - register { - BackupKeyScreen() - } + CompositionLocalProvider(LocalCodeNavigator provides navigator) { + AppNavHost( + navigator = navigator, + resultStateRegistry = resultStateRegistry, + decorators = listOf( + rememberNavMessagingEntryDecorator(navigator.backStack, barManager) + ), + sceneStrategy = ModalBottomSheetSceneStrategy(navigator.resultStore) { + navigator.backStack.getOrNull(navigator.backStack.lastIndex - 1) + } then SinglePaneSceneStrategy(), + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + }, + popTransitionSpec = { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + predictivePopTransitionSpec = { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + onBack = { onBack() }, + entryProvider = { innerKey -> + appEntryProvider(innerKey, resultStateRegistry, barManager, deepLink = { null }) + } + ) - register { - AdvancedFeaturesScreen() + BackHandler { onBack() } } } - - PreloadBalance() - PreloadLabs() - - content() -} \ No newline at end of file +} 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 cb46a4287..af3ef0ce7 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 @@ -1,6 +1,5 @@ package com.flipcash.app.internal.ui.navigation -import android.os.Parcelable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -21,12 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.painterResource -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow +import androidx.navigation3.runtime.NavKey import com.flipcash.app.android.R import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute @@ -34,7 +28,7 @@ import com.flipcash.app.router.LocalRouter import com.flipcash.app.router.Router import com.flipcash.services.internal.model.account.UserFlags import com.flipcash.services.user.AuthState -import com.getcode.navigation.screens.AppScreen +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.utils.trace @@ -44,149 +38,138 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize import kotlin.time.Duration.Companion.seconds -@Parcelize -internal class MainRoot(private val deepLink: () -> DeepLink?) : AppScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "root_screen" - - @Composable - override fun ScreenContent() { - val navigator = LocalNavigator.currentOrThrow - val userManager = LocalUserManager.currentOrThrow - var showLoading by remember { mutableStateOf(false) } - val router = LocalRouter.currentOrThrow - var showLogo by remember { mutableStateOf(false) } - Box( +@Composable +internal fun MainRoot(deepLink: () -> DeepLink?) { + val navigator = LocalCodeNavigator.current + val userManager = LocalUserManager.current!! + var showLoading by remember { mutableStateOf(false) } + val router = LocalRouter.current!! + var showLogo by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.brand), + contentAlignment = Alignment.Center, + ) { + Column( modifier = Modifier - .fillMaxSize() - .background(CodeTheme.colors.brand), - contentAlignment = Alignment.Center, + .fillMaxWidth(0.65f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { Column( - modifier = Modifier - .fillMaxWidth(0.65f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (showLogo) { - Image( - painter = painterResource(R.drawable.ic_flipcash_logo_w_name), - contentDescription = null, - ) - } + if (showLogo) { + Image( + painter = painterResource(R.drawable.ic_flipcash_logo_w_name), + contentDescription = null, + ) } - - Spacer(modifier = Modifier.requiredHeight(CodeTheme.dimens.inset)) - val loadingAlpha by animateFloatAsState( - if (showLoading) 1f else 0f, - label = "loading visibility" - ) - CodeCircularProgressIndicator( - modifier = Modifier.alpha(loadingAlpha) - ) } - } + Spacer(modifier = Modifier.requiredHeight(CodeTheme.dimens.inset)) + val loadingAlpha by animateFloatAsState( + if (showLoading) 1f else 0f, + label = "loading visibility" + ) + CodeCircularProgressIndicator( + modifier = Modifier.alpha(loadingAlpha) + ) + } + } - LaunchedEffect(userManager) { - userManager.state - .map { it.authState to it.flags } - .distinctUntilChanged() - .onEach { (state, flags) -> - trace( - tag = "AuthStateRouter", - message = "Handling auth state change during app launch => $state", - metadata = { - "state" to state - } - ) - val screens = buildNavGraphForLaunch( - state = state, - userFlags = flags, - router = router - ) - when (state) { - AuthState.LoggedInAwaitingUser -> { - delay(1.5.seconds) - showLoading = true - showLogo = true - } + LaunchedEffect(userManager) { + userManager.state + .map { it.authState to it.flags } + .distinctUntilChanged() + .onEach { (state, flags) -> + trace( + tag = "AuthStateRouter", + message = "Handling auth state change during app launch => $state", + metadata = { + "state" to state + } + ) + val routes = buildNavGraphForLaunch( + state = state, + userFlags = flags, + router = router, + deepLink = deepLink + ) - AuthState.LoggedInWithUser -> { - showLogo = false - } + when (state) { + AuthState.LoggedInAwaitingUser -> { + delay(1.5.seconds) + showLoading = true + showLogo = true + } - else -> { - showLogo = true - } + AuthState.LoggedInWithUser -> { + showLogo = false } - if (screens != null) { - navigator.replaceAll(screens) + else -> { + showLogo = true } - }.launchIn(this) - } + } + + if (routes != null) { + navigator.replaceAll(routes) + } + }.launchIn(this) } +} - private suspend fun buildNavGraphForLaunch( - state: AuthState, - userFlags: UserFlags?, - router: Router, - ): List? { - return when (state) { - is AuthState.Registered -> { - if (state.seenAccessKey) { - buildList { - if (userFlags?.requiresIapForRegistration == true) { - addAll( - listOf( - ScreenRegistry.get(AppRoute.Onboarding.Login()), - ScreenRegistry.get(AppRoute.Onboarding.AccessKey), - ScreenRegistry.get(AppRoute.Onboarding.Purchase()) - ) +private suspend fun buildNavGraphForLaunch( + state: AuthState, + userFlags: UserFlags?, + router: Router, + deepLink: () -> DeepLink?, +): List? { + return when (state) { + is AuthState.Registered -> { + if (state.seenAccessKey) { + buildList { + if (userFlags?.requiresIapForRegistration == true) { + addAll( + listOf( + AppRoute.Onboarding.Login(), + AppRoute.Onboarding.AccessKey, + AppRoute.Onboarding.Purchase() ) - } else { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner())) - } + ) + } else { + addAll(listOf(AppRoute.Main.Scanner())) } - } else { - listOf( - ScreenRegistry.get(AppRoute.Onboarding.Login()), - ScreenRegistry.get(AppRoute.Onboarding.AccessKey) - ) } + } else { + listOf( + AppRoute.Onboarding.Login(), + AppRoute.Onboarding.AccessKey + ) } + } - AuthState.LoggedInWithUser -> { - val screens = router.processDestination(deepLink()) + AuthState.LoggedInWithUser -> { + val routes = router.processDestination(deepLink()) - screens.ifEmpty { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner())) - } + routes.ifEmpty { + listOf(AppRoute.Main.Scanner()) } + } - AuthState.LoggedOut, - AuthState.Unknown -> { - val screens = router.processDestination(deepLink()) - screens.ifEmpty { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) - } + AuthState.LoggedOut, + AuthState.Unknown -> { + val routes = router.processDestination(deepLink()) + routes.ifEmpty { + listOf(AppRoute.Onboarding.Login()) } - - AuthState.LoggedInAwaitingUser -> null } + + AuthState.LoggedInAwaitingUser -> null } } - diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt new file mode 100644 index 000000000..ffeaa7a8a --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt @@ -0,0 +1,35 @@ +package com.flipcash.app.internal.ui.navigation.decorators + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.NavMetadataKeys +import com.getcode.navigation.metadata +import com.getcode.ui.components.bars.BarManager +import com.getcode.ui.components.bars.BottomBarContainer +import com.getcode.ui.components.bars.TopBarContainer + +@Suppress("FunctionName") +fun NavMessagingEntryDecorator( + backStack: NavBackStack, + barManager: BarManager +): NavEntryDecorator { + return NavEntryDecorator { entry -> + Box { + entry.Content() + if (backStack.lastOrNull()?.metadata()[NavMetadataKeys.IsSheet.key] != true) { + TopBarContainer(barManager.barMessages) + BottomBarContainer(barManager.barMessages) + } + } + } +} + +@Composable +fun rememberNavMessagingEntryDecorator( + backStack: NavBackStack, + barManager: BarManager +) = remember { NavMessagingEntryDecorator(backStack, barManager) } diff --git a/apps/flipcash/core/build.gradle.kts b/apps/flipcash/core/build.gradle.kts index c91d5db42..33687893e 100644 --- a/apps/flipcash/core/build.gradle.kts +++ b/apps/flipcash/core/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { api(project(":vendor:kik:scanner")) api(project(":ui:core")) + api(libs.navigation3.runtime) api(project(":vendor:tipkit:tipkit-m2")) } 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 ef3eca98a..21f0eca26 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 @@ -1,49 +1,57 @@ package com.flipcash.app.core import android.os.Parcelable -import cafe.adriel.voyager.core.registry.ScreenProvider +import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.tokens.TokenSwapPurpose -import com.flipcash.app.core.transfers.TransferDirection -import com.getcode.ed25519.Ed25519 +import com.getcode.navigation.Sheet import com.getcode.opencode.internal.solana.model.SwapId -import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import com.getcode.ui.core.RestrictionType import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable @Parcelize -sealed interface AppRoute : ScreenProvider, Parcelable { +sealed interface AppRoute : NavKey, Parcelable { + /** Initial loading/splash route shown while auth state resolves. */ + @Serializable @Parcelize data object Loading : AppRoute + + @Serializable @Parcelize // TODO: turn into a Flow sealed interface Onboarding: AppRoute { - data class Login(val seed: String? = null, val fromDeeplink: Boolean = false) : Onboarding - data object SeedInput : Onboarding - data object AccessKey : Onboarding - data object AccessKeySavedLocation: Onboarding - data class Purchase(val fromLogin: Boolean = false) : Onboarding + @Serializable data class Login(val seed: String? = null, val fromDeeplink: Boolean = false) : Onboarding + @Serializable data object SeedInput : Onboarding + @Serializable data object AccessKey : Onboarding + @Serializable data object AccessKeySavedLocation: Onboarding + @Serializable data class Purchase(val fromLogin: Boolean = false) : Onboarding @Deprecated("Onboarding streamlined; permissions now requested at time of use") - data class NotificationPermission(val postCreate: Boolean = false) : Onboarding + @Serializable data class NotificationPermission(val postCreate: Boolean = false) : Onboarding @Deprecated("Onboarding streamlined; permissions now requested at time of use") - data class CameraPermission(val postCreate: Boolean = false) : Onboarding + @Serializable data class CameraPermission(val postCreate: Boolean = false) : Onboarding } + @Serializable @Parcelize sealed interface Main: AppRoute { - data class AppRestricted(val restrictionType: RestrictionType) : Main - data class Scanner(val deeplink: DeeplinkType? = null) : Main + @Serializable data class AppRestricted(val restrictionType: RestrictionType) : Main + @Serializable data class Scanner(val deeplink: DeeplinkType? = null) : Main // TODO: is there a better place for this to live? - data class RegionSelection(val kind: RegionSelectionKind) : Main + @Serializable data class RegionSelection(val kind: RegionSelectionKind) : Main - data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Main + @Serializable + @Parcelize + data class Sheet(val initialRoute: Sheets) : Main, com.getcode.navigation.Sheet } + @Serializable @Parcelize data class Verification( val origin: AppRoute, @@ -54,54 +62,62 @@ sealed interface AppRoute : ScreenProvider, Parcelable { val emailVerificationCode: String? = null, ): AppRoute + @Serializable @Parcelize sealed interface Sheets: AppRoute { - data class TokenSelection(val purpose: TokenPurpose): Sheets - data object Wallet : Sheets - data object Menu : Sheets - data object Lab: Sheets - data object ShareApp : Sheets + @Serializable data class TokenSelection(val purpose: TokenPurpose): Sheets + @Serializable data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets + @Serializable data object Wallet : Sheets + @Serializable data object Menu : Sheets + @Serializable data object Lab: Sheets + @Serializable data object ShareApp : Sheets } + @Serializable @Parcelize sealed interface Token: AppRoute { - data class Info(val mint: Mint, val forNeededFunds: Boolean = false, val fromDeeplink: Boolean = false): Token - data class Transactions(val mint: Mint): Token - data class SwapTransact(val purpose: TokenSwapPurpose, val forNeededFunds: Boolean = false): Token - data class TxProcessing(val swapId: SwapId, val awaitExternalWallet: Boolean = false): Token - data object SellReceipt: Token + @Serializable data class Info(val mint: Mint, val forNeededFunds: Boolean = false, val fromDeeplink: Boolean = false): Token + @Serializable data class Transactions(val mint: Mint): Token + @Serializable data class SwapTransact(val purpose: TokenSwapPurpose, val forNeededFunds: Boolean = false): Token + @Serializable data class TxProcessing(val swapId: SwapId, val awaitExternalWallet: Boolean = false): Token + @Serializable data object SellReceipt: Token } + @Serializable @Parcelize sealed interface OnRamp: AppRoute { + @Serializable data class ProviderList( val from: AppRoute? = null, val neededAmount: Fiat? = null, ) : OnRamp - data object AmountEntry: OnRamp + @Serializable data object AmountEntry: OnRamp } + @Serializable @Parcelize sealed interface Transfers: AppRoute { sealed interface Withdrawal { - data class Amount(val mint: Mint) : Transfers - data object Destination : Transfers - data object Confirmation : Transfers + @Serializable data class Amount(val mint: Mint) : Transfers + @Serializable data object Destination : Transfers + @Serializable data object Confirmation : Transfers } } + @Serializable @Parcelize sealed interface Menu: AppRoute { - data object MyAccount : Menu - data class Deposit(val mint: Mint) : Menu - data object BackupKey : Menu - data object AppSettings : Menu - data object AdvancedFeatures : Menu - data object Lab : Menu + @Serializable data object MyAccount : Menu + @Serializable data class Deposit(val mint: Mint) : Menu + @Serializable data object BackupKey : Menu + @Serializable data object AppSettings : Menu + @Serializable data object AdvancedFeatures : Menu + @Serializable data object Lab : Menu } + @Serializable @Parcelize sealed interface Advanced: AppRoute -} \ No newline at end of file +} 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 new file mode 100644 index 000000000..c88d0d249 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.core.extensions + +import androidx.navigation3.runtime.NavKey +import com.flipcash.app.core.AppRoute +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. + */ +fun CodeNavigator.navigateTo(route: NavKey, options: NavOptions = NavOptions()) { + val destination = if (route is AppRoute.Sheets) { + AppRoute.Main.Sheet(route) + } else { + route + } + navigate(destination, options) +} + +/** + * Navigate to multiple routes, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet]. + * The first route uses the provided [options], subsequent routes use default options. + */ +fun CodeNavigator.navigateTo(routes: List, options: NavOptions = NavOptions()) { + if (routes.isEmpty()) return + routes.forEachIndexed { index, route -> + navigateTo(route, if (index == 0) options else NavOptions()) + } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/money/RegionSelectionKind.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/money/RegionSelectionKind.kt index 73197e39b..0073ae9db 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/money/RegionSelectionKind.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/money/RegionSelectionKind.kt @@ -1,5 +1,8 @@ package com.flipcash.app.core.money +import kotlinx.serialization.Serializable + +@Serializable enum class RegionSelectionKind { Entry, Balance; diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index fcd977453..364c8fa93 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -10,31 +10,36 @@ import com.getcode.ed25519.Ed25519 import com.getcode.solana.keys.Mint import com.getcode.vendor.Base58 import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable @Parcelize sealed interface DeeplinkType: Parcelable { sealed interface Navigatable - data class Login(val entropy: String) : DeeplinkType - data class CashLink(val entropy: String = "") : DeeplinkType + @Serializable data class Login(val entropy: String) : DeeplinkType + @Serializable data class CashLink(val entropy: String = "") : DeeplinkType - data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable + @Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable sealed interface ExternalWalletStep { val origin: OnRampDeeplinkOrigin } + @Serializable data class ExternalWalletConnection( override val origin: OnRampDeeplinkOrigin, val result: WalletDeeplinkConnectionResult?, val error: ExternalWalletDeeplinkError? = null ): DeeplinkType, ExternalWalletStep, Navigatable + @Serializable data class ExternalWalletSignedTransaction( override val origin: OnRampDeeplinkOrigin, val result: WalletDeeplinkSigningResult?, val error: ExternalWalletDeeplinkError? = null ): DeeplinkType, ExternalWalletStep, Navigatable + @Serializable data class EmailVerification( val email: String, val code: String, diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt index a18729490..7d20c840a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onramp/deeplinks/WalletDeeplinkConnectionResult.kt @@ -12,6 +12,7 @@ import com.getcode.solana.keys.base58 import com.getcode.utils.base58 import com.getcode.utils.decodeBase58 import com.getcode.utils.decodeBase64 +import com.getcode.utils.serializer.ByteListAsBase64Serializer import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -31,21 +32,22 @@ data class ExternallySignedTransaction( val serializedTransaction: String, ): Parcelable +@Serializable @Parcelize sealed class OnRampDeeplinkOrigin: Parcelable { - @Parcelize + @Serializable @Parcelize data object Menu : OnRampDeeplinkOrigin() - @Parcelize + @Serializable @Parcelize data class Give(val tokenAddress: Mint?) : OnRampDeeplinkOrigin() - @Parcelize + @Serializable @Parcelize data object Wallet: OnRampDeeplinkOrigin() - @Parcelize + @Serializable @Parcelize data class TokenInfo(val mint: Mint): OnRampDeeplinkOrigin() - @Parcelize + @Serializable @Parcelize data object Reserves: OnRampDeeplinkOrigin() @@ -63,7 +65,7 @@ sealed class OnRampDeeplinkOrigin: Parcelable { fun fromRoute(route: AppRoute?): OnRampDeeplinkOrigin? { return when (route) { is AppRoute.Sheets.Menu -> Menu - is AppRoute.Main.Give -> Give(route.mint) + is AppRoute.Sheets.Give -> Give(route.mint) is AppRoute.Sheets.Wallet -> Wallet is AppRoute.Token.Info -> { if (route.mint == Mint.usdf) Reserves else TokenInfo(route.mint) @@ -100,17 +102,19 @@ sealed class OnRampDeeplinkOrigin: Parcelable { } } +@Serializable @Parcelize data class WalletDeeplinkConnectionResult( - val encryptionPublicKey: List, - val nonce: List, - val encryptedData: List + @Serializable(with = ByteListAsBase64Serializer::class) val encryptionPublicKey: List, + @Serializable(with = ByteListAsBase64Serializer::class) val nonce: List, + @Serializable(with = ByteListAsBase64Serializer::class) val encryptedData: List ): Parcelable +@Serializable @Parcelize data class WalletDeeplinkSigningResult( - val nonce: List, - val encryptedData: List, + @Serializable(with = ByteListAsBase64Serializer::class) val nonce: List, + @Serializable(with = ByteListAsBase64Serializer::class) val encryptedData: List, ): Parcelable @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt index 9aa8f494f..28852acd1 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt @@ -2,11 +2,13 @@ package com.flipcash.app.core.tokens import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable @Parcelize sealed interface TokenPurpose: Parcelable { - data object Select : TokenPurpose - data object Withdraw: TokenPurpose - data object Deposit: TokenPurpose - data object Balance : TokenPurpose + @Serializable data object Select : TokenPurpose + @Serializable data object Withdraw: TokenPurpose + @Serializable data object Deposit: TokenPurpose + @Serializable data object Balance : TokenPurpose } \ No newline at end of file diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt index 3f3d9a803..01d800acd 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt @@ -3,6 +3,7 @@ package com.flipcash.app.core.tokens import android.os.Parcelable import com.getcode.solana.keys.Mint import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable /** * Represents the intent or specific action behind a token swap operation. @@ -13,12 +14,13 @@ import kotlinx.parcelize.Parcelize * @see Buy Represents a purchase intent (e.g., swapping base currency for a specific token). * @see Sell Represents a liquidation intent (e.g., swapping a specific token back to base currency). */ +@Serializable @Parcelize sealed interface TokenSwapPurpose : Parcelable { sealed interface BalanceIncrease sealed interface BalanceDecrease - data class Buy(val mint: Mint) : TokenSwapPurpose, BalanceIncrease - data class FundWithWallet(val mint: Mint): TokenSwapPurpose, BalanceIncrease - data class Sell(val mint: Mint) : TokenSwapPurpose, BalanceDecrease + @Serializable data class Buy(val mint: Mint) : TokenSwapPurpose, BalanceIncrease + @Serializable data class FundWithWallet(val mint: Mint): TokenSwapPurpose, BalanceIncrease + @Serializable data class Sell(val mint: Mint) : TokenSwapPurpose, BalanceDecrease // data class Swap(val from: Mint, val to: Mint) : TokenSwapPurpose } \ No newline at end of file diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt index dcd6e226f..ad3b390a0 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.advanced -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -8,72 +7,54 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.advanced.internal.AdvancedFeaturesScreen import com.flipcash.app.advanced.internal.AdvancedFeaturesScreenViewModel import com.flipcash.app.bill.customization.LocalBillPlaygroundController import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class AdvancedFeaturesScreen: ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "advanced_features_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - val billPlayground = LocalBillPlaygroundController.current - val viewModel = getViewModel() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_advancedFeatures), - titleAlignment = Alignment.CenterHorizontally, - isInModal = true, - backButton = true, - onBackIconClicked = { navigator.pop() } - ) - - AdvancedFeaturesScreen(viewModel) - } +@Composable +fun AdvancedFeaturesScreen() { + val navigator = LocalCodeNavigator.current + val billPlayground = LocalBillPlaygroundController.current + val viewModel = hiltViewModel() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_advancedFeatures), + titleAlignment = Alignment.CenterHorizontally, + isInModal = true, + backButton = true, + onBackIconClicked = { navigator.pop() } + ) + + AdvancedFeaturesScreen(viewModel) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { ScreenRegistry.get(it.screen) } - .onEach { navigator.push(it) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(it.screen) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - billPlayground.customizeFor(Token.usdf) - } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + billPlayground.customizeFor(Token.usdf) + } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt b/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt index 9c235b656..0d7eb09a0 100644 --- a/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt +++ b/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt @@ -1,48 +1,32 @@ package com.flipcash.app.appsettings -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.appsettings.internal.AppSettingsScreenContent import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class AppSettingsScreen : ModalScreen, Parcelable { +@Composable +fun AppSettingsScreen() { + val navigator = LocalCodeNavigator.current - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_appSettings), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + isInModal = true, + onBackIconClicked = navigator::pop + ) - @IgnoredOnParcel - override val testTag: String = "app_settings_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_appSettings), - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - isInModal = true, - onBackIconClicked = navigator::pop - ) - - AppSettingsScreenContent() - } + AppSettingsScreenContent() } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/appupdates/src/main/kotlin/com/flipcash/app/updates/UpdateRequiredView.kt b/apps/flipcash/features/appupdates/src/main/kotlin/com/flipcash/app/updates/UpdateRequiredView.kt index 73611e46b..b2e9de6c1 100644 --- a/apps/flipcash/features/appupdates/src/main/kotlin/com/flipcash/app/updates/UpdateRequiredView.kt +++ b/apps/flipcash/features/appupdates/src/main/kotlin/com/flipcash/app/updates/UpdateRequiredView.kt @@ -33,7 +33,7 @@ import com.getcode.ui.biometrics.BiometricsState import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold -import com.getcode.ui.utils.RepeatOnLifecycle +import com.getcode.navigation.utils.lifecycle.RepeatOnLifecycle import com.google.android.play.core.install.model.InstallStatus import com.google.android.play.core.install.model.UpdateAvailability import kotlinx.coroutines.flow.MutableStateFlow diff --git a/apps/flipcash/features/backupkey/src/main/kotlin/com/flipcash/app/backupkey/BackupKeyScreen.kt b/apps/flipcash/features/backupkey/src/main/kotlin/com/flipcash/app/backupkey/BackupKeyScreen.kt index ac3b8a829..70266fed3 100644 --- a/apps/flipcash/features/backupkey/src/main/kotlin/com/flipcash/app/backupkey/BackupKeyScreen.kt +++ b/apps/flipcash/features/backupkey/src/main/kotlin/com/flipcash/app/backupkey/BackupKeyScreen.kt @@ -1,51 +1,34 @@ package com.flipcash.app.backupkey -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.backupkey.internal.BackupKeyScreenContent import com.flipcash.app.backupkey.internal.BackupKeyScreenViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class BackupKeyScreen: ModalScreen, Parcelable { +@Composable +fun BackupKeyScreen() { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "access_key_screen" - - - @Composable - override fun ModalContent() { - val viewModel = getViewModel() - val navigator = LocalCodeNavigator.current - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_accessKey), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) - BackupKeyScreenContent(viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_accessKey), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + BackupKeyScreenContent(viewModel) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt index 24b2644ad..e33b3e895 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.balance -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -8,10 +7,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.balance.internal.BalanceScreen import com.flipcash.app.balance.internal.BalanceViewModel import com.flipcash.app.core.AppRoute @@ -21,75 +17,59 @@ import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.extensions.getActivityScopedViewModel -import com.getcode.navigation.screens.ModalScreen import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class BalanceScreen: ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: - ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "wallet_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_wallet), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - endContent = { - AppBarDefaults.Close { navigator.hide() } - } - ) +@Composable +fun BalanceScreen() { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_wallet), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + endContent = { + AppBarDefaults.Close { navigator.hide() } + } + ) - val viewModel = getActivityScopedViewModel() - val tokenViewModel = getViewModel() - BalanceScreen(viewModel, tokenViewModel) + val viewModel = getActivityScopedViewModel() + val tokenViewModel = hiltViewModel() + BalanceScreen(viewModel, tokenViewModel) - LaunchedEffect(tokenViewModel) { - tokenViewModel.dispatchEvent( - SelectTokenViewModel.Event.OnPurposeChanged( - TokenPurpose.Balance - ) + LaunchedEffect(tokenViewModel) { + tokenViewModel.dispatchEvent( + SelectTokenViewModel.Event.OnPurposeChanged( + TokenPurpose.Balance ) - } + ) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - ScreenRegistry.get( - AppRoute.Main.RegionSelection( - RegionSelectionKind.Balance - ) - ) + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.Main.RegionSelection( + RegionSelectionKind.Balance ) - }.launchIn(this) - } + ) + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { ScreenRegistry.get(it.screen) } - .onEach { navigator.push(it) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.screen } + .onEach { navigator.push(it) } + .launchIn(this) } } } @@ -97,4 +77,4 @@ class BalanceScreen: ModalScreen, Parcelable { @Composable fun PreloadBalance() { val viewModel = getActivityScopedViewModel() -} \ No newline at end of file +} diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt index 827572c92..0b6194ab3 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.cash -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -9,11 +8,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel -import cafe.adriel.voyager.navigator.currentOrThrow +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.cash.internal.CashScreenViewModel import com.flipcash.app.cash.internal.GiveScreenContent import com.flipcash.app.core.AppRoute @@ -21,7 +16,6 @@ import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.ui.TokenSelectionPill import com.flipcash.app.session.LocalSessionController import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.solana.keys.Mint import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -29,84 +23,66 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class CashScreen( - private val selectedMint: Mint?, - private val fromTokenInfo: Boolean, -) : ModalScreen, Parcelable { +@Composable +fun CashScreen( + selectedMint: Mint?, + fromTokenInfo: Boolean, +) { + val navigator = LocalCodeNavigator.current + val session = LocalSessionController.current!! - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() - companion object { - const val TEST_TAG = "cash_screen" + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + session.showBill(it.bill) + navigator.hide() + } + .launchIn(this) } - @IgnoredOnParcel - override val testTag = TEST_TAG - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - val session = LocalSessionController.currentOrThrow - - val viewModel = getViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - session.showBill(it.bill) - navigator.hide() + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + isInModal = true, + title = { + TokenSelectionPill(state.token?.token) { + navigator.push( + AppRoute.Sheets.TokenSelection(TokenPurpose.Select) + ) } - .launchIn(this) - } - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - isInModal = true, - title = { - TokenSelectionPill(state.token?.token) { - navigator.push( - ScreenRegistry.get( - AppRoute.Sheets.TokenSelection(TokenPurpose.Select) - ) - ) - } - }, - leftIcon = { - if (fromTokenInfo) { - AppBarDefaults.UpNavigation { navigator.pop() } - } - }, - rightContents = { - if (!fromTokenInfo) { - AppBarDefaults.Close { navigator.hide() } - } + }, + leftIcon = { + if (fromTokenInfo) { + AppBarDefaults.UpNavigation { navigator.pop() } + } + }, + rightContents = { + if (!fromTokenInfo) { + AppBarDefaults.Close { navigator.hide() } } - ) - GiveScreenContent(viewModel) - } - - LaunchedEffect(viewModel, selectedMint) { - selectedMint?.let { - viewModel.dispatchEvent(CashScreenViewModel.Event.OnTokenSelected(it)) } - } + ) + GiveScreenContent(viewModel) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { ScreenRegistry.get(it.screen) } - .onEach { navigator.push(it) } - .launchIn(this) + LaunchedEffect(viewModel, selectedMint) { + selectedMint?.let { + viewModel.dispatchEvent(CashScreenViewModel.Event.OnTokenSelected(it)) } } -} \ No newline at end of file + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.screen } + .onEach { navigator.push(it) } + .launchIn(this) + } +} diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt index 3f126b90f..ae73f950d 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenContent.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.ui.AmountWithKeypad @@ -48,10 +47,8 @@ internal fun GiveScreenContent(viewModel: CashScreenViewModel) { isClickable = true, onAmountClicked = { navigator.push( - ScreenRegistry.get( - AppRoute.Main.RegionSelection( - kind = RegionSelectionKind.Entry - ) + AppRoute.Main.RegionSelection( + kind = RegionSelectionKind.Entry ) ) }, @@ -78,4 +75,4 @@ internal fun GiveScreenContent(viewModel: CashScreenViewModel) { } } } -} \ No newline at end of file +} 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 ec8ee39af..d5913501b 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 @@ -1,23 +1,22 @@ package com.flipcash.app.contact.verification import android.content.Context -import android.os.Parcelable +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi -import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.transitions.SlideTransition -import com.flipcash.app.contact.verification.email.EmailMagicLinkScreen -import com.flipcash.app.contact.verification.email.EmailVerificationScreen -import com.flipcash.app.contact.verification.internal.VerificationFlowIntroScreen -import com.flipcash.app.contact.verification.phone.PhoneVerificationScreen +import com.flipcash.app.contact.verification.email.EmailMagicLinkContent +import com.flipcash.app.contact.verification.email.EmailVerificationContent +import com.flipcash.app.contact.verification.internal.VerificationFlowIntroContent +import com.flipcash.app.contact.verification.phone.PhoneCodeContent +import com.flipcash.app.contact.verification.phone.PhoneCountryCodeContent +import com.flipcash.app.contact.verification.phone.PhoneVerificationContent import com.flipcash.app.core.AppRoute import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin import com.flipcash.app.navigation.FlowNavigator @@ -27,148 +26,116 @@ import com.flipcash.features.contact.verification.R import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Parcelize -class VerificationFlowScreen( - private val origin: AppRoute, - private val target: AppRoute? = null, - private val includePhone: Boolean = true, - private val includeEmail: Boolean = true, - private val showSuccess: Boolean = target == null && (includePhone xor includeEmail), - private val emailAddress: String? = null, - private val emailVerificationCode: String? = null, -) : ModalScreen, Parcelable { - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "verification_screen" - - @OptIn(ExperimentalVoyagerApi::class) - @Composable - override fun ModalContent() { - val codeNavigator = LocalCodeNavigator.current - val context = LocalContext.current - fun showSuccess() { - if (includePhone) { - BottomBarManager.showMessage( - title = context.getString(R.string.prompt_title_phoneVerifiedSuccessfully), - subtitle = context.getString(R.string.prompt_description_phoneVerifiedSuccessfully), - actions = listOf( - BottomBarAction(text = context.getString(android.R.string.ok)) - ), - type = BottomBarManager.BottomBarMessageType.SUCCESS, - ) { - codeNavigator.pop() - } - } else { - BottomBarManager.showMessage( - title = context.getString(R.string.prompt_title_emailVerifiedSuccessfully), - subtitle = context.getString(R.string.prompt_description_emailVerifiedSuccessfully), - actions = listOf( - BottomBarAction(text = context.getString(android.R.string.ok)) - ), - type = BottomBarManager.BottomBarMessageType.SUCCESS, - ) { - codeNavigator.pop() - } - } - } - fun goToTargetOrReturn(wasSuccessful: Boolean) { - if (target != null) { - codeNavigator.replace(ScreenRegistry.get(target)) - } else { - if (wasSuccessful && showSuccess) { - showSuccess() - } else { - codeNavigator.pop() - } - } - } +sealed interface VerificationInternalScreen { + data class Intro(val isForOnRamp: Boolean) : VerificationInternalScreen + data object PhoneEntry : VerificationInternalScreen + data object PhoneCode : VerificationInternalScreen + data object PhoneCountryCode : VerificationInternalScreen + data object EmailEntry : VerificationInternalScreen + data class EmailMagicLink(val email: String? = null, val code: String? = null) : VerificationInternalScreen +} - LifecycleEffectOnce { - PhoneVerificationFlow.start(origin) - EmailVerificationFlow.start(EmailDeeplinkOrigin.fromRoute(origin)) - } +class InternalFlowNavigator { + val screens = mutableStateListOf() + private var direction = 1 // 1 = forward, -1 = backward - val screens = - buildScreenSet( - origin = origin, - includePhone = includePhone, - includeEmail = includeEmail, - emailAddress = emailAddress, - emailVerificationCode = emailVerificationCode - ) - if (screens.isEmpty()) { - goToTargetOrReturn(false) - return - } + fun push(screen: VerificationInternalScreen) { + direction = 1 + screens.add(screen) + } - Navigator(screens.toList()) { navigator -> - val flowNavigator = rememberFlowNavigator( - target = target, - includePhone = includePhone, - includeEmail = includeEmail, - showSuccess = showSuccess, - navigator = navigator, - context = context, - goToTargetOrReturn = { success -> goToTargetOrReturn(success) } - ) - - CompositionLocalProvider(LocalFlowNavigator provides flowNavigator) { - SlideTransition(navigator = navigator) - } + fun pop(): Boolean { + if (screens.size > 1) { + direction = -1 + screens.removeAt(screens.lastIndex) + return true } + return false } + + val current: VerificationInternalScreen? get() = screens.lastOrNull() + val slideDirection: Int get() = direction } -private fun buildScreenSet( +@Composable +fun VerificationFlowScreen( origin: AppRoute, - includePhone: Boolean, - includeEmail: Boolean, - emailAddress: String?, - emailVerificationCode: String?, -): Set { - if (includePhone && includeEmail) { - return setOf(VerificationFlowIntroScreen(origin is AppRoute.OnRamp.ProviderList)) - } - if (includePhone) { - return setOf(PhoneVerificationScreen()) + target: AppRoute? = null, + includePhone: Boolean = true, + includeEmail: Boolean = true, + showSuccess: Boolean = target == null && (includePhone xor includeEmail), + emailAddress: String? = null, + emailVerificationCode: String? = null, +) { + val codeNavigator = LocalCodeNavigator.current + val context = LocalContext.current + + fun showSuccess() { + if (includePhone) { + BottomBarManager.showMessage( + title = context.getString(R.string.prompt_title_phoneVerifiedSuccessfully), + subtitle = context.getString(R.string.prompt_description_phoneVerifiedSuccessfully), + actions = listOf( + BottomBarAction(text = context.getString(android.R.string.ok)) + ), + type = BottomBarManager.BottomBarMessageType.SUCCESS, + ) { + codeNavigator.pop() + } + } else { + BottomBarManager.showMessage( + title = context.getString(R.string.prompt_title_emailVerifiedSuccessfully), + subtitle = context.getString(R.string.prompt_description_emailVerifiedSuccessfully), + actions = listOf( + BottomBarAction(text = context.getString(android.R.string.ok)) + ), + type = BottomBarManager.BottomBarMessageType.SUCCESS, + ) { + codeNavigator.pop() + } + } } - if (includeEmail) { - return buildSet { - add(EmailVerificationScreen()) - if (emailAddress != null && emailVerificationCode != null) { - add(EmailMagicLinkScreen(emailAddress, emailVerificationCode)) + fun goToTargetOrReturn(wasSuccessful: Boolean) { + if (target != null) { + codeNavigator.replace(target) + } else { + if (wasSuccessful && showSuccess) { + showSuccess() + } else { + codeNavigator.pop() } } } - return emptySet() -} + LaunchedEffect(Unit) { + PhoneVerificationFlow.start(origin) + EmailVerificationFlow.start(EmailDeeplinkOrigin.fromRoute(origin)) + } -enum class VerificationFlowStep: NavigationFlowStep { - Intro, - Phone, - Email; -} + val startingScreens = buildStartingScreens( + includePhone = includePhone, + includeEmail = includeEmail, + emailAddress = emailAddress, + emailVerificationCode = emailVerificationCode, + origin = origin, + ) -@Composable -fun rememberFlowNavigator( - target: AppRoute?, - includePhone: Boolean, - includeEmail: Boolean, - showSuccess: Boolean, - navigator: Navigator, - context: Context = LocalContext.current, - goToTargetOrReturn: (Boolean) -> Unit = {}, -): FlowNavigator { - return remember(navigator, target, includePhone, includeEmail, showSuccess, context) { + if (startingScreens.isEmpty()) { + goToTargetOrReturn(false) + return + } + + val internalNav = remember { InternalFlowNavigator() } + + LaunchedEffect(Unit) { + if (internalNav.screens.isEmpty()) { + internalNav.screens.addAll(startingScreens) + } + } + + val flowNavigator = remember(target, includePhone, includeEmail, showSuccess, context, internalNav) { object : FlowNavigator { override fun exit(success: Boolean) { goToTargetOrReturn(success) @@ -177,11 +144,11 @@ fun rememberFlowNavigator( override fun continueFlowFrom(step: VerificationFlowStep) { when (step) { VerificationFlowStep.Intro -> { - navigator.push(PhoneVerificationScreen()) + internalNav.push(VerificationInternalScreen.PhoneEntry) } VerificationFlowStep.Phone -> { if (includeEmail) { - navigator.push(EmailVerificationScreen()) + internalNav.push(VerificationInternalScreen.EmailEntry) } else { goToTargetOrReturn(true) } @@ -193,4 +160,69 @@ fun rememberFlowNavigator( } } } -} \ No newline at end of file + + CompositionLocalProvider(LocalFlowNavigator provides flowNavigator) { + val currentScreen = internalNav.current ?: return@CompositionLocalProvider + + AnimatedContent( + targetState = currentScreen, + transitionSpec = { + val dir = internalNav.slideDirection + slideInHorizontally { fullWidth -> dir * fullWidth } togetherWith + slideOutHorizontally { fullWidth -> -dir * fullWidth } + }, + label = "verification_flow" + ) { screen -> + when (screen) { + is VerificationInternalScreen.Intro -> VerificationFlowIntroContent( + isForOnRamp = screen.isForOnRamp, + ) + is VerificationInternalScreen.PhoneEntry -> PhoneVerificationContent( + onPushCountryCode = { internalNav.push(VerificationInternalScreen.PhoneCountryCode) }, + onPushPhoneCode = { internalNav.push(VerificationInternalScreen.PhoneCode) }, + ) + is VerificationInternalScreen.PhoneCode -> PhoneCodeContent() + is VerificationInternalScreen.PhoneCountryCode -> PhoneCountryCodeContent( + onPop = { internalNav.pop() } + ) + is VerificationInternalScreen.EmailEntry -> EmailVerificationContent( + onPushMagicLink = { internalNav.push(VerificationInternalScreen.EmailMagicLink()) }, + ) + is VerificationInternalScreen.EmailMagicLink -> EmailMagicLinkContent( + email = screen.email, + code = screen.code, + ) + } + } + } +} + +private fun buildStartingScreens( + origin: AppRoute, + includePhone: Boolean, + includeEmail: Boolean, + emailAddress: String?, + emailVerificationCode: String?, +): List { + if (includePhone && includeEmail) { + return listOf(VerificationInternalScreen.Intro(origin is AppRoute.OnRamp.ProviderList)) + } + if (includePhone) { + return listOf(VerificationInternalScreen.PhoneEntry) + } + if (includeEmail) { + return buildList { + add(VerificationInternalScreen.EmailEntry) + if (emailAddress != null && emailVerificationCode != null) { + add(VerificationInternalScreen.EmailMagicLink(emailAddress, emailVerificationCode)) + } + } + } + return emptyList() +} + +enum class VerificationFlowStep: NavigationFlowStep { + Intro, + Phone, + Email; +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt index 4182b672f..c1335c582 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.contact.verification.email -import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -10,13 +9,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi -import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.contact.verification.EmailVerificationFlow import com.flipcash.app.contact.verification.VerificationFlowStep import com.flipcash.app.contact.verification.internal.email.EmailMagicLinkScreen import com.flipcash.app.contact.verification.internal.email.EmailVerificationViewModel @@ -24,89 +18,74 @@ import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.navigation.FlowNavigator import com.flipcash.app.navigation.LocalFlowNavigator import com.flipcash.features.contact.verification.R -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.AppScreen +import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class EmailMagicLinkScreen( - private val email: String? = null, - private val code: String? = null -) : AppScreen, Parcelable { +@Composable +fun EmailMagicLinkContent( + email: String? = null, + code: String? = null, +) { + val flowNavigator = LocalFlowNavigator.current as FlowNavigator + val viewModel = hiltViewModel() - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "email_magic_link_screen" - - @OptIn(ExperimentalVoyagerApi::class) - @Composable - override fun ScreenContent() { - val flowNavigator = LocalFlowNavigator.current as FlowNavigator - val viewModel = - getStackScopedViewModel(EmailVerificationFlow.key) - - BackHandler { - flowNavigator.exit(false) - } + BackHandler { + flowNavigator.exit(false) + } - val analytics = rememberAnalytics() - LifecycleEffectOnce { - analytics.onrampVerification(Analytics.OnrampVerificationStep.ConfirmEmail) - } + val analytics = rememberAnalytics() + LaunchedEffect(Unit) { + analytics.onrampVerification(Analytics.OnrampVerificationStep.ConfirmEmail) + } - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_verifyEmailAddress), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { flowNavigator.exit(false) }, - ) - EmailMagicLinkScreen(viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_verifyEmailAddress), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { flowNavigator.exit(false) }, + ) + EmailMagicLinkScreen(viewModel) + } - LaunchedEffect(email, code) { - viewModel.dispatchEvent(EmailVerificationViewModel.Event.OnDataProvided(email, code)) - } + LaunchedEffect(email, code) { + viewModel.dispatchEvent(EmailVerificationViewModel.Event.OnDataProvided(email, code)) + } - val context = LocalContext.current - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - context.startActivity(IntentUtils.emailApp()) - }.launchIn(this) - } + val context = LocalContext.current + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + context.startActivity(IntentUtils.emailApp()) + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { flowNavigator.exit(false) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.exit(false) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { flowNavigator.exit(false) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.exit(false) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { flowNavigator.continueFlowFrom(VerificationFlowStep.Email) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.continueFlowFrom(VerificationFlowStep.Email) } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt index 4be23e327..79a1df77f 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.contact.verification.email -import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -9,82 +8,63 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi -import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.contact.verification.EmailVerificationFlow import com.flipcash.app.contact.verification.internal.email.EmailEntryScreen import com.flipcash.app.contact.verification.internal.email.EmailVerificationViewModel import com.flipcash.features.contact.verification.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.AppScreen +import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class EmailVerificationScreen : AppScreen, Parcelable { +@Composable +fun EmailVerificationContent( + onPushMagicLink: () -> Unit = {}, +) { + val codeNavigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val keyboard = rememberKeyboardController() - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "email_verification_screen" - - @OptIn(ExperimentalVoyagerApi::class) - @Composable - override fun ScreenContent() { - val codeNavigator = LocalCodeNavigator.current - val navigator = LocalNavigator.currentOrThrow - val viewModel = getStackScopedViewModel(EmailVerificationFlow.key) - val keyboard = rememberKeyboardController() - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_verifyEmailAddress), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { - keyboard.hideIfVisible { - codeNavigator.pop() - } - }, - ) - EmailEntryScreen(viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_verifyEmailAddress), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { + keyboard.hideIfVisible { + codeNavigator.pop() + } + }, + ) + EmailEntryScreen(viewModel) + } - val analytics = rememberAnalytics() - LifecycleEffectOnce { - analytics.onrampVerification(Analytics.OnrampVerificationStep.EnterEmail) - } + val analytics = rememberAnalytics() + LaunchedEffect(Unit) { + analytics.onrampVerification(Analytics.OnrampVerificationStep.EnterEmail) + } - BackHandler { - keyboard.hideIfVisible { - codeNavigator.pop() - } + BackHandler { + keyboard.hideIfVisible { + codeNavigator.pop() } + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - keyboard.hideIfVisible { - navigator.push(EmailMagicLinkScreen()) - } - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + keyboard.hideIfVisible { + onPushMagicLink() + } + }.launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt index 14a7f037f..6bee78245 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationFlowIntroScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.contact.verification.internal -import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -16,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier @@ -23,10 +23,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi -import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.contact.verification.VerificationFlowStep @@ -35,47 +31,33 @@ import com.flipcash.app.navigation.LocalFlowNavigator import com.flipcash.app.theme.FlipcashPreview import com.flipcash.features.contact.verification.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.AppScreen import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.utils.rememberKeyboardController -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class VerificationFlowIntroScreen( - private val isForOnRamp: Boolean = true, -) : AppScreen, Parcelable { - - @IgnoredOnParcel - override val testTag: String = "verification_intro_screen" - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @OptIn(ExperimentalVoyagerApi::class) - @Composable - override fun ScreenContent() { - val codeNavigator = LocalCodeNavigator.current - val flowNavigator = LocalFlowNavigator.current as FlowNavigator - val keyboard = rememberKeyboardController() +@Composable +fun VerificationFlowIntroContent( + isForOnRamp: Boolean = true, +) { + val codeNavigator = LocalCodeNavigator.current + val flowNavigator = LocalFlowNavigator.current as FlowNavigator + val keyboard = rememberKeyboardController() - VerificationFlowIntroScreenContent( - isForOnRamp = isForOnRamp, - onClick = { flowNavigator.continueFlowFrom(VerificationFlowStep.Intro) }, - ) + VerificationFlowIntroScreenContent( + isForOnRamp = isForOnRamp, + onClick = { flowNavigator.continueFlowFrom(VerificationFlowStep.Intro) }, + ) - val analytics = rememberAnalytics() - LifecycleEffectOnce { - analytics.onrampVerification(Analytics.OnrampVerificationStep.ShowInfo) - } + val analytics = rememberAnalytics() + LaunchedEffect(Unit) { + analytics.onrampVerification(Analytics.OnrampVerificationStep.ShowInfo) + } - BackHandler { - keyboard.hideIfVisible { - codeNavigator.pop() - } + BackHandler { + keyboard.hideIfVisible { + codeNavigator.pop() } } } @@ -154,4 +136,4 @@ private fun Preview_FlowIntro() { ) } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt index 59ac62700..6df450aa4 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.contact.verification.phone -import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -9,75 +8,56 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi -import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.contact.verification.PhoneVerificationFlow import com.flipcash.app.contact.verification.VerificationFlowStep import com.flipcash.app.contact.verification.internal.phone.PhoneCodeScreen import com.flipcash.app.contact.verification.internal.phone.PhoneVerificationViewModel import com.flipcash.app.navigation.FlowNavigator import com.flipcash.app.navigation.LocalFlowNavigator import com.flipcash.features.contact.verification.R -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.AppScreen +import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class PhoneCodeScreen: AppScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "phone_code_screen" - - @OptIn(ExperimentalVoyagerApi::class) - @Composable - override fun ScreenContent() { - val flowNavigator = LocalFlowNavigator.current as FlowNavigator - val viewModel = getStackScopedViewModel(key = PhoneVerificationFlow.key) - - BackHandler { flowNavigator.exit(false) } - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_enterTheCode), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { flowNavigator.exit(false) }, - ) - PhoneCodeScreen(viewModel) - } +@Composable +fun PhoneCodeContent() { + val flowNavigator = LocalFlowNavigator.current as FlowNavigator + val viewModel = hiltViewModel() + + BackHandler { flowNavigator.exit(false) } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_enterTheCode), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { flowNavigator.exit(false) }, + ) + PhoneCodeScreen(viewModel) + } - val analytics = rememberAnalytics() - LifecycleEffectOnce { - analytics.onrampVerification(Analytics.OnrampVerificationStep.ConfirmPhone) - } + val analytics = rememberAnalytics() + LaunchedEffect(Unit) { + analytics.onrampVerification(Analytics.OnrampVerificationStep.ConfirmPhone) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { flowNavigator.exit(false) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.exit(false) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { flowNavigator.continueFlowFrom(VerificationFlowStep.Phone) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.continueFlowFrom(VerificationFlowStep.Phone) } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt index 66d8000d9..7b0526c76 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.contact.verification.phone -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -8,57 +7,39 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow -import com.flipcash.app.contact.verification.PhoneVerificationFlow import com.flipcash.app.contact.verification.internal.phone.PhoneCountryCodeScreen import com.flipcash.app.contact.verification.internal.phone.PhoneVerificationViewModel import com.flipcash.features.contact.verification.R -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.AppScreen +import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class PhoneCountryCodeScreen: AppScreen, Parcelable { +@Composable +fun PhoneCountryCodeContent( + onPop: () -> Unit = {}, +) { + val viewModel = hiltViewModel() - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "phone_country_code_screen" - - @Composable - override fun ScreenContent() { - val navigator = LocalNavigator.currentOrThrow - val viewModel = getStackScopedViewModel(PhoneVerificationFlow.key) - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_verifyPhoneNumber), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) - PhoneCountryCodeScreen(viewModel = viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_verifyPhoneNumber), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { onPop() }, + ) + PhoneCountryCodeScreen(viewModel = viewModel) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.pop() } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { onPop() } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt index e5332296b..e0ad858fa 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.contact.verification.phone -import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -9,93 +8,74 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi -import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.contact.verification.PhoneVerificationFlow import com.flipcash.app.contact.verification.internal.phone.PhoneEntryScreen import com.flipcash.app.contact.verification.internal.phone.PhoneVerificationViewModel import com.flipcash.features.contact.verification.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.AppScreen +import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class PhoneVerificationScreen : AppScreen, Parcelable { +@Composable +fun PhoneVerificationContent( + onPushCountryCode: () -> Unit = {}, + onPushPhoneCode: () -> Unit = {}, +) { + val codeNavigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val keyboard = rememberKeyboardController() - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "phone_verify_screen" - - @OptIn(ExperimentalVoyagerApi::class) - @Composable - override fun ScreenContent() { - val codeNavigator = LocalCodeNavigator.current - val navigator = LocalNavigator.currentOrThrow - val viewModel = getStackScopedViewModel(key = PhoneVerificationFlow.key) - val keyboard = rememberKeyboardController() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_verifyPhoneNumber), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { - keyboard.hideIfVisible { - codeNavigator.pop() - } - }, - ) - PhoneEntryScreen(viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_verifyPhoneNumber), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { + keyboard.hideIfVisible { + codeNavigator.pop() + } + }, + ) + PhoneEntryScreen(viewModel) + } - val analytics = rememberAnalytics() - LifecycleEffectOnce { - analytics.onrampVerification(Analytics.OnrampVerificationStep.EnterPhone) - } + val analytics = rememberAnalytics() + LaunchedEffect(Unit) { + analytics.onrampVerification(Analytics.OnrampVerificationStep.EnterPhone) + } - BackHandler { - keyboard.hideIfVisible { - codeNavigator.pop() - } + BackHandler { + keyboard.hideIfVisible { + codeNavigator.pop() } + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - keyboard.hideIfVisible { - navigator.push(PhoneCountryCodeScreen()) - } - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + keyboard.hideIfVisible { + onPushCountryCode() + } + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - keyboard.hideIfVisible { - navigator.push(PhoneCodeScreen()) - } - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + keyboard.hideIfVisible { + onPushPhoneCode() + } + }.launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt index 992ac8fe7..8005af735 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.deposit -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -8,51 +7,34 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.deposit.internal.DepositScreen import com.flipcash.app.deposit.internal.DepositViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.solana.keys.Mint import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class DepositScreen( - private val mint: Mint, -) : ModalScreen, Parcelable { - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey +@Composable +fun DepositScreen(mint: Mint) { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() - @IgnoredOnParcel - override val testTag: String = "deposit_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - - val viewModel = getViewModel() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_onrampProviderManualDeposit), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) - DepositScreen(viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_onrampProviderManualDeposit), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + DepositScreen(viewModel) + } - LaunchedEffect(viewModel, mint) { - viewModel.dispatchEvent(DepositViewModel.Event.OnMintSelected(mint)) - } + LaunchedEffect(viewModel, mint) { + viewModel.dispatchEvent(DepositViewModel.Event.OnMintSelected(mint)) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt index 514d35945..a4ab27090 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt @@ -1,92 +1,65 @@ package com.flipcash.app.lab -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.lab.internal.LabsScreenContent import com.flipcash.app.lab.internal.LabsScreenViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.extensions.getActivityScopedViewModel -import com.getcode.navigation.screens.ModalScreen import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Parcelize -class LabsScreen: ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "labs_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_betaFlags), - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - isInModal = true, - onBackIconClicked = navigator::pop - ) - - val viewModel = getActivityScopedViewModel() - - LabsScreenContent(viewModel) - } +@Composable +fun LabsScreen() { + val navigator = LocalCodeNavigator.current + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_betaFlags), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + isInModal = true, + onBackIconClicked = navigator::pop + ) + + val viewModel = getActivityScopedViewModel() + + LabsScreenContent(viewModel) } } -@Parcelize -class StandaloneLabsScreen: ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "labs_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_betaFlags), - titleAlignment = Alignment.CenterHorizontally, - isInModal = true, - endContent = { - AppBarDefaults.Close { navigator.hide() } - } - ) - - val viewModel = getActivityScopedViewModel() - - LabsScreenContent(viewModel) - } +@Composable +fun StandaloneLabsScreen() { + val navigator = LocalCodeNavigator.current + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_betaFlags), + titleAlignment = Alignment.CenterHorizontally, + isInModal = true, + endContent = { + AppBarDefaults.Close { navigator.hide() } + } + ) + + val viewModel = getActivityScopedViewModel() + + LabsScreenContent(viewModel) } } @Composable fun PreloadLabs() { val viewModel = getActivityScopedViewModel() -} \ No newline at end of file +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt index b51c6fee4..06c071b8b 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt @@ -1,56 +1,38 @@ package com.flipcash.app.login.accesskey -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute import com.flipcash.app.login.internal.AccessKeyScreen import com.flipcash.features.login.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.AppScreen import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class AccessKeyScreen : AppScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "access_key_screen" - - @Composable - override fun ScreenContent() { - val viewModel = getViewModel() - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_accessKey), - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) - AccessKeyScreen(viewModel) { requiresIap -> - if (requiresIap) { - navigator.push(ScreenRegistry.get(AppRoute.Onboarding.Purchase())) - } else { - navigator.replaceAll(ScreenRegistry.get(AppRoute.Main.Scanner())) - } +@Composable +fun AccessKeyScreen() { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_accessKey), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + AccessKeyScreen(viewModel) { requiresIap -> + if (requiresIap) { + navigator.push(AppRoute.Onboarding.Purchase()) + } else { + navigator.replaceAll(AppRoute.Main.Scanner()) } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/PhotoAccessKeyScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/PhotoAccessKeyScreen.kt index eb9de715a..58a37e0c0 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/PhotoAccessKeyScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/PhotoAccessKeyScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.login.accesskey -import android.os.Parcelable import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,41 +19,25 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.core.android.extensions.launchPhotos import com.flipcash.app.theme.FlipcashPreview import com.flipcash.features.login.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.AppScreen import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class PhotoAccessKeyScreen : AppScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "access_key_help_screen" +@Composable +fun PhotoAccessKeyScreen() { + val navigator = LocalCodeNavigator.current + val context = LocalContext.current - @Composable - override fun ScreenContent() { - val navigator = LocalCodeNavigator.current - val context = LocalContext.current - - AccessKeyInPhotos( - goBack = { navigator.pop() }, - openPhotos = { context.launchPhotos() }, - ) - } + AccessKeyInPhotos( + goBack = { navigator.pop() }, + openPhotos = { context.launchPhotos() }, + ) } @Composable @@ -128,4 +111,4 @@ private fun PreviewAccessKeyHelp() { openPhotos = {}, ) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/AccessKeyScreenContent.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/AccessKeyScreenContent.kt index ffaee159b..d3e232c94 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/AccessKeyScreenContent.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/AccessKeyScreenContent.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.accesskey.AccessKeyUiModel import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.extensions.launchAppSettings @@ -144,7 +143,7 @@ internal fun AccessKeyScreen(viewModel: LoginAccessKeyViewModel, onCompleted: (r onExport = onExportClick, onSkip = onSkipClick, onExit = { - navigator.replaceAll(ScreenRegistry.get(AppRoute.Onboarding.Login())) + navigator.replaceAll(AppRoute.Onboarding.Login()) } ) } @@ -326,4 +325,4 @@ private fun Preview_AccessKeyScreen() { onExit = { }, ) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputContent.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputContent.kt index ba1e380c8..34d50a211 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputContent.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputContent.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags @@ -73,7 +72,7 @@ internal fun SeedInputContent(viewModel: SeedInputViewModel) { onTextChange = { viewModel.onTextChange(it) }, onLogin = { viewModel.onSubmit(navigator) }, onRestore = { viewModel.restoreAccount(navigator) }, - onCantFind = { navigator.push(ScreenRegistry.get(AppRoute.Onboarding.AccessKeySavedLocation)) } + onCantFind = { navigator.push(AppRoute.Onboarding.AccessKeySavedLocation) } ) } @@ -233,4 +232,4 @@ private fun SeedInputContent( LaunchedEffect(Unit) { focusRequester.requestFocus() } -} \ No newline at end of file +} 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 7e275ddcc..3bb41a519 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 @@ -1,88 +1,70 @@ package com.flipcash.app.login.router -import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.extensions.navigateTo import com.flipcash.app.login.internal.LoginRouterScreenContent import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.AppScreen import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class LoginRouter( - private val seed: String? = null, - private val fromDeeplink: Boolean = false, -) : AppScreen, Parcelable { +@Composable +fun LoginRouter( + seed: String? = null, + fromDeeplink: Boolean = false, +) { + val vm = hiltViewModel() + val state by vm.stateFlow.collectAsState() + val navigator = LocalCodeNavigator.current - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "login_screen" - - @Composable - override fun ScreenContent() { - val vm = getViewModel() - val state by vm.stateFlow.collectAsState() - val navigator = LocalCodeNavigator.current - - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .onEach { navigator.push(ScreenRegistry.get(AppRoute.Onboarding.AccessKey)) } - .launchIn(this) - } + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { navigator.push(AppRoute.Onboarding.AccessKey) } + .launchIn(this) + } - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .onEach { delay(500) } - .onEach { navigator.replaceAll(ScreenRegistry.get(AppRoute.Main.Scanner())) } - .launchIn(this) - } + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { delay(500) } + .onEach { navigator.replaceAll(AppRoute.Main.Scanner()) } + .launchIn(this) + } - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .onEach { delay(500) } - .onEach { - navigator.push( - items = listOf( - ScreenRegistry.get(AppRoute.Onboarding.AccessKey), - ScreenRegistry.get( - AppRoute.Onboarding.Purchase(true) - ) - ) + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { delay(500) } + .onEach { + navigator.push( + routes = listOf( + AppRoute.Onboarding.AccessKey, + AppRoute.Onboarding.Purchase(true) ) - } - .launchIn(this) - } - - LaunchedEffect(seed) { - if (seed != null) { - vm.dispatchEvent(LoginViewModel.Event.LogIn(seed, fromDeeplink)) + ) } - } + .launchIn(this) + } - LoginRouterScreenContent( - isLoggingIn = state.loggingIn, - createAccount = { vm.dispatchEvent(LoginViewModel.Event.CreateAccount) }, - login = { navigator.push(ScreenRegistry.get(AppRoute.Onboarding.SeedInput)) }, - isLabsOpen = state.betaOptionsVisible, - onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, - openBetaFlags = { navigator.show(ScreenRegistry.get(AppRoute.Sheets.Lab)) } - ) + LaunchedEffect(seed) { + if (seed != null) { + vm.dispatchEvent(LoginViewModel.Event.LogIn(seed, fromDeeplink)) + } } -} \ No newline at end of file + + LoginRouterScreenContent( + isLoggingIn = state.loggingIn, + createAccount = { vm.dispatchEvent(LoginViewModel.Event.CreateAccount) }, + login = { navigator.push(AppRoute.Onboarding.SeedInput) }, + isLabsOpen = state.betaOptionsVisible, + onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, + openBetaFlags = { navigator.navigateTo(AppRoute.Sheets.Lab) } + ) +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputScreen.kt index 46ecc3d23..ba19b59ff 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputScreen.kt @@ -1,47 +1,29 @@ package com.flipcash.app.login.seed -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.login.internal.SeedInputContent import com.flipcash.features.login.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.AppScreen import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class SeedInputScreen: AppScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "seed_input_screen" - - @Composable - override fun ScreenContent() { - val viewModel: SeedInputViewModel = getViewModel() - val navigator = LocalCodeNavigator.current - Column { - AppBarWithTitle( - modifier = Modifier.fillMaxWidth(), - backButton = true, - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = { navigator.pop() }, - title = stringResource(R.string.title_enterAccessKeyWords), - ) - SeedInputContent(viewModel) - } +@Composable +fun SeedInputScreen() { + val viewModel: SeedInputViewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + Column { + AppBarWithTitle( + modifier = Modifier.fillMaxWidth(), + backButton = true, + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, + title = stringResource(R.string.title_enterAccessKeyWords), + ) + SeedInputContent(viewModel) } } - diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt index 6d70d5254..aaa9122ff 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt @@ -2,7 +2,6 @@ package com.flipcash.app.login.seed import android.annotation.SuppressLint import androidx.lifecycle.viewModelScope -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.auth.AuthManager import com.flipcash.app.auth.internal.credentials.SelectCredentialError import com.flipcash.app.core.AppRoute @@ -132,10 +131,10 @@ class SeedInputViewModel @Inject constructor( delay(1.seconds) when { !flags.isRegistered && flags.requiresIapForRegistration -> { - navigator.push(ScreenRegistry.get(AppRoute.Onboarding.Purchase(true))) + navigator.push(AppRoute.Onboarding.Purchase(true)) } - else -> navigator.replaceAll(ScreenRegistry.get(AppRoute.Main.Scanner())) + else -> navigator.replaceAll(AppRoute.Main.Scanner()) } } @@ -191,7 +190,7 @@ class SeedInputViewModel @Inject constructor( positiveText = resources.getString(R.string.action_createNewFlipcashAccount), tertiaryText = resources.getString(R.string.action_tryDifferentFlipcashAccount), onPositive = { - navigator.replaceAll(ScreenRegistry.get(AppRoute.Onboarding.Login())) + navigator.replaceAll(AppRoute.Onboarding.Login()) } ) ) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt index 09691e9ae..d14d8298b 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt @@ -1,65 +1,48 @@ package com.flipcash.app.menu -import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute import com.flipcash.app.menu.internal.MenuScreenContent import com.flipcash.app.menu.internal.MenuScreenViewModel import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class MenuScreen : ModalScreen, Parcelable { +@Composable +fun MenuScreen() { + val viewModel = hiltViewModel() + MenuScreenContent(viewModel) - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey + val navigator = LocalCodeNavigator.current - @IgnoredOnParcel - override val testTag: String = "menu_screen" - - @Composable - override fun ModalContent() { - val viewModel = getViewModel() - MenuScreenContent(viewModel) - - val navigator = LocalCodeNavigator.current - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - navigator.replaceAll(ScreenRegistry.get(AppRoute.Onboarding.Login())) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + navigator.replaceAll(AppRoute.Onboarding.Login()) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { ScreenRegistry.get(it.screen) } - .onEach { navigator.push(it) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.screen } + .onEach { navigator.push(it) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.entropy } - .onEach { - navigator.hide() - navigator.replaceAll(ScreenRegistry.get(AppRoute.Onboarding.Login(it))) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.entropy } + .onEach { + navigator.hide() + navigator.replaceAll(AppRoute.Onboarding.Login(it)) } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt index 22ae98f69..dc4087a1a 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.transfers.TransferDirection @@ -83,4 +82,4 @@ internal data object LogOut : FullMenuItem() { override val name: String @Composable get() = stringResource(R.string.action_logout) override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OnLogOutClicked -} \ No newline at end of file +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index 314f82305..194db81cc 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.myaccount -import android.os.Parcelable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -10,104 +9,88 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute import com.flipcash.app.myaccount.internal.MyAccountScreen import com.flipcash.app.myaccount.internal.MyAccountScreenViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.rememberedClickable import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class MyAccountScreen: ModalScreen, Parcelable { +@Composable +fun MyAccountScreen() { + val navigator = LocalCodeNavigator.current - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey + val viewModel = hiltViewModel() - @IgnoredOnParcel - override val testTag: String = "my_account_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - - val viewModel = getViewModel() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = { - AppBarDefaults.Title( - modifier = Modifier.rememberedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { viewModel.dispatchEvent(MyAccountScreenViewModel.Event.OnTitleClicked) }, - text = stringResource(R.string.title_myAccount), - ) - }, - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, - ) - MyAccountScreen(viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = { + AppBarDefaults.Title( + modifier = Modifier.rememberedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { viewModel.dispatchEvent(MyAccountScreenViewModel.Event.OnTitleClicked) }, + text = stringResource(R.string.title_myAccount), + ) + }, + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, + ) + MyAccountScreen(viewModel) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - navigator.replaceAll(ScreenRegistry.get(AppRoute.Onboarding.Login())) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + navigator.replaceAll(AppRoute.Onboarding.Login()) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push(ScreenRegistry.get(AppRoute.Menu.BackupKey)) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push(AppRoute.Menu.BackupKey) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - val flow = AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, - includePhone = true, - includeEmail = false, - ) + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + val flow = AppRoute.Verification( + origin = AppRoute.Menu.MyAccount, + includePhone = true, + includeEmail = false, + ) - navigator.push(ScreenRegistry.get(flow)) } - .launchIn(this) - } + navigator.push(flow) } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - val flow = AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, - includePhone = false, - includeEmail = true, - ) + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + val flow = AppRoute.Verification( + origin = AppRoute.Menu.MyAccount, + includePhone = false, + includeEmail = true, + ) - navigator.push(ScreenRegistry.get(flow)) } - .launchIn(this) - } + navigator.push(flow) } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt index e794ca993..00650fabd 100644 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt +++ b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.onramp -import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -13,82 +12,67 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.onramp.internal.OnRampViewModel import com.flipcash.app.onramp.internal.screens.OnRampAmountScreen import com.flipcash.features.onramp.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class OnRampCustomAmountScreen : ModalScreen, Parcelable { +@Composable +fun OnRampCustomAmountScreen() { + val navigator = LocalCodeNavigator.current + val viewModel = flowScopedViewModel(key = OnRampFlowTracker.key) + var paymentLink by rememberSaveable { mutableStateOf(null) } - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "onramp_custom_amount_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - val viewModel = getStackScopedViewModel(key = OnRampFlowTracker.key) - var paymentLink by rememberSaveable { mutableStateOf(null) } - - Box { - paymentLink?.let { - CoinbaseOnRampWebview( - paymentLinkUrl = it, - onPaymentSuccess = { - paymentLink = null - viewModel.dispatchEvent(OnRampViewModel.Event.OnPaymentSuccess) - }, - onPaymentFailure = { - paymentLink = null - viewModel.dispatchEvent(OnRampViewModel.Event.OnPaymentError(it)) - } - ) - } + Box { + paymentLink?.let { + CoinbaseOnRampWebview( + paymentLinkUrl = it, + onPaymentSuccess = { + paymentLink = null + viewModel.dispatchEvent(OnRampViewModel.Event.OnPaymentSuccess) + }, + onPaymentFailure = { + paymentLink = null + viewModel.dispatchEvent(OnRampViewModel.Event.OnPaymentError(it)) + } + ) + } - Column( - modifier = Modifier.fillMaxSize(), - ) { - AppBarWithTitle( - title = stringResource(R.string.title_amountToDeposit), - isInModal = true, - backButton = true, - onBackIconClicked = { navigator.pop() }, - titleAlignment = Alignment.CenterHorizontally, - ) - OnRampAmountScreen(viewModel) - } + Column( + modifier = Modifier.fillMaxSize(), + ) { + AppBarWithTitle( + title = stringResource(R.string.title_amountToDeposit), + isInModal = true, + backButton = true, + onBackIconClicked = { navigator.pop() }, + titleAlignment = Alignment.CenterHorizontally, + ) + OnRampAmountScreen(viewModel) } + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.url } - .onEach { + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.url } + .onEach { - }.launchIn(this) - } + }.launchIn(this) + } - val externalWalletOnRamp = LocalExternalWalletState.current - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.amount } - .onEach { externalWalletOnRamp.amount = it } - .launchIn(this) - } + val externalWalletOnRamp = LocalExternalWalletState.current + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.amount } + .onEach { externalWalletOnRamp.amount = it } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampProviderListScreen.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampProviderListScreen.kt index fd76e2656..926a6f8bc 100644 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampProviderListScreen.kt +++ b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampProviderListScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.onramp -import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -9,95 +8,71 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.core.AppRoute import com.flipcash.app.onramp.internal.OnRampViewModel import com.flipcash.app.onramp.internal.data.OnRampProviderDestination import com.flipcash.app.onramp.internal.screens.OnRampProviderListScreen import com.flipcash.features.onramp.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen -import com.getcode.navigation.screens.ReturnResultScreen -import com.getcode.navigation.screens.OnScreenResult +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class OnRampProviderListScreen( - val neededAmount: Long? = null, - val neededCurrency: CurrencyCode? = null, -): ReturnResultScreen(), ModalScreen, Parcelable { +@Composable +fun OnRampProviderListScreen( + neededAmount: Long? = null, + neededCurrency: CurrencyCode? = null, +) { + val navigator = LocalCodeNavigator.current - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey + val viewModel = flowScopedViewModel(key = OnRampFlowTracker.key) - @IgnoredOnParcel - override val testTag: String = "onramp_provider_list_screen" + val externalWalletOnRamp = LocalExternalWalletState.current - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - - val viewModel = getStackScopedViewModel(key = OnRampFlowTracker.key) - - val externalWalletOnRamp = LocalExternalWalletState.current - - Box { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_depositFunds), - titleAlignment = Alignment.CenterHorizontally, - isInModal = true, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) - OnRampProviderListScreen(viewModel) - } - } - - OnScreenResult { result -> - if (result == "verified") { - navigator.push(ScreenRegistry.get(AppRoute.OnRamp.AmountEntry)) - } + Box { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_depositFunds), + titleAlignment = Alignment.CenterHorizontally, + isInModal = true, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + OnRampProviderListScreen(viewModel) } + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.item.destination } - .filterIsInstance() - .map { it.screen } - .onEach { destination -> - navigator.push(ScreenRegistry.get(destination)) - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.item.destination } + .filterIsInstance() + .map { it.screen } + .onEach { destination -> + navigator.push(destination) + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.item.destination } - .filterIsInstance() - .onEach { type -> - // bring up amount picker in flow always to match coinbase onramp flow - // i.e no delta passed + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.item.destination } + .filterIsInstance() + .onEach { type -> + // bring up amount picker in flow always to match coinbase onramp flow + // i.e no delta passed // if (neededAmount != null && neededCurrency != null) { // externalWalletOnRamp.amount = Fiat(neededAmount, neededCurrency) // } - externalWalletOnRamp.start(OnRampFlowTracker.source, type.wallet) - } - .launchIn(this) - } + externalWalletOnRamp.start(OnRampFlowTracker.source, type.wallet) + } + .launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/screens/OnRampAmountScreenContent.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/screens/OnRampAmountScreenContent.kt index 0fc831022..9c0c1bb1e 100644 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/screens/OnRampAmountScreenContent.kt +++ b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/screens/OnRampAmountScreenContent.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.onramp.ui.buildExternalWalletButtonLabel @@ -67,10 +66,8 @@ private fun OnRampAmountScreenContent( isClickable = provider !is OnRampProvider.Phantom, onAmountClicked = { navigator.push( - ScreenRegistry.get( - AppRoute.Main.RegionSelection( - kind = RegionSelectionKind.Entry - ) + AppRoute.Main.RegionSelection( + kind = RegionSelectionKind.Entry ) ) }, @@ -129,4 +126,4 @@ private fun ConfirmationButton( ) { dispatchEvent(OnRampViewModel.Event.OnAmountConfirmed) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt index bdb6ed0db..7b0608d32 100644 --- a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt +++ b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/PurchaseAccountScreen.kt @@ -1,39 +1,28 @@ package com.flipcash.app.purchase -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.purchase.internal.PurchaseAccountScreen import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.AppScreen import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class PurchaseAccountScreen( - private val fromLogin: Boolean = false -) : AppScreen, Parcelable { - - @IgnoredOnParcel - override val testTag: String = "purchase_account_screen" - - @Composable - override fun ScreenContent() { - val navigator = LocalCodeNavigator.current - Column { - AppBarWithTitle( - backButton = true, - onBackIconClicked = { - if (fromLogin) { - navigator.popAll() - } else { - navigator.pop() - } +@Composable +fun PurchaseAccountScreen( + fromLogin: Boolean = false +) { + val navigator = LocalCodeNavigator.current + Column { + AppBarWithTitle( + backButton = true, + onBackIconClicked = { + if (fromLogin) { + navigator.popAll() + } else { + navigator.pop() } - ) - PurchaseAccountScreen(getViewModel()) - } + } + ) + PurchaseAccountScreen(hiltViewModel()) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt index fefca3e26..6024f558e 100644 --- a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt +++ b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.core.ui.BrandedGradientIcon import com.flipcash.features.purchase.R @@ -55,7 +54,7 @@ internal fun PurchaseAccountScreen(viewModel: PurchaseAccountViewModel) { viewModel.eventFlow .filterIsInstance() .onEach { - navigator.replaceAll(ScreenRegistry.get(AppRoute.Main.Scanner())) + navigator.replaceAll(AppRoute.Main.Scanner()) }.launchIn(this) } @@ -200,4 +199,4 @@ private fun Preview_Pending() { ), ) { } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt index a2e0c8249..2d840e94b 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt @@ -1,25 +1,10 @@ package com.flipcash.app.scanner -import android.os.Parcelable import androidx.compose.runtime.Composable -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.scanner.internal.Scanner -import com.getcode.navigation.screens.AppScreen -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class ScannerScreen(private val deepLink: DeeplinkType? = null) : AppScreen, Parcelable{ - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "scanner_screen" - - @Composable - override fun ScreenContent() { - Scanner(deepLink) - } -} \ No newline at end of file +@Composable +fun ScannerScreen(deepLink: DeeplinkType? = null) { + Scanner(deepLink) +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt index e56ab7571..38d975cf1 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt @@ -1,8 +1,8 @@ package com.flipcash.app.scanner.internal -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.extensions.navigateTo import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.onramp.deeplinks.OnRampDeeplinkOrigin import com.flipcash.app.core.tokens.TokenSwapPurpose @@ -20,29 +20,25 @@ class NavigationStateRestorer( when (deeplink) { is DeeplinkType.TokenInfo -> { delay(200.scaled(animationScale)) - navigator.show( - listOf( - ScreenRegistry.get(AppRoute.Sheets.Wallet), - ScreenRegistry.get(AppRoute.Token.Info(deeplink.mint, fromDeeplink = true)) - ) - ) + navigator.navigateTo(AppRoute.Sheets.Wallet) + navigator.push(AppRoute.Token.Info(deeplink.mint, fromDeeplink = true)) } is DeeplinkType.ExternalWalletStep -> { val screens = when (val origin = deeplink.origin) { - OnRampDeeplinkOrigin.Menu -> buildOnRampScreenFlow(AppRoute.Sheets.Menu) + ScreenRegistry.get(AppRoute.OnRamp.AmountEntry) - is OnRampDeeplinkOrigin.Give -> buildOnRampScreenFlow(AppRoute.Main.Give(origin.tokenAddress)) + ScreenRegistry.get(AppRoute.OnRamp.AmountEntry) - OnRampDeeplinkOrigin.Wallet -> buildOnRampScreenFlow(AppRoute.Sheets.Wallet) + ScreenRegistry.get(AppRoute.OnRamp.AmountEntry) - OnRampDeeplinkOrigin.Reserves -> buildOnRampScreenFlow(AppRoute.Token.Info(Mint.usdf)) + ScreenRegistry.get(AppRoute.OnRamp.AmountEntry) + OnRampDeeplinkOrigin.Menu -> buildOnRampScreenFlow(AppRoute.Sheets.Menu) + AppRoute.OnRamp.AmountEntry + is OnRampDeeplinkOrigin.Give -> buildOnRampScreenFlow(AppRoute.Sheets.Give(origin.tokenAddress)) + AppRoute.OnRamp.AmountEntry + OnRampDeeplinkOrigin.Wallet -> buildOnRampScreenFlow(AppRoute.Sheets.Wallet) + AppRoute.OnRamp.AmountEntry + OnRampDeeplinkOrigin.Reserves -> buildOnRampScreenFlow(AppRoute.Token.Info(Mint.usdf)) + AppRoute.OnRamp.AmountEntry is OnRampDeeplinkOrigin.TokenInfo -> listOf( - ScreenRegistry.get(AppRoute.Sheets.Wallet), - ScreenRegistry.get(AppRoute.Token.Info(origin.mint)), - ScreenRegistry.get(AppRoute.Token.SwapTransact(TokenSwapPurpose.FundWithWallet(origin.mint))) + AppRoute.Sheets.Wallet, + AppRoute.Token.Info(origin.mint), + AppRoute.Token.SwapTransact(TokenSwapPurpose.FundWithWallet(origin.mint)) ) } - navigator.show(screens) + navigator.navigateTo(screens) } is DeeplinkType.EmailVerification -> { @@ -50,31 +46,27 @@ class NavigationStateRestorer( val screens = when (origin) { is EmailDeeplinkOrigin.OnRamp -> when (val source = origin.source) { is AppRoute.Sheets.Menu -> { - buildOnRampScreenFlow(source) + ScreenRegistry.get( - AppRoute.Verification( + buildOnRampScreenFlow(source) + AppRoute.Verification( origin = source, target = AppRoute.OnRamp.AmountEntry, includePhone = false, email = deeplink.email, emailVerificationCode = deeplink.code ) - ) } else -> emptyList() } EmailDeeplinkOrigin.MyAccount -> listOf( - ScreenRegistry.get(AppRoute.Sheets.Menu), - ScreenRegistry.get(AppRoute.Menu.MyAccount) - ) + ScreenRegistry.get( - AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, - target = null, - includePhone = false, - email = deeplink.email, - emailVerificationCode = deeplink.code - ) + AppRoute.Sheets.Menu, + AppRoute.Menu.MyAccount + ) + AppRoute.Verification( + origin = AppRoute.Menu.MyAccount, + target = null, + includePhone = false, + email = deeplink.email, + emailVerificationCode = deeplink.code ) null -> emptyList() @@ -82,7 +74,7 @@ class NavigationStateRestorer( if (screens.isNotEmpty()) { analytics.deeplinkRouted(deeplink) - navigator.show(screens) + navigator.navigateTo(screens) } else { analytics.deeplinkRouted(deeplink, IllegalStateException("Failed to route deeplink")) } @@ -92,8 +84,7 @@ class NavigationStateRestorer( } private fun buildOnRampScreenFlow(origin: List) = - origin.dropLast(1).map { ScreenRegistry.get(it) } + - ScreenRegistry.get(AppRoute.OnRamp.ProviderList(origin.last()) -) + origin.dropLast(1) + + AppRoute.OnRamp.ProviderList(origin.last()) -private fun buildOnRampScreenFlow(origin: AppRoute) = buildOnRampScreenFlow(listOf(origin)) \ No newline at end of file +private fun buildOnRampScreenFlow(origin: AppRoute) = buildOnRampScreenFlow(listOf(origin)) 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 46eefa138..bd6157fb8 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 @@ -11,9 +11,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.navigator.currentOrThrow import com.flipcash.app.analytics.rememberAnalytics +import com.flipcash.app.core.extensions.navigateTo import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.BillContainer @@ -35,9 +34,9 @@ import timber.log.Timber @Composable internal fun Scanner(deepLink: DeeplinkType?) { - val router = LocalRouter.currentOrThrow + val router = LocalRouter.current!! val navigator = LocalCodeNavigator.current - val session = LocalSessionController.currentOrThrow + val session = LocalSessionController.current!! val state by session.state.collectAsState() val billState by session.billState.collectAsState() val analytics = rememberAnalytics() @@ -100,7 +99,7 @@ internal fun Scanner(deepLink: DeeplinkType?) { } else -> Unit } - navigator.show(ScreenRegistry.get(it.screen)) + navigator.navigateTo(it.screen) }, scannerView = { CodeScanner( @@ -174,8 +173,8 @@ internal fun Scanner(deepLink: DeeplinkType?) { } } - LaunchedEffect(navigator.isVisible) { - previewing = !navigator.isVisible + LaunchedEffect(navigator.backStack.size) { + previewing = navigator.backStack.size <= 1 } LaunchedEffect(billState.bill) { @@ -188,4 +187,4 @@ internal fun Scanner(deepLink: DeeplinkType?) { isEnabled = billState.bill != null, useBrightness = true, ) -} \ No newline at end of file +} 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 e164bd851..834be11db 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 @@ -4,7 +4,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose sealed class ScannerDecorItem(val screen: AppRoute) { - data object Give : ScannerDecorItem(AppRoute.Main.Give()) + data object Give : ScannerDecorItem(AppRoute.Sheets.Give()) data object Wallet : ScannerDecorItem(AppRoute.Sheets.Wallet) data object Menu : ScannerDecorItem(AppRoute.Sheets.Menu) diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt index 3ffe9a6ef..7732eb2a6 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.navigator.currentOrThrow import com.flipcash.app.bill.customization.LocalBillPlaygroundController import com.flipcash.app.bills.AnimatedBill import com.flipcash.app.core.android.extensions.launchAppSettings @@ -74,7 +73,7 @@ internal fun BillContainer( onStartCamera: () -> Unit, onAction: (ScannerDecorItem) -> Unit ) { - val session = LocalSessionController.currentOrThrow + val session = LocalSessionController.current!! val context = LocalContext.current val onPermissionResult = { result: PermissionResult -> session.onCameraPermissionResult(result) @@ -288,4 +287,4 @@ internal fun BillContainer( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/ShareAppScreen.kt b/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/ShareAppScreen.kt index a91f1b144..d1c48d68c 100644 --- a/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/ShareAppScreen.kt +++ b/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/ShareAppScreen.kt @@ -1,60 +1,42 @@ package com.flipcash.app.shareapp -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.shareapp.internal.QrCodeImageCache import com.flipcash.app.shareapp.internal.ShareAppScreenContent import com.flipcash.features.shareapp.R import com.getcode.libs.qr.rememberQrBitmapPainter import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class ShareAppScreen: ModalScreen, Parcelable { +@Composable +fun ShareAppScreen() { + val navigator = LocalCodeNavigator.current + Column( + modifier = androidx.compose.ui.Modifier.fillMaxSize(), + ) { + AppBarWithTitle( + isInModal = true, + titleAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close { navigator.hide() } } + ) - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey + ShareAppScreenContent() - @IgnoredOnParcel - override val testTag: String = "share_app_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - ) { - AppBarWithTitle( - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close { navigator.hide() } } + if (QrCodeImageCache.downloadQrCode == null) { + QrCodeImageCache.downloadQrCode = rememberQrBitmapPainter( + content = stringResource( + R.string.app_download_link, + stringResource(id = R.string.app_download_link_qr_ref) + ), + size = CodeTheme.dimens.screenWidth * 0.60f, + padding = 0.25.dp ) - - ShareAppScreenContent() - - if (QrCodeImageCache.downloadQrCode == null) { - QrCodeImageCache.downloadQrCode = rememberQrBitmapPainter( - content = stringResource( - R.string.app_download_link, - stringResource(id = R.string.app_download_link_qr_ref) - ), - size = CodeTheme.dimens.screenWidth * 0.60f, - padding = 0.25.dp - ) - } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/internal/ShareAppScreenContent.kt b/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/internal/ShareAppScreenContent.kt index 3faec2b6f..8397e5f10 100644 --- a/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/internal/ShareAppScreenContent.kt +++ b/apps/flipcash/features/shareapp/src/main/kotlin/com/flipcash/app/shareapp/internal/ShareAppScreenContent.kt @@ -39,7 +39,6 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.Cloudy import com.getcode.ui.components.SelectionContainer import com.getcode.ui.components.rememberSelectionState -import com.getcode.ui.core.addIf import com.getcode.ui.core.rememberedLongClickable import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton @@ -123,9 +122,7 @@ internal fun ShareAppScreenContent() { Image( modifier = Modifier - .addIf(navigator.sheetFullyVisible) { - Modifier.onPlaced { contentRect = it.boundsInWindow() } - } + .onPlaced { contentRect = it.boundsInWindow() } .rememberedLongClickable { onClick() } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt index 9897a9966..dacdcfb77 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.tokens -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -10,9 +9,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenSwapPurpose import com.flipcash.app.onramp.LocalExternalWalletState @@ -20,95 +16,81 @@ import com.flipcash.app.tokens.internal.BuySellTokenEntryScreen import com.flipcash.app.tokens.ui.BuySellSwapTokenViewModel import com.flipcash.features.tokens.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize +@Composable +fun TokenBuySellEntryScreen( + purpose: TokenSwapPurpose, +) { + val navigator = LocalCodeNavigator.current + val viewModel = flowScopedViewModel(BuySellFlow.key) + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val externalWalletOnRamp = LocalExternalWalletState.current -@Parcelize -class TokenBuySellEntryScreen( - private val purpose: TokenSwapPurpose -): ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "token_buy_sell_amount_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - val viewModel = getStackScopedViewModel(BuySellFlow.key) - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val externalWalletOnRamp = LocalExternalWalletState.current - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - isInModal = true, - title = when (purpose) { - is TokenSwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy) - is TokenSwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell) - }, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { - if (state.buyProgress.loading) { - // swallow - } else { - navigator.pop() - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + isInModal = true, + title = when (purpose) { + is TokenSwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy) + is TokenSwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell) + }, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { + if (state.buyProgress.loading) { + // swallow + } else { + navigator.pop() } - ) + } + ) - BuySellTokenEntryScreen(viewModel) - } + BuySellTokenEntryScreen(viewModel) + } - LaunchedEffect(viewModel) { - viewModel.dispatchEvent(BuySellSwapTokenViewModel.Event.OnPurposeChanged(purpose)) - } + LaunchedEffect(viewModel) { + viewModel.dispatchEvent(BuySellSwapTokenViewModel.Event.OnPurposeChanged(purpose)) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push(ScreenRegistry.get(AppRoute.Token.SellReceipt)) - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push(AppRoute.Token.SellReceipt) + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { (token, amount) -> - externalWalletOnRamp.tokenToPurchase = token - externalWalletOnRamp.amount = amount - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { (token, amount) -> + externalWalletOnRamp.tokenToPurchase = token + externalWalletOnRamp.amount = amount + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.popUntil { it is TokenInfoScreen } - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.popUntil { it is AppRoute.Token.Info } + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.swapId } - .onEach { swapId -> - navigator.push(ScreenRegistry.get(AppRoute.Token.TxProcessing(swapId))) - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.swapId } + .onEach { swapId -> + navigator.push(AppRoute.Token.TxProcessing(swapId)) + }.launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index c6981dffe..2d4add1e0 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.tokens -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -9,16 +8,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi -import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.rememberAnalytics +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.ui.TokenIconWithName import com.flipcash.app.onramp.LocalExternalWalletState import com.flipcash.app.onramp.OnRampFlowTracker @@ -27,8 +22,6 @@ import com.flipcash.app.tokens.ui.TokenInfoViewModel import com.flipcash.features.tokens.R import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen -import com.getcode.navigation.screens.ReturnResultScreen import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults @@ -40,111 +33,97 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class TokenInfoScreen( - private val mint: Mint, - private val forNeededFunds: Boolean, - private val fromDeeplink: Boolean, -) : ReturnResultScreen(), ModalScreen, Parcelable { +@Composable +fun TokenInfoScreen( + mint: Mint, + forNeededFunds: Boolean, + fromDeeplink: Boolean, +) { + val navigator = LocalCodeNavigator.current + val externalWalletOnRamp = LocalExternalWalletState.current - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "token_info_screen" - - @OptIn(ExperimentalVoyagerApi::class) - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - val externalWalletOnRamp = LocalExternalWalletState.current - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val analytics = rememberAnalytics() - val viewModel = getViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - AppBarWithTitle( - isInModal = true, - title = { - state.token.dataOrNull?.let { token -> - if (state.isCashReserve && state.cashReservesEnabled) { - AppBarDefaults.Title(text = stringResource(R.string.title_cashReserves)) - } else { - TokenIconWithName( - token = token, - imageSize = CodeTheme.dimens.staticGrid.x5, - spacing = CodeTheme.dimens.grid.x1, - ) - } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val analytics = rememberAnalytics() + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + AppBarWithTitle( + isInModal = true, + title = { + state.token.dataOrNull?.let { token -> + if (state.isCashReserve && state.cashReservesEnabled) { + AppBarDefaults.Title(text = stringResource(R.string.title_cashReserves)) + } else { + TokenIconWithName( + token = token, + imageSize = CodeTheme.dimens.staticGrid.x5, + spacing = CodeTheme.dimens.grid.x1, + ) } - }, - titleAlignment = Alignment.CenterHorizontally, - leftIcon = { - AppBarDefaults.UpNavigation { navigator.pop() } - }, - rightContents = { - state.token.dataOrNull?.let { - if (!state.isCashReserve) { - AppBarDefaults.Share { - analytics.buttonTapped(Button.TokenShare) - viewModel.dispatchEvent(TokenInfoViewModel.Event.Share) - } + } + }, + titleAlignment = Alignment.CenterHorizontally, + leftIcon = { + AppBarDefaults.UpNavigation { navigator.pop() } + }, + rightContents = { + state.token.dataOrNull?.let { + if (!state.isCashReserve) { + AppBarDefaults.Share { + analytics.buttonTapped(Button.TokenShare) + viewModel.dispatchEvent(TokenInfoViewModel.Event.Share) } } - }, - ) - - LifecycleEffectOnce { - val source = when { - forNeededFunds -> Analytics.TokenInfoSource.Give - fromDeeplink -> Analytics.TokenInfoSource.Deeplink - else -> Analytics.TokenInfoSource.Wallet } + }, + ) - analytics.openTokenInfo( - source = source, - mint = mint - ) + LaunchedEffect(Unit) { + val source = when { + forNeededFunds -> Analytics.TokenInfoSource.Give + fromDeeplink -> Analytics.TokenInfoSource.Deeplink + else -> Analytics.TokenInfoSource.Wallet } - TokenInfoScreen(viewModel, forNeededFunds) + analytics.openTokenInfo( + source = source, + mint = mint + ) + } + + TokenInfoScreen(viewModel, forNeededFunds) - LaunchedEffect(Unit) { - viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, forNeededFunds)) - } + LaunchedEffect(Unit) { + viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, forNeededFunds)) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.pop() } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.pop() } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.screen } - .onEach { - navigator.push(ScreenRegistry.get(it)) - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.screen } + .onEach { + navigator.push(it) + }.launchIn(this) + } - val animationScale by rememberAnimationScale() - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { delay(300.scaled(animationScale)) } - .onEach { - externalWalletOnRamp.start(OnRampFlowTracker.source, OnRampProvider.Phantom) - }.launchIn(this) - } + val animationScale by rememberAnimationScale() + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { delay(300.scaled(animationScale)) } + .onEach { + externalWalletOnRamp.start(OnRampFlowTracker.source, OnRampProvider.Phantom) + }.launchIn(this) } } - -} \ No newline at end of file +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt index d701e15aa..995158f7f 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.tokens -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -8,10 +7,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute.Menu.Deposit import com.flipcash.app.core.AppRoute.Transfers.Withdrawal.Amount import com.flipcash.app.core.tokens.TokenPurpose @@ -19,85 +15,68 @@ import com.flipcash.app.tokens.internal.SelectTokenScreen import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.features.tokens.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class TokenSelectScreen(private val purpose: TokenPurpose) : ModalScreen, Parcelable { +@Composable +fun TokenSelectScreen(purpose: TokenPurpose) { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + isInModal = true, + title = stringResource(R.string.title_selectCurrency), + backButton = true, + onBackIconClicked = { navigator.pop() }, + titleAlignment = Alignment.CenterHorizontally, + ) + val viewModel = hiltViewModel() + SelectTokenScreen(viewModel) - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "token_select_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - isInModal = true, - title = stringResource(R.string.title_selectCurrency), - backButton = true, - onBackIconClicked = { navigator.pop() }, - titleAlignment = Alignment.CenterHorizontally, - ) - val viewModel = getViewModel() - SelectTokenScreen(viewModel) - - LaunchedEffect(viewModel) { - viewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(purpose)) - } + LaunchedEffect(viewModel) { + viewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(purpose)) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { ScreenRegistry.get(it.route) } - .onEach { navigator.push(it) } - .launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.route } + .onEach { navigator.push(it) } + .launchIn(this) + } - // handle the cases where we are inserted in a flow to select a token - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .filter { it.fromUser } - .map { it.mint } - .onEach { token -> - when (purpose) { - TokenPurpose.Balance -> Unit - TokenPurpose.Select -> Unit - TokenPurpose.Withdraw -> { - navigator.push( - ScreenRegistry.get(Amount(token)) - ) - } - TokenPurpose.Deposit -> { - navigator.push( - ScreenRegistry.get(Deposit(token)) - ) - } + // handle the cases where we are inserted in a flow to select a token + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .filter { it.fromUser } + .map { it.mint } + .onEach { token -> + when (purpose) { + TokenPurpose.Balance -> Unit + TokenPurpose.Select -> Unit + TokenPurpose.Withdraw -> { + navigator.push(Amount(token)) + } + TokenPurpose.Deposit -> { + navigator.push(Deposit(token)) } - }.launchIn(this) - } + } + }.launchIn(this) + } - // handle the case where we are changing the selected token - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.pop() } - .launchIn(this) - } + // handle the case where we are changing the selected token + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.pop() } + .launchIn(this) } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt index c6e572bf4..0e0f50338 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSellReceiptScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.tokens -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -10,68 +9,52 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.core.AppRoute import com.flipcash.app.tokens.internal.TokenSellReceiptScreen import com.flipcash.app.tokens.ui.BuySellSwapTokenViewModel import com.flipcash.features.tokens.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class TokenSellReceiptScreen: ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "token_sell_receipt_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - - val viewModel = getStackScopedViewModel(BuySellFlow.key) - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - isInModal = true, - title = stringResource(R.string.title_sellToken, state.tokenName), - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { - if (state.sellProgress.loading) { - // swallow - } else { - navigator.pop() - } +@Composable +fun TokenSellReceiptScreen() { + val navigator = LocalCodeNavigator.current + + val viewModel = flowScopedViewModel(BuySellFlow.key) + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + isInModal = true, + title = stringResource(R.string.title_sellToken, state.tokenName), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { + if (state.sellProgress.loading) { + // swallow + } else { + navigator.pop() } - ) + } + ) - TokenSellReceiptScreen(viewModel) - } + TokenSellReceiptScreen(viewModel) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.swapId } - .onEach { swapId -> - navigator.push(ScreenRegistry.get(AppRoute.Token.TxProcessing(swapId))) - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.swapId } + .onEach { swapId -> + navigator.push(AppRoute.Token.TxProcessing(swapId)) + }.launchIn(this) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt index 76e927b94..0c4ae748c 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.tokens -import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -9,16 +8,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey +import com.flipcash.app.core.AppRoute import com.flipcash.app.onramp.LocalExternalWalletState import com.flipcash.app.onramp.internal.ExternalWalletState import com.flipcash.app.tokens.internal.TokenTxProcessingScreen import com.flipcash.app.tokens.ui.BuySellSwapTokenViewModel import com.flipcash.app.tokens.ui.BuySellSwapTokenViewModel.Event import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.ui.utils.DisableSheetGestures import com.getcode.view.LoadingSuccessState @@ -26,82 +23,70 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class TokenTxProcessingScreen( - val swapId: SwapId, - val awaitExternalWallet: Boolean = false, -) : ModalScreen, Parcelable { +@Composable +fun TokenTxProcessingScreen( + swapId: SwapId, + awaitExternalWallet: Boolean = false, +) { + val navigator = LocalCodeNavigator.current + val viewModel = flowScopedViewModel(BuySellFlow.key) - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey + // When awaiting external wallet, show a local loading indicator that doesn't + // affect the ViewModel's processingProgress timer. Once OnSwapIdChanged is + // dispatched the ViewModel takes over with its own loading state and fresh timer. + var awaitingWallet by remember { mutableStateOf(awaitExternalWallet) } - @IgnoredOnParcel - override val testTag: String = "token_buy_sell_processing_screen" + TokenTxProcessingScreen( + viewModel = viewModel, + processingProgressOverride = if (awaitingWallet) LoadingSuccessState(loading = true) else null, + ) - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - val viewModel = getStackScopedViewModel(BuySellFlow.key) + if (awaitExternalWallet) { + val externalWalletState = LocalExternalWalletState.current + LaunchedEffect(viewModel, swapId) { + // Wait for the transaction to be submitted before starting swap polling + snapshotFlow { externalWalletState.deeplinkState } + .first { it == ExternalWalletState.TRANSACTED } - // When awaiting external wallet, show a local loading indicator that doesn't - // affect the ViewModel's processingProgress timer. Once OnSwapIdChanged is - // dispatched the ViewModel takes over with its own loading state and fresh timer. - var awaitingWallet by remember { mutableStateOf(awaitExternalWallet) } + externalWalletState.reset() + viewModel.dispatchEvent(Event.OnSwapIdChanged(swapId)) - TokenTxProcessingScreen( - viewModel = viewModel, - processingProgressOverride = if (awaitingWallet) LoadingSuccessState(loading = true) else null, - ) + // Wait for the ViewModel's own loading state before dropping override. + // Both are LoadingSuccessState(loading=true) — data class equality means + // the indicator's remember(processingState) won't reset, so the timer + // and progress continue seamlessly with no jump. + snapshotFlow { viewModel.stateFlow.value.processingProgress } + .first { it.loading } - if (awaitExternalWallet) { - val externalWalletState = LocalExternalWalletState.current - LaunchedEffect(viewModel, swapId) { - // Wait for the transaction to be submitted before starting swap polling - snapshotFlow { externalWalletState.deeplinkState } - .first { it == ExternalWalletState.TRANSACTED } - - externalWalletState.reset() - viewModel.dispatchEvent(Event.OnSwapIdChanged(swapId)) - - // Wait for the ViewModel's own loading state before dropping override. - // Both are LoadingSuccessState(loading=true) — data class equality means - // the indicator's remember(processingState) won't reset, so the timer - // and progress continue seamlessly with no jump. - snapshotFlow { viewModel.stateFlow.value.processingProgress } - .first { it.loading } - - awaitingWallet = false - } - } else { - LaunchedEffect(viewModel, swapId) { - viewModel.dispatchEvent(Event.OnSwapIdChanged(swapId)) - } + awaitingWallet = false } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - if (BuySellFlow.isForNeededFunds) { - navigator.popAll() - } else { - navigator.popUntil { it is TokenInfoScreen } - } - }.launchIn(this) + } else { + LaunchedEffect(viewModel, swapId) { + viewModel.dispatchEvent(Event.OnSwapIdChanged(swapId)) } + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.popUntil { it is TokenInfoScreen } - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + if (BuySellFlow.isForNeededFunds) { + navigator.popAll() + } else { + navigator.popUntil { it is AppRoute.Token.Info } + } + }.launchIn(this) + } - BackHandler { /* intercept */ } - DisableSheetGestures() + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.popUntil { it is AppRoute.Token.Info } + }.launchIn(this) } + + BackHandler { /* intercept */ } + DisableSheetGestures() } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuySellEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuySellEntryScreen.kt index 9861d4f0b..573da02ab 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuySellEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuySellEntryScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel @@ -80,10 +79,8 @@ internal fun BuySellTokenEntryScreen( isClickable = state.purpose !is TokenSwapPurpose.FundWithWallet, onAmountClicked = { navigator.push( - ScreenRegistry.get( - AppRoute.Main.RegionSelection( - kind = RegionSelectionKind.Entry - ) + AppRoute.Main.RegionSelection( + kind = RegionSelectionKind.Entry ) ) }, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index d61d1b68e..1b8191129 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -302,7 +302,7 @@ private fun BottomBarButtons( ) { dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Main.Give(mint = loadable.data.address, fromTokenInfo = true) + AppRoute.Sheets.Give(mint = loadable.data.address, fromTokenInfo = true) ) ) } @@ -333,7 +333,7 @@ private fun BottomBarButtons( ) { dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Main.Give(mint = loadable.data.address, fromTokenInfo = true) + AppRoute.Sheets.Give(mint = loadable.data.address, fromTokenInfo = true) ) ) } diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionHistoryScreen.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionHistoryScreen.kt index 2449f9c99..292863fa6 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionHistoryScreen.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionHistoryScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.transactions -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -8,48 +7,33 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.transactions.internal.TransactionHistoryScreen import com.flipcash.app.transactions.internal.TransactionHistoryViewModel import com.flipcash.features.transactions.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.solana.keys.Mint import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class TransactionHistoryScreen(private val mint: Mint) : ModalScreen, Parcelable { +@Composable +fun TransactionHistoryScreen(mint: Mint) { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + isInModal = true, + title = stringResource(R.string.title_transactionHistory), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { navigator.pop() } + ) + val viewModel = hiltViewModel() + TransactionHistoryScreen(viewModel) - override val key: ScreenKey - get() = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "transaction_history_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - isInModal = true, - title = stringResource(R.string.title_transactionHistory), - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() } - ) - val viewModel = getViewModel() - TransactionHistoryScreen(viewModel) - - LaunchedEffect(viewModel, mint) { - viewModel.dispatchEvent(TransactionHistoryViewModel.Event.OnMintProvided(mint)) - } + LaunchedEffect(viewModel, mint) { + viewModel.dispatchEvent(TransactionHistoryViewModel.Event.OnMintProvided(mint)) } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalConfirmationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalConfirmationScreen.kt index 9faf090bc..17b5da851 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalConfirmationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalConfirmationScreen.kt @@ -1,46 +1,30 @@ package com.flipcash.app.withdrawal -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.withdrawal.internal.confirmation.WithdrawalConfirmationScreen import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class WithdrawalConfirmationScreen: ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "withdraw_confirmation_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - ) { - AppBarWithTitle( - title = stringResource(R.string.title_withdraw), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) - WithdrawalConfirmationScreen(getStackScopedViewModel(key = WithdrawalFlow.key)) - } +@Composable +fun WithdrawalConfirmationScreen() { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + ) { + AppBarWithTitle( + title = stringResource(R.string.title_withdraw), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + WithdrawalConfirmationScreen(flowScopedViewModel(key = WithdrawalFlow.key)) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalDestinationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalDestinationScreen.kt index cb79214bb..0c425ff2f 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalDestinationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalDestinationScreen.kt @@ -1,46 +1,30 @@ package com.flipcash.app.withdrawal -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.withdrawal.internal.destination.WithdrawalDestinationScreen import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class WithdrawalDestinationScreen: ModalScreen, Parcelable { - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "withdraw_destination_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - ) { - AppBarWithTitle( - title = stringResource(R.string.title_withdraw), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { navigator.pop() }, - ) - WithdrawalDestinationScreen(getStackScopedViewModel(key = WithdrawalFlow.key)) - } +@Composable +fun WithdrawalDestinationScreen() { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + ) { + AppBarWithTitle( + title = stringResource(R.string.title_withdraw), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { navigator.pop() }, + ) + WithdrawalDestinationScreen(flowScopedViewModel(key = WithdrawalFlow.key)) } - -} \ No newline at end of file +} diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalEntryScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalEntryScreen.kt index 973c3058f..ce5615b0a 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalEntryScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalEntryScreen.kt @@ -1,53 +1,37 @@ package com.flipcash.app.withdrawal -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.withdrawal.internal.entry.WithdrawalEntryScreen import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getStackScopedViewModel -import com.getcode.navigation.screens.ModalScreen +import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.solana.keys.Mint import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -@Parcelize -class WithdrawalEntryScreen( - private val selectedMint: Mint -): ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - @IgnoredOnParcel - override val testTag: String = "withdraw_entry_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - ) { - AppBarWithTitle( - title = stringResource(R.string.title_withdraw), - isInModal = true, - backButton = true, - onBackIconClicked = { navigator.pop() }, - titleAlignment = Alignment.CenterHorizontally, - ) - val viewModel = getStackScopedViewModel(key = WithdrawalFlow.key) - WithdrawalEntryScreen(viewModel, selectedMint) - } +@Composable +fun WithdrawalEntryScreen( + selectedMint: Mint +) { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + ) { + AppBarWithTitle( + title = stringResource(R.string.title_withdraw), + isInModal = true, + backButton = true, + onBackIconClicked = { navigator.pop() }, + titleAlignment = Alignment.CenterHorizontally, + ) + val viewModel = flowScopedViewModel(key = WithdrawalFlow.key) + WithdrawalEntryScreen(viewModel, selectedMint) } } @@ -59,4 +43,4 @@ object WithdrawalFlow { fun start() { key = Uuid.random().toString() } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt index e11004667..51dae922b 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt @@ -19,9 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute -import com.flipcash.app.withdrawal.WithdrawalEntryScreen import com.flipcash.app.withdrawal.WithdrawalFlow import com.flipcash.app.withdrawal.WithdrawalViewModel import com.flipcash.app.withdrawal.internal.components.DestinationBox @@ -66,7 +64,7 @@ internal fun WithdrawalConfirmationScreen(viewModel: WithdrawalViewModel) { ) ), onClose = { - navigator.popUntil { it == ScreenRegistry.get(AppRoute.Sheets.Menu) } + navigator.popUntil { it == AppRoute.Sheets.Menu } } ) ) @@ -91,7 +89,7 @@ internal fun WithdrawalConfirmationScreen(viewModel: WithdrawalViewModel) { }, onClose = { WithdrawalFlow.start() - navigator.popUntil { it is WithdrawalEntryScreen } + navigator.popUntil { it is AppRoute.Transfers.Withdrawal.Amount } } ) ) diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt index 028b03461..d21a0f0b6 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/destination/WithdrawalDestinationScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.withdrawal.WithdrawalViewModel import com.flipcash.features.withdrawal.R @@ -54,7 +53,7 @@ internal fun WithdrawalDestinationScreen(viewModel: WithdrawalViewModel) { viewModel.eventFlow .filterIsInstance() .onEach { - navigator.push(ScreenRegistry.get(AppRoute.Transfers.Withdrawal.Confirmation)) + navigator.push(AppRoute.Transfers.Withdrawal.Confirmation) }.launchIn(this) } } diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/entry/WithdrawalEntryScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/entry/WithdrawalEntryScreen.kt index 92c51cadc..571b1a480 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/entry/WithdrawalEntryScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/entry/WithdrawalEntryScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.core.AppRoute import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.ui.AmountWithKeypad @@ -44,9 +43,7 @@ internal fun WithdrawalEntryScreen(viewModel: WithdrawalViewModel, mint: Mint) { .filterIsInstance() .onEach { navigator.push( - ScreenRegistry.get( - AppRoute.Transfers.Withdrawal.Destination - ) + AppRoute.Transfers.Withdrawal.Destination ) }.launchIn(this) } @@ -83,10 +80,8 @@ private fun WithdrawalEntryScreenContent( isClickable = true, onAmountClicked = { navigator.push( - ScreenRegistry.get( - AppRoute.Main.RegionSelection( - kind = RegionSelectionKind.Entry - ) + AppRoute.Main.RegionSelection( + kind = RegionSelectionKind.Entry ) ) }, diff --git a/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/RegionSelectionScreen.kt b/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/RegionSelectionScreen.kt index a6cd4b773..f1302f2e3 100644 --- a/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/RegionSelectionScreen.kt +++ b/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/RegionSelectionScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.currency -import android.os.Parcelable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -8,52 +7,36 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.hilt.getViewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.currency.internal.CurrencyViewModel import com.flipcash.app.currency.internal.RegionSelectionModalContent import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen import com.getcode.ui.components.AppBarWithTitle -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class RegionSelectionScreen( - private val kind: RegionSelectionKind -) : ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - @IgnoredOnParcel - override val testTag: String = "region_selection_screen" - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_selectRegion), - isInModal = true, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { - navigator.pop() - } - ) +@Composable +fun RegionSelectionScreen(kind: RegionSelectionKind) { + val navigator = LocalCodeNavigator.current + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_selectRegion), + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { + navigator.pop() + } + ) - val viewModel = getViewModel() - RegionSelectionModalContent(viewModel) + val viewModel = hiltViewModel() + RegionSelectionModalContent(viewModel) - LaunchedEffect(viewModel, kind) { - viewModel.dispatchEvent(CurrencyViewModel.Event.OnKindChanged(kind)) - } + LaunchedEffect(viewModel, kind) { + viewModel.dispatchEvent(CurrencyViewModel.Event.OnKindChanged(kind)) } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt b/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt index 424dee9c5..b11804702 100644 --- a/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt +++ b/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt @@ -115,7 +115,8 @@ internal fun ListRowItem( ) { Text( text = item.currency.name, - style = CodeTheme.typography.textMedium + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, ) } } diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletLocal.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletLocal.kt index 597afc46e..35f4adf29 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletLocal.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletLocal.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import cafe.adriel.voyager.navigator.currentOrThrow import com.flipcash.app.core.LocalUserManager import com.flipcash.app.onramp.internal.ExternalWalletDeeplinkState import com.getcode.opencode.compose.LocalTransactionController @@ -14,8 +13,8 @@ import com.getcode.solana.rpc.RpcConfig fun rememberExternalWalletState( rpcConfig: RpcConfig ): ExternalWalletDeeplinkState { - val userManager = LocalUserManager.currentOrThrow - val transctionController = LocalTransactionController.currentOrThrow + val userManager = LocalUserManager.current!! + val transctionController = LocalTransactionController.current!! val scope = rememberCoroutineScope() return remember(userManager, scope, rpcConfig) { ExternalWalletDeeplinkState( @@ -29,4 +28,4 @@ fun rememberExternalWalletState( } val LocalExternalWalletState = - compositionLocalOf { throw IllegalStateException() } \ No newline at end of file + compositionLocalOf { throw IllegalStateException() } diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt index ec17e1e9d..05c33c813 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute @@ -35,7 +34,7 @@ import com.getcode.libs.analytics.LocalAnalytics import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.CodeNavigator -import com.getcode.ui.utils.RepeatOnLifecycle +import com.getcode.navigation.utils.lifecycle.RepeatOnLifecycle import com.getcode.util.permissions.LocalPermissionChecker import com.getcode.util.permissions.notificationPermissionCheck import com.getcode.utils.TraceType @@ -70,12 +69,10 @@ fun ExternalWalletOnRampHandler( return } - state.origin?.let { screenProvider -> - val screen = ScreenRegistry.get(screenProvider) + state.origin?.let { route -> composeScope.launch { delay(300) - val popped = navigator.popUntil { it::class == screen::class } - if (!popped) navigator.popAll() + navigator.popUntil { it::class == route::class } } } ?: run { navigator.popAll() } } @@ -178,11 +175,9 @@ fun ExternalWalletOnRampHandler( if (origin is AppRoute.Token.Info) { preNavigatedToEntry = true navigator.push( - ScreenRegistry.get( - AppRoute.Token.SwapTransact( - TokenSwapPurpose.FundWithWallet(origin.mint), - forNeededFunds = origin.forNeededFunds - ) + AppRoute.Token.SwapTransact( + TokenSwapPurpose.FundWithWallet(origin.mint), + forNeededFunds = origin.forNeededFunds ) ) } @@ -223,16 +218,14 @@ fun ExternalWalletOnRampHandler( when (val origin = state.origin) { is AppRoute.Token.Info -> { navigator.push( - ScreenRegistry.get( - AppRoute.Token.SwapTransact( - TokenSwapPurpose.FundWithWallet(origin.mint), - forNeededFunds = origin.forNeededFunds - ) + AppRoute.Token.SwapTransact( + TokenSwapPurpose.FundWithWallet(origin.mint), + forNeededFunds = origin.forNeededFunds ) ) } else -> { - navigator.push(ScreenRegistry.get(AppRoute.OnRamp.AmountEntry)) + navigator.push(AppRoute.OnRamp.AmountEntry) } } } @@ -256,9 +249,7 @@ fun ExternalWalletOnRampHandler( if (state.origin is AppRoute.Token.Info && swapId != null) { preNavigatedToProcessing = true navigator.push( - ScreenRegistry.get( - AppRoute.Token.TxProcessing(swapId, awaitExternalWallet = true) - ) + AppRoute.Token.TxProcessing(swapId, awaitExternalWallet = true) ) } @@ -302,7 +293,7 @@ fun ExternalWalletOnRampHandler( if (swapId != null) { // confirmation is shown in finalization screen - navigator.push(ScreenRegistry.get(AppRoute.Token.TxProcessing(swapId))) + navigator.push(AppRoute.Token.TxProcessing(swapId)) } else { val hasPushPerms = permissions.isGranted(Manifest.permission.POST_NOTIFICATIONS) val title = state.tokenToPurchase?.let { token -> @@ -375,4 +366,4 @@ private fun DeeplinkOnRampError.messaging(context: Context, provider: String): P DeeplinkError.InternalError -> context.getString(R.string.error_title_deeplinkOnRampInternalError) to context.getString(R.string.error_description_deeplinkOnRampInternalError).format(provider) DeeplinkError.Unknown -> context.getString(R.string.error_title_deeplinkOnRampUnknown) to context.getString(R.string.error_description_deeplinkOnRampUnknown) } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/CameraPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/CameraPermissionScreen.kt index e9bad791c..f3edab690 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/CameraPermissionScreen.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/CameraPermissionScreen.kt @@ -1,19 +1,13 @@ package com.flipcash.app.permissions import androidx.compose.runtime.Composable -import cafe.adriel.voyager.core.screen.Screen import com.flipcash.app.permissions.internal.Permission import com.flipcash.app.permissions.internal.PermissionScreenContent -import com.getcode.navigation.screens.AppScreen -class CameraPermissionScreen(private val fromOnboarding: Boolean = false): AppScreen { - override val testTag: String = "camera_permission_screen" - - @Composable - override fun ScreenContent() { - PermissionScreenContent( - permission = Permission.Camera, - postCreate = fromOnboarding, - ) - } -} \ No newline at end of file +@Composable +fun CameraPermissionScreen(fromOnboarding: Boolean = false) { + PermissionScreenContent( + permission = Permission.Camera, + postCreate = fromOnboarding, + ) +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt index fc906df4d..2a0561783 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt @@ -1,20 +1,13 @@ package com.flipcash.app.permissions import androidx.compose.runtime.Composable -import cafe.adriel.voyager.core.screen.Screen import com.flipcash.app.permissions.internal.Permission import com.flipcash.app.permissions.internal.PermissionScreenContent -import com.getcode.navigation.screens.AppScreen -class NotificationPermissionScreen(private val fromOnboarding: Boolean = false): AppScreen { - - override val testTag: String = "notification_permission_screen" - - @Composable - override fun ScreenContent() { - PermissionScreenContent( - permission = Permission.Notifications, - postCreate = fromOnboarding, - ) - } -} \ No newline at end of file +@Composable +fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { + PermissionScreenContent( + permission = Permission.Notifications, + postCreate = fromOnboarding, + ) +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt index febe2f2c5..13df5ffc6 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import cafe.adriel.voyager.core.registry.ScreenRegistry import com.flipcash.app.analytics.Action import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.rememberAnalytics @@ -61,21 +60,21 @@ internal fun PermissionScreenContent( if (postCreate) { analytics.action(Action.CompletedOnboarding) } - navigator.replaceAll(ScreenRegistry.get(AppRoute.Main.Scanner())) + navigator.replaceAll(AppRoute.Main.Scanner()) }, onNotGranted = { - navigator.replaceAll(ScreenRegistry.get(AppRoute.Main.Scanner())) + navigator.replaceAll(AppRoute.Main.Scanner()) } ) Permission.Notifications -> NotificationScreenContent { if (permissionChecker.isDenied(Manifest.permission.CAMERA)) { - navigator.push(ScreenRegistry.get(AppRoute.Onboarding.CameraPermission(postCreate))) + navigator.push(AppRoute.Onboarding.CameraPermission(postCreate)) } else { if (postCreate) { analytics.action(Action.CompletedOnboarding) } - navigator.replaceAll(ScreenRegistry.get(AppRoute.Main.Scanner())) + navigator.replaceAll(AppRoute.Main.Scanner()) } } } @@ -250,4 +249,4 @@ private fun PreviewNotificationPermissionScreen() { FlipcashPreview(showBackground = true) { NotificationScreenContent { } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt index 455ff3c8a..11e606b19 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt @@ -1,13 +1,13 @@ package com.flipcash.app.router import androidx.compose.runtime.staticCompositionLocalOf -import cafe.adriel.voyager.core.screen.Screen +import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.navigation.DeeplinkType import dev.theolm.rinku.DeepLink interface Router { - suspend fun processDestination(deeplink: DeepLink?): List + suspend fun processDestination(deeplink: DeepLink?): List fun processType(deeplink: DeepLink?): DeeplinkType? } -val LocalRouter = staticCompositionLocalOf { null } \ No newline at end of file +val LocalRouter = staticCompositionLocalOf { null } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index e6c7242c6..eb1f91732 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -1,8 +1,7 @@ package com.flipcash.app.router.internal import androidx.core.net.toUri -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.Screen +import androidx.navigation3.runtime.NavKey import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkType @@ -41,54 +40,54 @@ internal class AppRouter( val token = listOf("token") } - override suspend fun processDestination(deeplink: DeepLink?): List { + override suspend fun processDestination(deeplink: DeepLink?): List { return deeplink?.let { val type = processType(deeplink) ?: return emptyList() when (type) { is DeeplinkType.Login -> { if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner(type))) + listOf(AppRoute.Main.Scanner(type)) } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login(type.entropy, true))) + listOf(AppRoute.Onboarding.Login(type.entropy, true)) } } is DeeplinkType.CashLink -> { if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner(type))) + listOf(AppRoute.Main.Scanner(type)) } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) + listOf(AppRoute.Onboarding.Login()) } } is DeeplinkType.ExternalWalletConnection -> { if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner())) + listOf(AppRoute.Main.Scanner()) } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) + listOf(AppRoute.Onboarding.Login()) } } is DeeplinkType.ExternalWalletSignedTransaction -> { if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner())) + listOf(AppRoute.Main.Scanner()) } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) + listOf(AppRoute.Onboarding.Login()) } } is DeeplinkType.EmailVerification -> { if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner(type))) + listOf(AppRoute.Main.Scanner(type)) } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) + listOf(AppRoute.Onboarding.Login()) } } is DeeplinkType.TokenInfo -> { if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner(type))) + listOf(AppRoute.Main.Scanner(type)) } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) + listOf(AppRoute.Onboarding.Login()) } } } @@ -225,4 +224,4 @@ private fun DeepLink.handleEmailVerification(): DeeplinkType.EmailVerification? } return null -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/transfers/src/main/kotlin/com/flipcash/app/transfers/TransferInformationalScreen.kt b/apps/flipcash/shared/transfers/src/main/kotlin/com/flipcash/app/transfers/TransferInformationalScreen.kt index 5f5ff0289..40cf65ebf 100644 --- a/apps/flipcash/shared/transfers/src/main/kotlin/com/flipcash/app/transfers/TransferInformationalScreen.kt +++ b/apps/flipcash/shared/transfers/src/main/kotlin/com/flipcash/app/transfers/TransferInformationalScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.transfers -import android.os.Parcelable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,122 +15,105 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign -import cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.flipcash.app.core.transfers.TransferDirection import com.flipcash.app.core.ui.BrandedGradientIcon import com.flipcash.shared.transfers.R import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ModalScreen -import com.getcode.navigation.screens.NamedScreen import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class TransferInformationalScreen( - private val direction: TransferDirection -) : ModalScreen, NamedScreen, Parcelable { +@Composable +fun TransferInformationalScreen( + direction: TransferDirection +) { + val navigator = LocalCodeNavigator.current + val uriHandler = LocalUriHandler.current + val context = LocalContext.current - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - override val name: String - @Composable get() = direction.title - - @Composable - override fun ModalContent() { - val navigator = LocalCodeNavigator.current - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - - CodeScaffold( - topBar = { - AppBarWithTitle( - title = name, - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - isInModal = true, - onBackIconClicked = navigator::pop - ) - }, - bottomBar = { - Column( + CodeScaffold( + topBar = { + AppBarWithTitle( + title = direction.title, + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + isInModal = true, + onBackIconClicked = navigator::pop + ) + }, + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4) + ) { + CodeButton( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x2) - .navigationBarsPadding(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4) + .fillMaxWidth(), + buttonState = ButtonState.Filled, + text = direction.continueAction ) { - CodeButton( - modifier = Modifier - .fillMaxWidth(), - buttonState = ButtonState.Filled, - text = direction.continueAction - ) { - navigator.push(ScreenRegistry.get(direction.nextScreen)) - } + navigator.push(direction.nextScreen) + } - CodeButton( - modifier = Modifier - .fillMaxWidth(), - buttonState = ButtonState.Subtle, - text = direction.learnMoreAction - ) { - try { - uriHandler.openUri( - when (direction) { - TransferDirection.Incoming -> context.getString(R.string.external_url_deposit) - TransferDirection.Outgoing -> context.getString(R.string.external_url_withdraw) - } - ) - } catch (_: Exception) { - BottomBarManager.showError( - title = context.getString(R.string.error_title_failedToOpenExternalLink), - message = context.getString(R.string.error_description_failedToOpenExternalLink) - ) - } + CodeButton( + modifier = Modifier + .fillMaxWidth(), + buttonState = ButtonState.Subtle, + text = direction.learnMoreAction + ) { + try { + uriHandler.openUri( + when (direction) { + TransferDirection.Incoming -> context.getString(R.string.external_url_deposit) + TransferDirection.Outgoing -> context.getString(R.string.external_url_withdraw) + } + ) + } catch (_: Exception) { + BottomBarManager.showError( + title = context.getString(R.string.error_title_failedToOpenExternalLink), + message = context.getString(R.string.error_description_failedToOpenExternalLink) + ) } } } - ) { padding -> - Box( + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column( modifier = Modifier - .fillMaxSize() - .padding(padding) + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .align(Alignment.Center), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - horizontalAlignment = Alignment.CenterHorizontally - ) { - BrandedGradientIcon( - painter = when (direction) { - TransferDirection.Incoming -> painterResource(R.drawable.ic_transfer_deposit) - TransferDirection.Outgoing -> painterResource(R.drawable.ic_transfer_withdraw) - } - ) + BrandedGradientIcon( + painter = when (direction) { + TransferDirection.Incoming -> painterResource(R.drawable.ic_transfer_deposit) + TransferDirection.Outgoing -> painterResource(R.drawable.ic_transfer_withdraw) + } + ) - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10), - text = direction.description, - style = CodeTheme.typography.textMedium, - textAlign = TextAlign.Center, - color = CodeTheme.colors.textMain - ) - } + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10), + text = direction.description, + style = CodeTheme.typography.textMedium, + textAlign = TextAlign.Center, + color = CodeTheme.colors.textMain + ) } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/web/src/main/kotlin/com/flipcash/app/web/WebViewScreen.kt b/apps/flipcash/shared/web/src/main/kotlin/com/flipcash/app/web/WebViewScreen.kt index e4d7a7760..aed95ade7 100644 --- a/apps/flipcash/shared/web/src/main/kotlin/com/flipcash/app/web/WebViewScreen.kt +++ b/apps/flipcash/shared/web/src/main/kotlin/com/flipcash/app/web/WebViewScreen.kt @@ -1,22 +1,11 @@ package com.flipcash.app.web -import android.os.Parcelable import androidx.compose.runtime.Composable -import com.getcode.navigation.screens.ModalScreen import com.kevinnzou.web.WebView import com.kevinnzou.web.rememberWebViewState -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class WebViewScreen(val url: String): ModalScreen, Parcelable { - - @IgnoredOnParcel - override val testTag: String = "webview_screen" - - @Composable - override fun ModalContent() { - val state = rememberWebViewState(url = url) - WebView(state = state) - } -} \ No newline at end of file +@Composable +fun WebViewScreen(url: String) { + val state = rememberWebViewState(url = url) + WebView(state = state) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6b3d17ec..d887c5770 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,8 @@ androidx-core = "1.17.0" androidx-datastore = "1.2.0" androidx-lifecycle = "2.10.0" androidx-navigation = "2.9.7" +navigation3 = "1.0.1" +navigation3-lifecycle = "2.10.0" androidx-browser = "1.9.0" androidx-paging = "3.4.1" androidx-room = "2.8.4" @@ -112,6 +114,9 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } androidx-navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "navigation3-lifecycle" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } androidx-paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } @@ -138,9 +143,11 @@ hilt-worker = { module = "androidx.hilt:hilt-work", version.ref = "hilt-jetpack" hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-jetpack" } hilt-android-test = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-nav-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-jetpack" } # Kotlin kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "kotlinx-coroutines" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.3.6" } diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt new file mode 100644 index 000000000..cca7ba75e --- /dev/null +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/utils/serializer/ByteListSerializer.kt @@ -0,0 +1,21 @@ +package com.getcode.utils.serializer + +import android.util.Base64 +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object ByteListAsBase64Serializer : KSerializer> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteList", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: List) { + encoder.encodeString(Base64.encodeToString(value.toByteArray(), Base64.NO_WRAP)) + } + + override fun deserialize(decoder: Decoder): List { + return Base64.decode(decoder.decodeString(), Base64.NO_WRAP).toList() + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/SwapId.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/SwapId.kt index 81f6edba4..0177547be 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/SwapId.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/SwapId.kt @@ -3,11 +3,14 @@ package com.getcode.opencode.internal.solana.model import android.os.Parcelable import com.getcode.opencode.utils.generate import com.getcode.solana.keys.PublicKey +import com.getcode.utils.serializer.ByteListAsBase64Serializer import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable @JvmInline @Parcelize -value class SwapId(val value: List): Parcelable { +value class SwapId(@Serializable(with = ByteListAsBase64Serializer::class) val value: List): Parcelable { init { require(value.size == 32) { "SwapId must be exactly 32 bytes" } diff --git a/ui/analytics/src/main/kotlin/com/getcode/ui/analytics/AnalyticsScreenWatcher.kt b/ui/analytics/src/main/kotlin/com/getcode/ui/analytics/AnalyticsScreenWatcher.kt index 814867f74..7a2f286d0 100644 --- a/ui/analytics/src/main/kotlin/com/getcode/ui/analytics/AnalyticsScreenWatcher.kt +++ b/ui/analytics/src/main/kotlin/com/getcode/ui/analytics/AnalyticsScreenWatcher.kt @@ -3,21 +3,22 @@ package com.getcode.ui.analytics import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.LifecycleOwner -import cafe.adriel.voyager.core.screen.Screen +import androidx.navigation3.runtime.NavKey import com.getcode.libs.analytics.AppAction import com.getcode.navigation.core.LocalCodeNavigator @Composable -fun Screen.AnalyticsScreenWatcher( +fun AnalyticsScreenWatcher( + route: NavKey, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, action: AppAction, ) { val navigator = LocalCodeNavigator.current val lastItem = navigator.lastItem - if (lastItem?.key == key) { + if (lastItem == route) { AnalyticsWatcher( lifecycleOwner = lifecycleOwner, onEvent = { analytics, _ -> analytics.action(action) } ) } -} \ No newline at end of file +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/emojis/EmojiModal.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/emojis/EmojiModal.kt index b663bf0e9..3bff532ef 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/emojis/EmojiModal.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/emojis/EmojiModal.kt @@ -1,6 +1,5 @@ package com.getcode.ui.components.emojis -import android.os.Parcelable import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -22,10 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.getcode.libs.emojis.generated.Emojis -import com.getcode.navigation.screens.ModalScreen import com.getcode.theme.CodeTheme import com.getcode.theme.inputColors import com.getcode.ui.components.R @@ -34,52 +30,42 @@ import com.getcode.ui.core.unboundedClickable import com.getcode.ui.emojis.EmojiGarden import com.getcode.ui.emojis.EmojiSearchResults import com.getcode.ui.emojis.fuzzySearch -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -@Parcelize -class EmojiModal(private val onSelected: (String) -> Unit) : ModalScreen, Parcelable { - - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - @IgnoredOnParcel - override val testTag: String = "emoji_screen" - - @Composable - override fun ModalContent() { +@Composable +fun EmojiModalContent(onSelected: (String) -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth(), + ) { Column( - modifier = Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - val textState = rememberTextFieldState() - SearchBar(textState) + val textState = rememberTextFieldState() + SearchBar(textState) - val emojis = remember { Emojis.categorizedNoSkinTones } - val allEmojis = remember(emojis) { - emojis.mapValues { it.value.values.toList().flatten() }.values.toList().flatten() - } + val emojis = remember { Emojis.categorizedNoSkinTones } + val allEmojis = remember(emojis) { + emojis.mapValues { it.value.values.toList().flatten() }.values.toList().flatten() + } - val searchResults by remember(allEmojis, textState.text) { - derivedStateOf { - if (textState.text.isEmpty()) return@derivedStateOf null - allEmojis.fuzzySearch(textState.text.toString()) - } + val searchResults by remember(allEmojis, textState.text) { + derivedStateOf { + if (textState.text.isEmpty()) return@derivedStateOf null + allEmojis.fuzzySearch(textState.text.toString()) } + } - Crossfade(searchResults != null) { searching -> - if (searching) { - EmojiSearchResults(searchResults) { onSelected(it) } - } else { - EmojiGarden(onClick = onSelected) - } + Crossfade(searchResults != null) { searching -> + if (searching) { + EmojiSearchResults(searchResults) { onSelected(it) } + } else { + EmojiGarden(onClick = onSelected) } } } } } + @Composable private fun SearchBar( state: TextFieldState, @@ -121,4 +107,4 @@ private fun SearchBar( } } ) -} \ No newline at end of file +} diff --git a/ui/core/build.gradle.kts b/ui/core/build.gradle.kts index 1e1dd3351..127ea489a 100644 --- a/ui/core/build.gradle.kts +++ b/ui/core/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(libs.compose.animation) implementation(libs.compose.activities) implementation(libs.compose.material) + implementation(libs.kotlinx.serialization.core) api(project(":ui:resources")) api(project(":ui:testing")) implementation(project(":ui:theme")) diff --git a/ui/core/src/main/kotlin/com/getcode/ui/core/RestrictionType.kt b/ui/core/src/main/kotlin/com/getcode/ui/core/RestrictionType.kt index 46874f7ce..446db3fb0 100644 --- a/ui/core/src/main/kotlin/com/getcode/ui/core/RestrictionType.kt +++ b/ui/core/src/main/kotlin/com/getcode/ui/core/RestrictionType.kt @@ -1,5 +1,8 @@ package com.getcode.ui.core +import kotlinx.serialization.Serializable + +@Serializable enum class RestrictionType { ACCESS_EXPIRED, FORCE_UPGRADE, diff --git a/ui/navigation/build.gradle.kts b/ui/navigation/build.gradle.kts index 6d3d3a72a..f9e454360 100644 --- a/ui/navigation/build.gradle.kts +++ b/ui/navigation/build.gradle.kts @@ -1,29 +1,35 @@ plugins { alias(libs.plugins.flipcash.android.library.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.hilt) id("org.jetbrains.kotlin.plugin.parcelize") } android { namespace = "${Gradle.codeNamespace}.navigation" + buildFeatures { buildConfig = true } } dependencies { implementation(project(":libs:logging")) implementation(project(":ui:core")) + api(project(":ui:resources")) implementation(project(":ui:theme")) - implementation(libs.androidx.annotation) - api(libs.kotlin.stdlib) + api(libs.rxjava) - api(libs.rxandroid) - implementation(libs.compose.material) + + implementation(libs.compose.material3) implementation(libs.compose.activities) - implementation(libs.androidx.lifecycle.runtime) - implementation(libs.androidx.lifecycle.viewmodel) - implementation(libs.androidx.navigation.fragment) - api(libs.voyager.navigator) - api(libs.voyager.transitions) - api(libs.voyager.bottomsheet) - api(libs.voyager.tabs) - api(libs.voyager.hilt) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlin.reflect) + + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + + api(libs.navigation3.runtime) + api(libs.navigation3.ui) + api(libs.lifecycle.viewmodel.navigation3) + api(libs.hilt.nav.compose) api(libs.rinku) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt new file mode 100644 index 000000000..e1ecedd80 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt @@ -0,0 +1,102 @@ +package com.getcode.navigation + +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SinglePaneSceneStrategy +import androidx.navigation3.ui.NavDisplay +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.decorators.rememberNavResultScopeEntryDecorator +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.results.rememberNavResultStateRegistry +import com.getcode.theme.CodeTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppNavHost( + navigator: CodeNavigator, + resultStateRegistry: NavResultStateRegistry = rememberNavResultStateRegistry(), + sceneStrategy: SceneStrategy = SinglePaneSceneStrategy(), + transitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = { + fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + }, + popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = transitionSpec, + predictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = { + transitionSpec() + }, + onBack: (() -> Unit)? = null, + entryProvider: (key: NavKey) -> NavEntry, + decorators: List> = emptyList(), +) { + ChangeSystemBarsTheme(CodeTheme.colors.background.luminance() < 0.5f) + + NavDisplay( + backStack = navigator.backStack, + onBack = onBack ?: { + if (navigator.backStack.isNotEmpty()) { + navigator.backStack.removeAt(navigator.backStack.lastIndex) + } + }, + sceneStrategy = sceneStrategy, + transitionSpec = transitionSpec, + popTransitionSpec = popTransitionSpec, + predictivePopTransitionSpec = predictivePopTransitionSpec, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + rememberNavResultScopeEntryDecorator( + backStack = navigator.backStack, + navResultStore = navigator.resultStore, + resultStateRegistry = resultStateRegistry + ) + ) + decorators, + entryProvider = entryProvider, + ) +} + +@Composable +private fun ChangeSystemBarsTheme(useDarkSystemBarIcons: Boolean) { + val barColor: Int = Color.Transparent.toArgb() + val activity = LocalActivity.current as? AppCompatActivity + LaunchedEffect(useDarkSystemBarIcons) { + if (useDarkSystemBarIcons) { + activity?.enableEdgeToEdge( + statusBarStyle = SystemBarStyle.light( + barColor, barColor, + ), + navigationBarStyle = SystemBarStyle.light( + barColor, barColor, + ), + ) + } else { + activity?.enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark( + barColor, + ), + navigationBarStyle = SystemBarStyle.dark( + barColor, + ), + ) + } + } +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavContentKey.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavContentKey.kt new file mode 100644 index 000000000..5776fcd2c --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavContentKey.kt @@ -0,0 +1,9 @@ +package com.getcode.navigation + +import androidx.navigation3.runtime.NavKey + +@JvmInline +value class NavContentKey(val contentKey: String) + +// Helper currently aligned with NavKey.defaultContentKey +fun NavKey.contentKey() = NavContentKey(this.toString()) diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt new file mode 100644 index 000000000..5c3239ec6 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -0,0 +1,43 @@ +package com.getcode.navigation + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.results.NavResultKey +import com.getcode.navigation.results.NavigationRetVal +import kotlin.reflect.KClass +import kotlin.reflect.full.isSuperclassOf + +enum class NavMetadataKeys(val key: String, ) { + IsNonDismissable("non_dismissable"), + IsSheet("sheet"), + IsSolitarySheet("sheet_solitary"), + NavResultKey("navresult_key"), +} + +inline fun EntryProviderScope.annotatedEntry(noinline content: @Composable (T) -> Unit) { + val metadata = T::class.metadata() + return entry(metadata = metadata, content = content) +} + +inline fun T.metadata(): Map { + val retValType = T::class.supertypes.find { it.classifier == NavigationRetVal::class } + val resultClass = retValType?.arguments?.firstOrNull()?.type?.classifier as? KClass<*> + + val metadata = mapOf( + NavMetadataKeys.IsSheet.key to (this is Sheet), + NavMetadataKeys.IsSolitarySheet.key to (SolitarySheet::class.java.isAssignableFrom(T::class.java)), + NavMetadataKeys.IsNonDismissable.key to (NonDismissableRoute::class.java.isAssignableFrom(T::class.java)), + NavMetadataKeys.NavResultKey.key to (if (NavigationRetVal::class.isSuperclassOf(T::class)) { + @Suppress("UNCHECKED_CAST") + (NavResultKey( + T::class as KClass>, + resultClass as KClass + )) + } else { + "" + }) + ) + return metadata +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt new file mode 100644 index 000000000..a37180b17 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt @@ -0,0 +1,7 @@ +package com.getcode.navigation + +import androidx.navigation3.runtime.NavKey + +interface Sheet: NavKey +interface NonDismissableRoute: NavKey +interface SolitarySheet: NavKey diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/BottomSheetNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/BottomSheetNavigator.kt deleted file mode 100644 index 8524cc5f6..000000000 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/BottomSheetNavigator.kt +++ /dev/null @@ -1,354 +0,0 @@ -package com.getcode.navigation.core - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.ZeroCornerSize -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetDefaults -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetState -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.annotation.InternalVoyagerApi -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.core.screen.uniqueScreenKey -import cafe.adriel.voyager.core.stack.SnapshotStateStack -import cafe.adriel.voyager.core.stack.Stack -import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.compositionUniqueId -import com.getcode.theme.Black40 -import com.getcode.theme.CodeTheme -import com.getcode.theme.extraLarge -import com.getcode.ui.utils.LocalSheetGesturesState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import timber.log.Timber - -typealias BottomSheetNavigatorContent = @Composable (bottomSheetNavigator: BottomSheetNavigator) -> Unit - -val LocalBottomSheetNavigator: ProvidableCompositionLocal = - staticCompositionLocalOf { error("BottomSheetNavigator not initialized") } - -@OptIn(InternalVoyagerApi::class) -@ExperimentalMaterialApi -@Composable -fun BottomSheetNavigator( - modifier: Modifier = Modifier, - hideOnBackPress: Boolean = true, - scrimColor: Color = Black40, - sheetShape: Shape = CodeTheme.shapes.extraLarge.copy( - bottomStart = ZeroCornerSize, bottomEnd = ZeroCornerSize - ), - sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, - sheetBackgroundColor: Color = CodeTheme.colors.surface, - sheetContentColor: Color = CodeTheme.colors.onSurface, - sheetGesturesEnabled: Boolean = true, - skipHalfExpanded: Boolean = true, - animationSpec: AnimationSpec = tween(), - key: String = compositionUniqueId(), - sheetContent: BottomSheetNavigatorContent = { CurrentScreen() }, - onHide: () -> Unit = { }, - content: BottomSheetNavigatorContent -) { - Navigator(HiddenBottomSheetScreen, onBackPressed = null, key = key) { navigator -> - var hideBottomSheet: (() -> Unit)? = null - val coroutineScope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - confirmValueChange = { state -> - if (state == ModalBottomSheetValue.Hidden) { - hideBottomSheet?.invoke() - } - true - }, - skipHalfExpanded = skipHalfExpanded, - animationSpec = animationSpec - ) - - val bottomSheetNavigator = remember(navigator, sheetState, coroutineScope) { - BottomSheetNavigator(navigator, sheetState, coroutineScope) - } - - hideBottomSheet = { - bottomSheetNavigator.hide() - coroutineScope.launch { - delay(1_000) - onHide() - } - } - - var gesturesEnabled by rememberSaveable(sheetGesturesEnabled) { - mutableStateOf(sheetGesturesEnabled) - } - - CompositionLocalProvider( - LocalBottomSheetNavigator provides bottomSheetNavigator, - LocalSheetGesturesState provides { gesturesEnabled = it }, - ) { - ModalBottomSheetLayout( - modifier = modifier, - scrimColor = scrimColor, - sheetState = sheetState, - sheetShape = sheetShape, - sheetElevation = sheetElevation, - sheetBackgroundColor = sheetBackgroundColor, - sheetContentColor = sheetContentColor, - sheetGesturesEnabled = gesturesEnabled, - sheetContent = { - BottomSheetNavigatorBackHandler( - bottomSheetNavigator, - sheetState, - hideOnBackPress - ) - sheetContent(bottomSheetNavigator) - }, - content = { - content(bottomSheetNavigator) - } - ) - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -class BottomSheetNavigator @InternalVoyagerApi constructor( - internal val navigator: Navigator, - private val sheetState: ModalBottomSheetState, - private val coroutineScope: CoroutineScope -) : Stack by navigator { - - private var _isSheetActive by mutableStateOf(false) - val isSheetActive: Boolean get() = _isSheetActive - - val isVisible: Boolean - get() = sheetState.isVisible - - val sheetStacks = SheetStacks(LinkedHashMap()) - - val progress: Float - get() { - val currentState = sheetState.currentValue - val targetState = sheetState.targetValue - if (currentState == ModalBottomSheetValue.Hidden && currentState == targetState) return 0f - return when (targetState) { - ModalBottomSheetValue.Hidden -> 1f - sheetState.progress - ModalBottomSheetValue.Expanded -> sheetState.progress - ModalBottomSheetValue.HalfExpanded -> 0f - }.coerceIn(0f, 1f) - } - - fun show(screen: Screen) { - coroutineScope.launch { - if (sheetStacks.isEmpty) { - replaceAll(screen) - // setup stack - sheetStacks.push(screen) - _isSheetActive = true - sheetState.show() - } else { - hideAndShow(screen) - } - } - } - - fun show(screens: List) { - coroutineScope.launch { - if (sheetStacks.isEmpty) { - replaceAll(screens) - // setup stack - val firstScreen = items.first() - val remainingScreens = items.drop(1) - sheetStacks.push(firstScreen to remainingScreens) - _isSheetActive = true - sheetState.show() - } else { - hideAndShow(screens) - } - } - } - - private suspend fun hideAndShow(screen: Screen) { - if (isVisible) { - sheetStacks.pop() - // animate sheet out - sheetState.hide() - _isSheetActive = false - // replacing w/ dummy sheet - replaceAll(HiddenBottomSheetScreen) - // push new stack - sheetStacks.push(screen) - // show new sheet - replaceAll(screen) - _isSheetActive = true - sheetState.show() - } else { - Timber.e("shouldn't get here; but ensuring a sheet is shown when requested.") - sheetStacks.popAll() - show(screen) - } - } - - private suspend fun hideAndShow(screens: List) { - if (isVisible) { - sheetStacks.pop() - // animate sheet out - sheetState.hide() - _isSheetActive = false - // replacing w/ dummy sheet - replaceAll(HiddenBottomSheetScreen) - // push new stack - val firstScreen = items.first() - val remainingScreens = items.drop(1) - sheetStacks.push(firstScreen to remainingScreens) - // show new sheet - replaceAll(screens) - _isSheetActive = true - sheetState.show() - } else { - Timber.e("shouldn't get here; but ensuring a sheet is shown when requested.") - sheetStacks.popAll() - show(screens) - } - } - - - fun hide() { - coroutineScope.launch { - if (isVisible) { - sheetStacks.pop() - sheetState.hide() - _isSheetActive = false - replaceAll(HiddenBottomSheetScreen) - showPreviousSheet() - } else if (sheetState.targetValue == ModalBottomSheetValue.Hidden) { - // Swipe down - sheetState is already hidden here so `isVisible` is false - replaceAll(HiddenBottomSheetScreen) - _isSheetActive = false - } - } - } - - override fun push(item: Screen) { - if (isVisible) { - sheetStacks.pushToLastStack(item) - } - navigator.push(item) - } - - override fun pop(): Boolean { - if (isVisible) { - sheetStacks.popFromLastStack() - } - return navigator.pop() - } - - private suspend fun showPreviousSheet(): Boolean { - if (!sheetStacks.isEmpty) { - val screens = sheetStacks.lastItemOrNull?.second.orEmpty() - if (screens.isNotEmpty()) { - replaceAll(screens) - sheetState.show() - return true - } - } - - return false - } - - - @Composable - fun saveableState( - key: String, - screen: Screen? = lastItemOrNull, - content: @Composable () -> Unit - ) { - val lastScreen by remember(screen) { - derivedStateOf { - screen ?: error("Navigator has no screen") - } - } - - navigator.saveableState(key, screen = lastScreen, content = content) - } -} - -object HiddenBottomSheetScreen : Screen { - override val key: ScreenKey = uniqueScreenKey - private fun readResolve(): Any = this - - @Composable - override fun Content() { - Spacer(modifier = Modifier.height(1.dp)) - } -} - -@ExperimentalMaterialApi -@Composable -fun BottomSheetNavigatorBackHandler( - navigator: BottomSheetNavigator, - sheetState: ModalBottomSheetState, - hideOnBackPress: Boolean -) { - BackHandler(enabled = sheetState.isVisible) { - if (navigator.pop().not() && hideOnBackPress) { - navigator.hide() - } - } -} - -class SheetStacks( - map: LinkedHashMap> -) : Stack>> by map.toMutableStateStack() { - - fun pushTo(stackRoot: Screen, screen: Screen) { - val stack = items.firstOrNull { it.first == stackRoot } ?: return - replace(stackRoot to stack.second + screen) - } - - fun popFrom(stackRoot: Screen, screen: Screen) { - val stack = items.firstOrNull { it.first == stackRoot } ?: return - replace(stackRoot to stack.second - screen) - } - - infix fun pushToLastStack(screen: Screen) { - val stack = lastItemOrNull ?: return - pushTo(stack.first, screen) - } - - infix fun push(screen: Screen) { - push(screen to listOf(screen)) - } - - fun popFromLastStack() { - val stack = lastItemOrNull ?: return - val screen = stack.second.lastOrNull() ?: return - popFrom(stack.first, screen) - } -} - -private fun LinkedHashMap.toMutableStateStack( - minSize: Int = 0 -): SnapshotStateStack> = - SnapshotStateStack(this.toList(), minSize) \ No newline at end of file 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 8a10c9f07..5b7291750 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 @@ -1,122 +1,205 @@ package com.getcode.navigation.core -import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.staticCompositionLocalOf -import cafe.adriel.voyager.core.lifecycle.JavaSerializable -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.StackEvent -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.tab.TabNavigator -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.results.NavResultStore +import com.getcode.navigation.results.NavResultStoreImpl +import com.getcode.navigation.results.NavigationRetVal +import com.getcode.navigation.results.SavedStateHandleNavResultController +import com.getcode.navigation.results.rememberNavResultStore +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import timber.log.Timber +import kotlin.reflect.KClass val LocalCodeNavigator: ProvidableCompositionLocal = - staticCompositionLocalOf { CodeNavigatorStub } - -object NavigatorStub: NavigationLocator { - override val lastItem: Screen? = null - override fun push(item: Screen, delay: Long) { } - override infix fun push(items: List) { } - - override fun pop(): Boolean { return false } - override fun popAll() { } + staticCompositionLocalOf { EmptyCodeNavigator } + +val EmptyCodeNavigator = CodeNavigator( + backStack = NavBackStack(), + resultStore = NavResultStoreImpl(SavedStateHandleNavResultController { null }), + onRootReached = {}, +) + +@Composable +fun rememberCodeNavigator( + backStack: NavBackStack, + resultStateRegistry: NavResultStateRegistry, + onRootReached: () -> Unit, +): CodeNavigator { + val navResultStore = rememberNavResultStore(resultStateRegistry = resultStateRegistry) + return remember(navResultStore, onRootReached) { + CodeNavigator(backStack = backStack, resultStore = navResultStore, onRootReached = onRootReached) + } } -object CodeNavigatorStub : CodeNavigator { - override val lastItem: Screen? = null - override val lastModalItem: Screen? = null - override val sheetStackRoot: Screen? = null - override val lastEvent: StackEvent = StackEvent.Idle - override val isVisible: Boolean = false - override val sheetFullyVisible: Boolean = false - override val progress: Float = 0f - - override var screensNavigator: Navigator? = null - override var tabsNavigator: TabNavigator? = null - - override fun show(screen: Screen) = Unit - override fun show(items: List) = Unit - - override fun hide() = Unit - override fun hideWithResult(result: T) = Unit - - override fun push(item: Screen, delay: Long) = Unit - - override fun push(items: List) = Unit - - override fun replace(item: Screen) = Unit - - override fun replaceAll(item: Screen) = Unit +data class CodeNavigator( + val backStack: NavBackStack, + val resultStore: NavResultStore, + val onRootReached: () -> Unit, +) { + val currentRouteKey: NavKey? + get() = backStack.lastOrNull() + + fun navigate( + route: NavKey, + options: NavOptions = NavOptions(), + ) { + if (options.debugRouting) { + trace("Navigating to $route from ${backStack.lastOrNull()} with $options", type = TraceType.Navigation) + } + + if (!options.navigatingForResult && route is NavigationRetVal<*>) { + if (options.debugRouting) { + trace("Navigating to NavigationRetVal without navigatingForResult = true", type = TraceType.Log) + } + } + + when (options.popUpTo) { + NavOptions.PopUpTo.ClearAll -> { + Snapshot.withMutableSnapshot { + if (currentRouteKey != route) { + backStack.add(route) + // Remove all entries before the new one + while (backStack.size > 1) { + backStack.removeAt(0) + } + } + } + } + is NavOptions.PopUpTo.ClearToFirst<*> -> { + Snapshot.withMutableSnapshot { + if (currentRouteKey != route) { + val routeFound = clearToFirst(options.popUpTo.routeClass) + if (routeFound && options.popUpTo.inclusive) { + if (backStack.isNotEmpty()) backStack.removeAt(backStack.lastIndex) + } + backStack.add(route) + } + } + } + NavOptions.PopUpTo.None -> { + Snapshot.withMutableSnapshot { + if (currentRouteKey != route) { + backStack.add(route) + } + } + } + NavOptions.PopUpTo.PopLast -> { + Snapshot.withMutableSnapshot { + if (currentRouteKey != route) { + backStack.add(route) + val lastIndex = backStack.lastIndex - 1 + if (lastIndex >= 0) backStack.removeAt(lastIndex) + } + } + } + } + } - override fun replaceAll(items: List) = Unit + fun restoreRouting(routes: List) { + val list = routes.toMutableList() + val base = list.removeAt(0) + navigate( + route = base, + options = NavOptions( + popUpTo = NavOptions.PopUpTo.ClearAll + ), + ) + list.forEach { + navigate(it) + } + } - override fun isAtRoot(): Boolean = true + fun navigateBack(navigatingForResult: Boolean = false) { + trace("Navigating back from ${backStack.lastOrNull()}", type = TraceType.Navigation) + if (!navigatingForResult && backStack.lastOrNull() is NavigationRetVal<*>) { + trace("Navigating up from a NavigationRetVal route, no result will be set.", type = TraceType.Log) + } + if (backStack.size <= 1) { + onRootReached() + return + } + + backStack.removeAt(backStack.lastIndex) + } - override fun pop(): Boolean = false - override fun popWithResult(result: T) = false + fun clearToFirst(routeClass: KClass): Boolean { + Timber.d("Clearing backstack to first instance of $routeClass") + var routeFound = false + Snapshot.withMutableSnapshot { + val first = backStack.indexOfFirst { it::class == routeClass } + if (first != -1) { + // Remove everything after the first occurrence + while (backStack.size > first + 1) { + backStack.removeAt(backStack.lastIndex) + } + routeFound = true + } + } + return routeFound + } - override fun popAll() = Unit + // Bridge methods — compatible API surface for migrating from Voyager - override fun popUntil(predicate: (Screen) -> Boolean): Boolean = false + /** Push a route onto the back stack (equivalent to Voyager Navigator.push). */ + fun push(route: NavKey) = navigate(route) - @Composable - override fun saveableState( - key: String, - screen: Screen?, - content: @Composable () -> Unit - ) { - content() + /** Push multiple routes onto the back stack. */ + fun push(routes: List) { + routes.forEach { navigate(it) } } -} -interface NavigationLocator { - val lastItem: Screen? - fun push(item: Screen, delay: Long = 0) - infix fun push(items: List) - fun pop(): Boolean - fun popAll() -} + /** Pop the current route (equivalent to Voyager Navigator.pop). */ + fun pop() = navigateBack() -interface CodeNavigator: NavigationLocator { - override val lastItem: Screen? - val lastModalItem: Screen? - val sheetStackRoot: Screen? - val lastEvent: StackEvent - val isVisible: Boolean - val sheetFullyVisible: Boolean - val progress: Float - var screensNavigator: Navigator? - var tabsNavigator: TabNavigator? + /** Replace entire back stack with a single route. */ + fun replaceAll(route: NavKey) = navigate(route, NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll)) - fun show(screen: Screen) - fun show(items: List) - fun hide() - fun hideWithResult(result: T) - override fun push(item: Screen, delay: Long) + /** Replace entire back stack with multiple routes. */ + fun replaceAll(routes: List) = restoreRouting(routes) - override infix fun push(items: List) + /** Show a sheet route (sheets are identified by metadata in Nav3). */ + fun show(route: NavKey) = navigate(route) - infix fun replace(item: Screen) + /** Show multiple sheet routes (pushes each in order). */ + fun show(routes: List) { + routes.forEach { navigate(it) } + } - fun replaceAll(item: Screen) + /** Hide/dismiss a sheet (pops the current route). */ + fun hide() = navigateBack() - fun replaceAll(items: List) + /** Replace the current route with a new one. */ + fun replace(route: NavKey) = navigate(route, NavOptions(popUpTo = NavOptions.PopUpTo.PopLast)) - fun isAtRoot(): Boolean - override fun pop(): Boolean - fun popWithResult(result: T): Boolean + /** Pop back stack until the predicate returns true. */ + fun popUntil(predicate: (NavKey) -> Boolean) { + Snapshot.withMutableSnapshot { + while (backStack.size > 1 && !predicate(backStack.last())) { + backStack.removeAt(backStack.lastIndex) + } + } + } - override fun popAll() + /** Pop all routes except the root. */ + fun popAll() { + Snapshot.withMutableSnapshot { + while (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } + } + } - infix fun popUntil(predicate: (Screen) -> Boolean): Boolean + /** The last item on the back stack (used by features checking current route). */ + val lastItem: NavKey? get() = backStack.lastOrNull() - @SuppressLint("ComposableNaming") - @Composable - fun saveableState( - key: String, - screen: Screen?, - content: @Composable () -> Unit - ) + /** The last item on the back stack or null. */ + val lastItemOrNull: NavKey? get() = backStack.lastOrNull() } \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CombinedNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CombinedNavigator.kt deleted file mode 100644 index e61a05aee..000000000 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/CombinedNavigator.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.getcode.navigation.core - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.StackEvent -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.navigator.tab.TabNavigator -import com.getcode.navigation.screens.ModalScreen -import com.getcode.navigation.screens.ReturnResultScreen -import com.getcode.navigation.screens.ChildNavTab -import com.getcode.utils.TraceType -import com.getcode.utils.trace -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -class CombinedNavigator( - var sheetNavigator: BottomSheetNavigator -) : CodeNavigator, CoroutineScope by CoroutineScope(Dispatchers.Main) { - override var screensNavigator: Navigator? = null - override var tabsNavigator: TabNavigator? = null - - private val isSheetActive: Boolean - get() = sheetNavigator.isSheetActive - - override val lastItem: Screen? - get() = if (isSheetActive) sheetNavigator.lastItemOrNull else screensNavigator?.lastItemOrNull - - override val lastEvent: StackEvent - get() = if (isSheetActive) sheetNavigator.lastEvent else screensNavigator?.lastEvent - ?: StackEvent.Idle - - override val lastModalItem: Screen? - get() = sheetNavigator.lastItemOrNull - - override val sheetStackRoot: Screen? - get() = sheetNavigator.sheetStacks.lastItemOrNull?.first - - override val isVisible: Boolean - get() = sheetNavigator.isVisible - - override val sheetFullyVisible: Boolean - get() = sheetNavigator.isVisible && sheetNavigator.progress == 1f - - override val progress: Float - get() = sheetNavigator.progress - - - override fun show(screen: Screen) { - trace(message = "opening ${screen::class.java.simpleName} in a sheet") - sheetNavigator.show(screen) - } - - override fun show(items: List) { - trace(message = "opening ${items.joinToString { it::class.java.simpleName }} in a sheet") - if (items.isEmpty()) return - sheetNavigator.show(items) - } - - override fun hide() { - trace(message = "closing sheet") - sheetNavigator.hide() - } - - override fun hideWithResult(result: T) { - if (tabsNavigator != null) { - val prev = (tabsNavigator?.current as? ChildNavTab)?.childNav?.lastItem as? ReturnResultScreen - hide() - prev?.onResult(result) - return - } - - with(sheetNavigator) { - var prev = if (size < 2) null else items[items.size - 2] as? ReturnResultScreen - if (prev == null) { - // grab last screen from base - prev = screensNavigator?.let { - with(it) { - items.lastOrNull() as? ReturnResultScreen - } - } - } - prev?.onResult(result) - hide() - } - } - - override fun push(item: Screen, delay: Long) { - trace(message = "navigating to ${item::class.java.simpleName}") - launch { - delay(delay) - if (isSheetActive) { - sheetNavigator.push(item) - } else { - screensNavigator?.push(item) - } - } - } - - override fun push(items: List) { - trace(message = "navigating to ${items.joinToString { it::class.java.simpleName }}") - if (isSheetActive) { - sheetNavigator.push(items) - } else { - screensNavigator?.push(items) - } - } - - override fun replace(item: Screen) { - sheetNavigator.replace(item) - } - - override fun replaceAll(item: Screen) { - replaceAll(listOf(item)) - } - - override fun replaceAll(items: List) { - trace(message = "replacing all in back stack with ${items.joinToString { it::class.java.simpleName }}") - val modalScreens = items.filterIsInstance() - val otherScreens = items.filterNot { it is ModalScreen } - screensNavigator?.replaceAll(otherScreens) - if (modalScreens.isNotEmpty()) { - sheetNavigator.replaceAll(modalScreens) - } - } - - override fun isAtRoot(): Boolean { - return if (isSheetActive) { - sheetNavigator.items.count() == 1 - } else { - screensNavigator?.items?.count() == 1 - } - } - - override fun pop(): Boolean { - trace(message = "popping from back stack") - return if (isSheetActive) { - sheetNavigator.pop() - } else { - screensNavigator?.pop() ?: false - } - } - - override fun popWithResult(result: T): Boolean { - return if (isSheetActive) { - with(sheetNavigator) { - val prev = if (size < 2) null else items[items.size - 2] as? ReturnResultScreen - prev?.onResult(result) - pop() - } - } else { - if (tabsNavigator != null) { - with (tabsNavigator!!) { - val prev = (current as? ChildNavTab)?.childNav?.lastItem as? ReturnResultScreen - prev?.onResult(result) - pop() - } - } else { - screensNavigator?.let { - with(it) { - val prev = if (size < 2) null else items[items.size - 2] as? ReturnResultScreen - prev?.onResult(result) - pop() - } - } ?: false - } - } - } - - override fun popAll() { - trace(message = "popping all from back stack") - if (isSheetActive) { - sheetNavigator.popAll() - } else { - screensNavigator?.popAll() - } - } - - override fun popUntil(predicate: (Screen) -> Boolean): Boolean { - return if (isSheetActive) { - sheetNavigator.popUntil(predicate) - } else { - screensNavigator?.popUntil(predicate) == true - } - } - - @Composable - override fun saveableState( - key: String, - screen: Screen?, - content: @Composable () -> Unit - ) { - if (isSheetActive) { - sheetNavigator.saveableState(key, screen = screen, content = content) - } else { - val lastScreen by remember(screen) { - derivedStateOf { - screen ?: error("Navigator has no screen") - } - } - screensNavigator?.saveableState(key = key, screen = lastScreen, content = content) - } - } - - private fun trace(message: String) { - trace(tag = "Navigator", message = message, type = TraceType.Navigation) - } -} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/core/NavOptions.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/NavOptions.kt new file mode 100644 index 000000000..bf63f4fd6 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/core/NavOptions.kt @@ -0,0 +1,29 @@ +package com.getcode.navigation.core + +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.BuildConfig +import kotlin.reflect.KClass + +/** + * Options for navigating with [CodeNavigator.navigate]. + * + * @param popUpTo If set, pop up to a specific destination before navigating in a transaction. + * @param navigatingForResult A flag to help detect unintended navigation for result scenarios which + * would otherwise result is missed results. + */ +data class NavOptions( + val popUpTo: PopUpTo = PopUpTo.None, + val navigatingForResult: Boolean = false, + val debugRouting: Boolean = BuildConfig.DEBUG, +) { + sealed interface PopUpTo { + data object ClearAll : PopUpTo + data object PopLast : PopUpTo + + data class ClearToFirst( + val routeClass: KClass, + val inclusive: Boolean = false, + ) : PopUpTo + data object None : PopUpTo + } +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/decorators/NavResultScopeEntryDecorator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/decorators/NavResultScopeEntryDecorator.kt new file mode 100644 index 000000000..00b55c8cf --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/decorators/NavResultScopeEntryDecorator.kt @@ -0,0 +1,65 @@ +package com.getcode.navigation.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.NavContentKey +import com.getcode.navigation.results.LocalNavKey +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.results.NavResultStore +import dagger.hilt.android.lifecycle.HiltViewModel +import timber.log.Timber +import javax.inject.Inject + +/** + * Responsible for removing any store associated with a route on cleanup + */ +@Suppress("FunctionName") +fun NavResultScopeEntryDecorator( + backStack: NavBackStack, + navResultStore: NavResultStore, + resultStateRegistry: NavResultStateRegistry, +): NavEntryDecorator { + val onPop: (Any) -> Unit = { contentKey -> + require(contentKey is String) + navResultStore.disposeOwner(NavContentKey(contentKey)) + resultStateRegistry.remove(NavContentKey(contentKey)) + } + + return NavEntryDecorator(onPop = onPop) { entry -> + // Provide and cleanup a persisted local store for nav results not handled locally + val persistedVm = hiltViewModel() + val key = NavContentKey(entry.contentKey as String) + + LaunchedEffect(key, persistedVm.handle) { + Timber.d("ResultStateRegistry created for key=$key") + resultStateRegistry[key] = persistedVm.handle + } + + // Provide the local navkey which is needed widely for result handling + CompositionLocalProvider(LocalNavKey provides backStack.last()) { + entry.Content() + // This calls any stored result callback handlers + navResultStore.ResultCallbackDispatcherEffect(NavContentKey(entry.contentKey as String)) + } + } +} + +@Composable +fun rememberNavResultScopeEntryDecorator( + backStack: NavBackStack, + navResultStore: NavResultStore, + resultStateRegistry: NavResultStateRegistry, +) = remember { NavResultScopeEntryDecorator(backStack, navResultStore, resultStateRegistry) } + +@HiltViewModel +internal class NavEntrySavedStateHandleViewModel @Inject constructor( + val handle: SavedStateHandle, +) : ViewModel() diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt index 81011fce4..73c22be75 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt @@ -1,91 +1,62 @@ package com.getcode.navigation.extensions -import android.content.Context import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.HasDefaultViewModelProviderFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.viewmodel.CreationExtras -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.hilt.VoyagerHiltViewModelFactories -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.utils.getActivity @Composable -inline fun getActivityScopedViewModel(): T { +inline fun getActivityScopedViewModel(): T { val activity = LocalContext.current.getActivity() as ComponentActivity - val defaultFactory = (LocalLifecycleOwner.current as HasDefaultViewModelProviderFactory) - val viewModelStore = LocalContext.current.getActivity()!!.viewModelStore + val viewModelStore = activity.viewModelStore + val defaultFactory = activity as HasDefaultViewModelProviderFactory return remember(key1 = T::class) { - val factory = VoyagerHiltViewModelFactories.getVoyagerFactory( - activity = activity, - delegateFactory = defaultFactory.defaultViewModelProviderFactory - ) - val provider = ViewModelProvider( store = viewModelStore, - factory = factory, + factory = defaultFactory.defaultViewModelProviderFactory, defaultCreationExtras = defaultFactory.defaultViewModelCreationExtras ) provider[T::class.java] } } +/** + * Get a ViewModel scoped to a key (for multi-step flows sharing state). + * Falls back to activity-scoped hiltViewModel when no key is provided. + */ +@Deprecated( + "Use flowScopedViewModel(key) for flow-shared VMs or hiltViewModel() for entry-scoped VMs", + ReplaceWith("flowScopedViewModel(key)", "com.getcode.navigation.extensions.flowScopedViewModel") +) @Composable -inline fun Screen.getStackScopedViewModel(key: String? = null): T { - val _key = key ?: LocalCodeNavigator.current.sheetStackRoot?.key - val activity = LocalContext.current.getActivity() as ComponentActivity - val viewModelStoreOwner = LocalContext.current as ViewModelStoreOwner - - return remember(key1 = _key) { - val factory = viewModelStoreOwner.createVoyagerFactory(activity, null) - viewModelStoreOwner.get(T::class.java, _key, factory) - } -} - -@PublishedApi -internal fun ViewModelStoreOwner.createVoyagerFactory( - context: Context, - viewModelProviderFactory: ViewModelProvider.Factory? = null -): ViewModelProvider.Factory? { - val factory = viewModelProviderFactory - ?: (this as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory - return if (factory != null) { - VoyagerHiltViewModelFactories.getVoyagerFactory( - activity = context.getActivity() as ComponentActivity, - delegateFactory = factory - ) +inline fun getStackScopedViewModel(key: String? = null): T { + return if (key != null) { + val activity = LocalContext.current.getActivity() as ComponentActivity + remember(key1 = key) { + val factory = activity.defaultViewModelProviderFactory + val extras = activity.defaultViewModelCreationExtras + val provider = ViewModelProvider(activity.viewModelStore, factory, extras) + provider[key, T::class.java] + } } else { - null + hiltViewModel() } } -@PublishedApi -internal fun ViewModelStoreOwner.get( - javaClass: Class, - key: String?, - viewModelProviderFactory: ViewModelProvider.Factory? = null, - creationExtras: CreationExtras = if (this is HasDefaultViewModelProviderFactory) { - this.defaultViewModelCreationExtras - } else { - CreationExtras.Empty - } -): T { - val factory = viewModelProviderFactory - ?: (this as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory - val provider = if (factory != null) { - ViewModelProvider(viewModelStore, factory, creationExtras) - } else { - ViewModelProvider(this) - } - return if (key != null) { - provider[key, javaClass] - } else { - provider[javaClass] - } +/** + * Get a Hilt ViewModel shared across multiple screens in a multi-step flow. + * The ViewModel is scoped to the activity's ViewModelStore with the given [key], + * so all screens using the same key get the same instance. + * + * @param key A unique key identifying the flow instance (typically a UUID generated at flow start). + */ +@Composable +inline fun flowScopedViewModel(key: String): T { + val activity = LocalContext.current.getActivity() as ComponentActivity + return hiltViewModel(viewModelStoreOwner = activity, key = key) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResult.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResult.kt new file mode 100644 index 000000000..ee6401cd6 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResult.kt @@ -0,0 +1,33 @@ +package com.getcode.navigation.results + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * NavResultStore value + */ +sealed interface NavResultOrCanceled : Parcelable { + @Parcelize + data class ReturnValue(val value: T) : NavResultOrCanceled + + @Parcelize + data object Canceled : NavResultOrCanceled +} + +/** + * Convenience to get the value or null if canceled + */ +fun NavResultOrCanceled.getOrNull(): T? = + when (this) { + is NavResultOrCanceled.ReturnValue -> value + NavResultOrCanceled.Canceled -> null + } + +/** + * Invoke the block if we have a value (not canceled) + * + * A slight convenience over `getOrNull()?.let { value -> block(value) }`. Worthwhile? + */ +inline fun NavResultOrCanceled.ifValue(block: (T) -> Unit) { + if (this is NavResultOrCanceled.ReturnValue) block(value) +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultController.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultController.kt new file mode 100644 index 000000000..d89ea1e35 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultController.kt @@ -0,0 +1,106 @@ +package com.getcode.navigation.results + +import android.os.Parcelable +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +interface NavResultController { + fun , T : Parcelable> put( + callerEntryId: NavKey, + key: NavResultKey, + value: NavResultOrCanceled, + ) + + fun , T : Parcelable> take( + callerEntryId: NavKey, + key: NavResultKey, + ): NavResultOrCanceled? + + fun , T : Parcelable> clear(callerEntryId: NavKey, key: NavResultKey) + + /** + * Observe changes to persisted results for a specific key + */ + fun , T : Parcelable> observeChanges( + callerEntryId: NavKey, + key: NavResultKey, + ): Flow?> +} + +data class PersistenceChangeEvent( + val callerEntryId: NavKey, + val key: NavResultKey<*, *>, + val value: NavResultOrCanceled<*>?, +) + +class SavedStateHandleNavResultController( + private val savedStateHandleProvider: (callerNavKey: NavKey) -> androidx.lifecycle.SavedStateHandle?, +) : NavResultController { + private val changeEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 10 + ) + + override fun , T : Parcelable> put( + callerEntryId: NavKey, + key: NavResultKey, + value: NavResultOrCanceled, + ) { + // Writing a value twice is an error. Ensure any existing value is removed to allow overwrite. + // Not clear if this was really necessary or if the error about a repeated write to savestate was caused by something else. + if (savedStateHandleProvider(callerEntryId)?.contains(key.name) == true) { + savedStateHandleProvider(callerEntryId)?.remove>(key.toString()) + } + savedStateHandleProvider(callerEntryId)?.set(key.toString(), value) + // Emit change event + changeEvents.tryEmit(PersistenceChangeEvent(callerEntryId, key, value)) + } + + override fun , T : Parcelable> take( + callerEntryId: NavKey, + key: NavResultKey, + ): NavResultOrCanceled? { + val handle = savedStateHandleProvider(callerEntryId) ?: return null + val value = handle.get>(key.name) + // one-shot consumption + if (value != null) { + handle.remove>(key.name) + // Emit removal event + changeEvents.tryEmit(PersistenceChangeEvent(callerEntryId, key, null)) + } + return value + } + + override fun , T : Parcelable> clear( + callerEntryId: NavKey, + key: NavResultKey, + ) { + savedStateHandleProvider(callerEntryId)?.remove(key.name) + // Emit removal event + changeEvents.tryEmit(PersistenceChangeEvent(callerEntryId, key, null)) + } + + @Suppress("UNCHECKED_CAST") + override fun , T : Parcelable> observeChanges( + callerEntryId: NavKey, + key: NavResultKey, + ): Flow?> { + return changeEvents + .filter { it.callerEntryId == callerEntryId && it.key == key } + .map { it.value as? NavResultOrCanceled } + .onStart { + // Emit current value if it exists + val currentValue = + savedStateHandleProvider(callerEntryId)?.get>( + key.name + ) + if (currentValue != null) { + emit(currentValue) + } + } + } +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultKey.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultKey.kt new file mode 100644 index 000000000..fe56fbaa2 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultKey.kt @@ -0,0 +1,24 @@ +package com.getcode.navigation.results + +import android.os.Parcelable +import com.getcode.navigation.utils.KClassSerializer +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +/** + * NavResultStore key + * + * Keys are based on the result generating NavKey type and the return type. + */ +@Serializable +data class NavResultKey, out T : Parcelable>( + @Serializable(with = KClassSerializer::class) + val routeClass: KClass, + @Serializable(with = KClassSerializer::class) + val valueClass: KClass, +) { + val name: String get() = toString() +} + +inline fun , reified T : Parcelable> key(): NavResultKey = + NavResultKey(K::class, T::class) 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 new file mode 100644 index 000000000..8d4ca31ba --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt @@ -0,0 +1,409 @@ +package com.getcode.navigation.results + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SavedStateHandle +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.NavContentKey +import com.getcode.navigation.contentKey +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.core.NavOptions +import com.getcode.navigation.utils.lifecycle.RepeatOnLifecycle +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.collections.isNotEmpty + +interface NavResultStore { + fun , T : Parcelable> registerCallback( + callerEntryId: NavKey, + key: NavResultKey, + onResult: (NavResultOrCanceled) -> Unit, + ) + + fun unregisterCallback( + callerEntryId: NavKey, + key: NavResultKey<*, *>, + ) + + fun , T : Parcelable> deliverOrPersist( + callerEntryId: NavKey, + key: NavResultKey, + value: NavResultOrCanceled, + ): Boolean + + fun , T : Parcelable> takePersisted( + callerEntryId: NavKey, + key: NavResultKey, + ): NavResultOrCanceled? + + /** Parent should disposeOwner() when done to avoid leaks. */ + fun disposeOwner(ownerContentKey: NavContentKey) + + /** + * Observe changes to persisted results for a specific key + */ + fun , T : Parcelable> observePersistedChanges( + callerEntryId: NavKey, + key: NavResultKey, + ): Flow?> + + /** + * Register a persistent listener that survives navigation changes + */ + fun , T : Parcelable> registerPersistentListener( + callerEntryId: NavKey, + key: NavResultKey, + onResult: (NavResultOrCanceled) -> Unit, + ) + + /** + * Unregister a persistent listener + */ + fun unregisterPersistentListener( + callerEntryId: NavKey, + key: NavResultKey<*, *>, + ) + + @Composable + fun ResultCallbackDispatcherEffect(callerEntryId: NavContentKey) +} + +typealias NavResultStateRegistry = SnapshotStateMap + +@Composable +fun rememberNavResultStateRegistry(): NavResultStateRegistry { + return remember { mutableStateMapOf() } +} + +@Composable +fun rememberNavResultStore(resultStateRegistry: NavResultStateRegistry): NavResultStore = remember { + NavResultStoreImpl( + controller = SavedStateHandleNavResultController( + savedStateHandleProvider = { navKey: NavKey -> + resultStateRegistry[navKey.contentKey()] + } + ) + ) +} + +internal class NavResultStoreImpl( + // An optional method of persisting a result + private val controller: NavResultController, +) : NavResultStore { + + // This is a registry of all callbacks registered for a given NavKey owner + private val idsByOwner = + mutableMapOf>>>() + + /** + * This is registry of callbacks registered for results + * @param Pair> - the caller's NavKey and the specific result key + * @param (Any) -> Unit - the callback to invoke with the result (as NavResultOrCanceled) + */ + private val callbacks = + mutableMapOf>, (Any) -> Unit>() + + // This is a registry of pending handler queues for a given NavKey owner + private val handlerQueues: SnapshotStateMap Unit>> = + SnapshotStateMap() + + private fun makeKey( + callerEntryId: NavKey, + key: NavResultKey<*, *>, + ): Pair> = callerEntryId to key + + + @Suppress("UNCHECKED_CAST") + override fun , T : Parcelable> registerCallback( + callerEntryId: NavKey, + key: NavResultKey, + onResult: (NavResultOrCanceled) -> Unit, + ) { + trace("registerCallback: callerEntryId=$callerEntryId, key=$key", type = TraceType.Silent) + val callbackKey = makeKey(callerEntryId, key) + idsByOwner.getOrPut( + callerEntryId.contentKey(), + defaultValue = { mutableSetOf() }, + ).add(callbackKey) + callbacks.put(callbackKey) { any -> onResult(any as NavResultOrCanceled) } + } + + override fun unregisterCallback( + callerEntryId: NavKey, + key: NavResultKey<*, *>, + ) { + trace("unregisterCallback: callerEntryId=$callerEntryId, key=$key", type = TraceType.Silent) + val callbackKey = makeKey(callerEntryId, key) + idsByOwner[callerEntryId.contentKey()]?.remove(callbackKey) + callbacks.remove(callbackKey) + } + + /** + * Deliver a result intended for the caller entry. + * Returns true if delivered to a callback; false if persisted. + */ + override fun , T : Parcelable> deliverOrPersist( + callerEntryId: NavKey, + key: NavResultKey, + value: NavResultOrCanceled, + ): Boolean { + trace("deliverOrPersist: callerEntryId=$callerEntryId, key=$key, value=$value") + val callbackKey = makeKey(callerEntryId, key) + val cb = callbacks.remove(callbackKey) + return if (cb != null) { + // Avoid invoking the callback immediately/directly here because we don't know if the + // owner is running. The callback behavior will be lost if it is not currently + // rendering. + // The queue should be added when the listener is started + Snapshot.withMutableSnapshot { + handlerQueues.getOrPut(callerEntryId.contentKey(), { SnapshotStateList() }) + .add { + trace("deliverOrPersist: callerEntryId=$callerEntryId, key=$key, INVOKING CALLBACK XXXXXX", type = TraceType.Silent) + cb(value) + } + } + trace("deliverOrPersist: callerEntryId=$callerEntryId, key=$key, queued callback, queueSize=${handlerQueues[callerEntryId.contentKey()]?.size}", type = TraceType.Silent) + true + } else { + controller.put(callerEntryId, key, value) + false + } + } + + /** + * For process-death recovery: the caller can poll once on resume. + * If a persisted value exists, returns and clears it. + */ + override fun , T : Parcelable> takePersisted( + callerEntryId: NavKey, + key: NavResultKey, + ): NavResultOrCanceled? { + return controller.take(callerEntryId, key) + } + + override fun disposeOwner(ownerContentKey: NavContentKey) { + trace("disposeOwner: owner=$ownerContentKey", type = TraceType.Silent) + Snapshot.withMutableSnapshot { + idsByOwner.remove(ownerContentKey) + ?.forEach { (callerEntryId, key) -> unregisterCallback(callerEntryId, key) } + handlerQueues.remove(ownerContentKey) + } + } + + /** + * Observe changes to persisted results for a specific key + */ + override fun , T : Parcelable> observePersistedChanges( + callerEntryId: NavKey, + key: NavResultKey, + ): Flow?> { + trace("observePersistedChanges: callerEntryId=$callerEntryId, key=$key", type = TraceType.Silent) + return controller.observeChanges(callerEntryId, key) + } + + /** + * Register a persistent listener that survives navigation changes + */ + override fun , T : Parcelable> registerPersistentListener( + callerEntryId: NavKey, + key: NavResultKey, + onResult: (NavResultOrCanceled) -> Unit, + ) { + val callbackKey = makeKey(callerEntryId, key) + idsByOwner.getOrPut( + callerEntryId.contentKey(), + defaultValue = { mutableSetOf() }, + ).add(callbackKey) + callbacks.put(callbackKey) { any -> + @Suppress("UNCHECKED_CAST") + onResult(any as NavResultOrCanceled) + } + } + + /** + * Unregister a persistent listener + */ + override fun unregisterPersistentListener( + callerEntryId: NavKey, + key: NavResultKey<*, *>, + ) { + val callbackKey = makeKey(callerEntryId, key) + idsByOwner[callerEntryId.contentKey()]?.remove(callbackKey) + callbacks.remove(callbackKey) + } + + @Composable + override fun ResultCallbackDispatcherEffect(callerEntryId: NavContentKey) { + RepeatOnLifecycle(targetState = Lifecycle.State.RESUMED) { + while (isActive) { + val queue: SnapshotStateList<() -> Unit>? = handlerQueues[callerEntryId] + if (queue != null && queue.isNotEmpty()) { + trace("ResultCallbackDispatcherEffect: callerEntryId=$callerEntryId, invoking callback, queueSize=${queue.size}", type = TraceType.Silent) + val cb = queue.removeAt(0) + try { + cb() + } catch (e: Exception) { + trace("Error invoking nav result callback for $callerEntryId", error = e, type = TraceType.Error) + } + } else { + delay(500) + } + } + } + } +} + +val LocalNavKey = staticCompositionLocalOf { error("No NavKey") } + +/** + * If a CoroutineScope is provided, launch the work in it; otherwise run it directly. + * This is useful for cases where you might or might not have a lifecycle-aware scope + * available to launch from. + */ +fun CoroutineScope?.launchOrRun(work: () -> Unit) { + this?.launch { work() } ?: work() +} + +/** + * Caller registers for a one-shot result before navigating forward. + * @param coroutineScope Required if onResult will change parent state in a way that might cancel + * its coroutine context. + */ +inline fun CodeNavigator.navigateForResult( + route: NavigationRetVal, + options: NavOptions = NavOptions(), + coroutineScope: CoroutineScope? = null, + noinline onResult: (NavResultOrCanceled) -> Unit, +) { + if (options.debugRouting) { + 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)) + } +} + +// LocalNavKey is volatile for a given composition. When navigating away the component will see +// the key change. It is assumed this is during the animation phase. +@Composable +inline fun resultBackNavigator( + @Suppress("UNCHECKED_CAST") route: NavigationRetVal? = LocalNavKey.current as? NavigationRetVal, + navigator: CodeNavigator = LocalCodeNavigator.current, + navResultStore: NavResultStore = LocalCodeNavigator.current.resultStore, +): IResultBackNavigator = route?.let { + ResultBackNavigator( + navResultKey = route.asKey(), + navigator = navigator, + navResultStore = navResultStore, + ) +} ?: run { + trace("ResultBackNavigator: Returning default object because route was not a RetVal") + object : IResultBackNavigator { + override fun returnValue(value: T) { + trace("ResultBackNavigator: No route available to return result $value for ${T::class.simpleName}", type = TraceType.Silent) + } + + override fun returnCanceled() { + trace("ResultBackNavigator: No route available to return canceled for ${T::class.simpleName}", type = TraceType.Silent) + } + } +} + +interface IResultBackNavigator { + fun returnValue(value: T) + fun returnCanceled() + fun returnValueOrNone(value: T?) { + if (value != null) { + returnValue(value) + } else { + returnNoValue() + } + } + + fun returnNoValue() = returnCanceled() +} + +class ResultBackNavigator( + private val navResultKey: NavResultKey, T>, + private val navigator: CodeNavigator, + private val navResultStore: NavResultStore, +) : IResultBackNavigator { + private fun returnResult(value: NavResultOrCanceled) { + val callerId = navigator.backStack.getOrNull(navigator.backStack.lastIndex - 1) + trace("returnResult: callerId=$callerId, key=$navResultKey, value=$value", type = TraceType.Silent) + if (callerId != null) { + navResultStore.deliverOrPersist( + callerEntryId = callerId, + key = navResultKey, + value = value + ) + } else { + trace("No caller to deliver result to; result will be dropped", type = TraceType.Silent) + } + navigator.navigateBack(navigatingForResult = true) + } + + override fun returnValue(value: T) { + returnResult(NavResultOrCanceled.ReturnValue(value)) + } + + @Suppress("UNCHECKED_CAST") + override fun returnCanceled() { + returnResult(NavResultOrCanceled.Canceled) + } +} + +/** + * Composable helper to observe persisted NavResult changes and automatically handle them + */ +@Composable +fun , T : Parcelable> ObserveNavResultChanges( + key: NavResultKey, + callerEntryId: NavKey = LocalNavKey.current, + navResultStore: NavResultStore = LocalCodeNavigator.current.resultStore, + onResult: (NavResultOrCanceled) -> Unit, +) { + LaunchedEffect(callerEntryId, key) { + navResultStore.observePersistedChanges(callerEntryId, key) + .filterNotNull() + .collect { result -> + trace("ObserveNavResultChanges: callerEntryId=$callerEntryId, key=$key, result=$result", type = TraceType.Silent) + runCatching { + onResult(result) + }.onFailure { + trace( + message = "Error handling nav result for callerEntryId=$callerEntryId, key=$key", + error = it, + type = TraceType.Error + ) + } + // After handling, remove the persisted result to prevent duplicate handling + runCatching { + navResultStore.takePersisted(callerEntryId, key) + }.onFailure { + trace( + message = "Error clearing persisted nav result for callerEntryId=$callerEntryId, key=$key", + error = it, + type = TraceType.Error + ) + } + } + } +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/RetVal.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/RetVal.kt new file mode 100644 index 000000000..79eded14d --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/RetVal.kt @@ -0,0 +1,17 @@ +package com.getcode.navigation.results + +import android.os.Parcelable +import androidx.navigation3.runtime.NavKey +import kotlin.reflect.KClass + + +/** Navigation result return value */ +interface NavigationRetVal : NavKey + +inline fun NavigationRetVal.asKey(): NavResultKey, T> { + // This uses the actual runtime class of 'this' while ensuring it matches the expected type + return NavResultKey( + this::class as KClass>, + T::class + ) +} 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 new file mode 100644 index 000000000..147029163 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -0,0 +1,184 @@ +package com.getcode.navigation.scenes + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import com.getcode.navigation.NavMetadataKeys +import com.getcode.navigation.results.NavResultKey +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.NavResultStore +import com.getcode.navigation.results.NavigationRetVal +import com.getcode.theme.CodeTheme +import kotlinx.coroutines.launch + +// Adapted from code courtesy of https://github.com/android/nav3-recipes/pull/67 + +/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ +internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::class) constructor( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val modalBottomSheetProperties: ModalBottomSheetProperties, + private val onBack: () -> Unit, + override val metadata: Map, + private val navResultStore: NavResultStore, + lastNavKey: () -> NavKey?, +) : OverlayScene { + + private val returnNavKey = lastNavKey() + + override val entries: List> = listOf(entry) + + @OptIn(ExperimentalMaterial3Api::class) + override val content: @Composable (() -> Unit) = { + val isNonDismissable = + (metadata[NavMetadataKeys.IsNonDismissable.key] as? Boolean) ?: false + + val handleBackResult = { + val navResultKey = + metadata[NavMetadataKeys.NavResultKey.key] as? NavResultKey, Parcelable> + if (navResultKey != null) { + returnNavKey?.let { navKey -> + navResultStore.deliverOrPersist( + navKey, + navResultKey, + NavResultOrCanceled.Canceled + ) + } + } + } + + var sheetState: SheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { value -> + // prevent dismissing via gesture if non-dismissable + !(value == SheetValue.Hidden && isNonDismissable) + }, + ) + + val composeScope = rememberCoroutineScope() + + val dismiss = { hide: Boolean -> + if (hide && sheetState.isVisible) { + composeScope.launch { + sheetState.hide() + }.invokeOnCompletion { + handleBackResult() + onBack() + } + } else { + handleBackResult() + onBack() + } + } + + // Remove inset padding. Default adds nav bar padding. + // Remove grab bar for bleed to top edge of sheet + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { dismiss(false) }, + scrimColor = CodeTheme.colors.scrim, + properties = modalBottomSheetProperties, + dragHandle = null, + contentWindowInsets = { WindowInsets() }, + containerColor = CodeTheme.colors.surface, + ) { + Box( + modifier = Modifier.fillMaxWidth() + .fillMaxHeight(CodeTheme.dimens.modalHeightRatio) + ) { + CompositionLocalProvider(LocalBottomSheetDismissDispatcher provides { dismiss(true) }) { + entry.Content() + } + } + } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [modalBottomSheet] to their [NavEntry.metadata] + * within a [ModalBottomSheet] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +class ModalBottomSheetSceneStrategy( + private val navResultStore: NavResultStore, + private val lastNavKey: () -> NavKey?, +) : SceneStrategy { + + @OptIn(ExperimentalMaterial3Api::class) + override fun SceneStrategyScope.calculateScene( + entries: List>, + ): Scene? { + val lastEntry = entries.lastOrNull() ?: return null + val isSheet = lastEntry.metadata[NavMetadataKeys.IsSheet.key] as? Boolean ?: false + if (!isSheet) return null + + // Keep all entries unless solitary; for inner sheets, retain other inner sheet entries + val overlaidEntries = entries.dropLast(1).let { remainingEntries -> + if (lastEntry.metadata[NavMetadataKeys.IsSolitarySheet.key] == true) { + // Drop all sheet entries for solitary sheets + remainingEntries.dropLastWhile { + it.metadata.getOrDefault(NavMetadataKeys.IsSheet.key, false) as Boolean + } + } else { + // Keep all entries to allow intra-sheet navigation + remainingEntries + } + }.ifEmpty { return null } + + val bottomSheetProperties = lastEntry.metadata[BOTTOM_SHEET_KEY] as? ModalBottomSheetProperties + ?: ModalBottomSheetProperties() + + @Suppress("UNCHECKED_CAST") + return ModalBottomSheetScene( + key = lastEntry.contentKey as T, + previousEntries = entries.dropLast(1).ifEmpty { return null }, + overlaidEntries = overlaidEntries, + modalBottomSheetProperties = bottomSheetProperties, + entry = lastEntry, + onBack = onBack, + metadata = lastEntry.metadata, + navResultStore = navResultStore, + lastNavKey = lastNavKey, + ) + } + + companion object { + /** + * Function to be called on the [NavEntry.metadata] to mark this entry as something that + * should be displayed within a [ModalBottomSheet]. + * + * @param modalBottomSheetProperties properties that should be passed to the containing + * [ModalBottomSheet]. + */ + @OptIn(ExperimentalMaterial3Api::class) + fun modalBottomSheet( + modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties(), + ): Map = mapOf(BOTTOM_SHEET_KEY to modalBottomSheetProperties) + + internal const val BOTTOM_SHEET_KEY = "bottomsheet" + } +} + +val LocalBottomSheetDismissDispatcher = staticCompositionLocalOf { {} } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ChildNavTab.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ChildNavTab.kt deleted file mode 100644 index 12ecd2b20..000000000 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ChildNavTab.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.getcode.navigation.screens - -import cafe.adriel.voyager.navigator.tab.Tab -import com.getcode.navigation.core.NavigationLocator - -interface ChildNavTab: Tab { - val ordinal: Int - var childNav: NavigationLocator -} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ContextSheet.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ContextSheet.kt deleted file mode 100644 index 6bdd9d3c4..000000000 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/ContextSheet.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.getcode.navigation.screens - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Divider -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import cafe.adriel.voyager.core.screen.Screen -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.theme.CodeTheme -import com.getcode.theme.White05 -import com.getcode.ui.core.ContextMenuAction -import com.getcode.ui.core.rememberAnimationScale -import com.getcode.ui.core.scaled -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -sealed interface ContextMenuStyle { - @get:Composable - val color: Color - - data object Default: ContextMenuStyle { - override val color: Color - @Composable get() = Color(0xFF171921) - } - - data object Themed: ContextMenuStyle { - override val color: Color - @Composable get() = CodeTheme.colors.surfaceVariant - } -} - -class ContextSheet( - private val actions: List, - private val style: ContextMenuStyle = ContextMenuStyle.Themed, -) : Screen { - - @Composable - override fun Content() { - val navigator = LocalCodeNavigator.current - val composeScope = rememberCoroutineScope() - val animationScale by rememberAnimationScale() - - LazyColumn( - modifier = Modifier - .background(style.color) - .padding(top = CodeTheme.dimens.grid.x2) - .navigationBarsPadding() - ) { - itemsIndexed(actions) { index, action -> - when (action) { - is ContextMenuAction.Single -> { - Row( - modifier = Modifier - .clickable { - composeScope.launch { - navigator.hide() - if (action.delayUponSelection) { - delay(300.scaled(animationScale)) - } - action.onSelect() - } - } - .fillMaxWidth() - .padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x3 - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) - ) { - Image( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - painter = action.painter, - contentDescription = null, - colorFilter = ColorFilter.tint( - if (action.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain - ) - ) - Text( - text = action.title, - style = CodeTheme.typography.textMedium.copy( - color = if (action.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain - ), - modifier = Modifier.weight(1f) - ) - } - } - - is ContextMenuAction.Custom -> { - action.Content() - } - } - if (index < actions.lastIndex) { - Divider(color = White05) - } - } - } - } -} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/Screens.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/Screens.kt deleted file mode 100644 index e3883f4f4..000000000 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/screens/Screens.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.getcode.navigation.screens - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.lifecycle.Lifecycle -import cafe.adriel.voyager.core.screen.Screen -import com.getcode.theme.CodeTheme -import com.getcode.ui.utils.RepeatOnLifecycle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import timber.log.Timber - -abstract class ReturnResultScreen: Screen { - var result = MutableStateFlow(null) - - abstract val testTag: String - - fun onResult(obj: T) { - Timber.d("onResult=$obj") - result.value = obj - } -} - -@Composable -fun ReturnResultScreen.OnScreenResult(block: suspend (Any) -> Unit) { - RepeatOnLifecycle( - targetState = Lifecycle.State.RESUMED, - ) { - result - .filterNotNull() - .onEach { runCatching { block(it) } } - .onEach { result.value = null } - .launchIn(this) - } -} - -interface AppScreen: Screen { - val testTag: String - - @Composable - fun ScreenContent() - - @Composable - override fun Content() { - Box( - modifier = Modifier - .fillMaxWidth() - .testTag(testTag) - ) { - ScreenContent() - } - } -} - - -interface ModalContent - -sealed interface ModalHeightMetric { - data class Weight(val weight: Float) : ModalHeightMetric - data object WrapContent : ModalHeightMetric -} - -interface ModalScreen : AppScreen, ModalContent { - - val modalHeight: ModalHeightMetric - @Composable get() = ModalHeightMetric.Weight(CodeTheme.dimens.modalHeightRatio) - - @Composable - fun ModalContent() - - @Composable - override fun ScreenContent() = ModalContent() - - @Composable - override fun Content() { - Column( - modifier = Modifier - .fillMaxWidth() - .testTag(testTag) - .then( - modalHeight.let { mh -> - when (mh) { - is ModalHeightMetric.Weight -> Modifier.fillMaxHeight(mh.weight) - ModalHeightMetric.WrapContent -> Modifier.wrapContentHeight() - } - } - ) - ) { - ScreenContent() - } - } -} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/transitions/SheetSlideTransition.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/transitions/SheetSlideTransition.kt deleted file mode 100644 index 200688823..000000000 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/transitions/SheetSlideTransition.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.getcode.navigation.transitions - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.spring -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.IntOffset -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.core.stack.StackEvent -import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.transitions.ScreenTransitionContent -import com.getcode.navigation.core.CodeNavigator -import com.getcode.navigation.screens.ModalContent - -@Composable -fun SheetSlideTransition( - navigator: CodeNavigator, - modifier: Modifier = Modifier, - orientation: SlideOrientation = SlideOrientation.Horizontal, - animationSpec: FiniteAnimationSpec = spring( - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = IntOffset.VisibilityThreshold - ), - content: ScreenTransitionContent = { it.Content() } -) { - BottomSheetScreenTransition( - navigator = navigator, - modifier = modifier, - content = content, - transition = { - if (navigator.lastEvent == StackEvent.Replace) { - EnterTransition.None togetherWith ExitTransition.None - } else { - val (initialOffset, targetOffset) = when (navigator.lastEvent) { - StackEvent.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size }) - else -> ({ size: Int -> size }) to ({ size: Int -> -size }) - } - - when (orientation) { - SlideOrientation.Horizontal -> - slideInHorizontally(animationSpec, initialOffset) togetherWith - slideOutHorizontally(animationSpec, targetOffset) - - SlideOrientation.Vertical -> - slideInVertically(animationSpec, initialOffset) togetherWith - slideOutVertically(animationSpec, targetOffset) - } - } - } - ) -} - -@Composable -fun BottomSheetScreenTransition( - navigator: CodeNavigator, - transition: AnimatedContentTransitionScope.() -> ContentTransform, - modifier: Modifier = Modifier, - content: ScreenTransitionContent = { it.Content() } -) { - val lastItem = navigator.lastItem - if (lastItem != null) { - when { - lastItem is ModalContent -> { - AnimatedContent( - targetState = lastItem, - transitionSpec = transition, - modifier = modifier, - label = "screen transition", - ) { screen -> - navigator.saveableState("transition", screen = screen) { - content(screen) - } - } - } - else -> CurrentScreen() - } - } -} - -enum class SlideOrientation { - Horizontal, - Vertical -} \ No newline at end of file diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/utils/KClassSerializer.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/utils/KClassSerializer.kt new file mode 100644 index 000000000..304caf399 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/utils/KClassSerializer.kt @@ -0,0 +1,49 @@ +package com.getcode.navigation.utils + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlin.reflect.KClass + +/** + * A serializer for [KClass] that serializes the class by its Java class name. + * + * Note: This serializer does not handle generic types or inner classes. This takes the approach of + * stringifying the class name. It should be able to handle obfuscation but won't be stable across + * app versions if obfuscation mappings change. It won't is also unlikely to be stable with local or + * anonymous classes. + */ +@OptIn(ExperimentalSerializationApi::class) +object KClassSerializer : KSerializer> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + "KClass", + PrimitiveKind.STRING, + ) + + private val primitiveByJavaName: Map> = mapOf( + "boolean" to java.lang.Boolean.TYPE, + "byte" to java.lang.Byte.TYPE, + "char" to java.lang.Character.TYPE, + "short" to java.lang.Short.TYPE, + "int" to java.lang.Integer.TYPE, + "long" to java.lang.Long.TYPE, + "float" to java.lang.Float.TYPE, + "double" to java.lang.Double.TYPE, + "void" to java.lang.Void.TYPE + ) + + override fun serialize(encoder: Encoder, value: KClass<*>) { + encoder.encodeString(value.java.name) + } + + override fun deserialize(decoder: Decoder): KClass<*> { + val name = decoder.decodeString() + val jClass = primitiveByJavaName[name] + ?: Class.forName(name) + return jClass.kotlin + } +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/RepeatOnLifecycle.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/utils/lifecycle/RepeatOnLifecycle.kt similarity index 50% rename from ui/navigation/src/main/kotlin/com/getcode/ui/utils/RepeatOnLifecycle.kt rename to ui/navigation/src/main/kotlin/com/getcode/navigation/utils/lifecycle/RepeatOnLifecycle.kt index 47df2994a..3343f78ee 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/RepeatOnLifecycle.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/utils/lifecycle/RepeatOnLifecycle.kt @@ -1,4 +1,4 @@ -package com.getcode.ui.utils +package com.getcode.navigation.utils.lifecycle import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -7,8 +7,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import cafe.adriel.voyager.core.screen.Screen -import com.getcode.navigation.core.LocalCodeNavigator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -16,24 +14,18 @@ import kotlinx.coroutines.launch fun RepeatOnLifecycle( targetState: Lifecycle.State, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, - screen: Screen? = null, doOnDispose: () -> Unit = {}, action: suspend CoroutineScope.() -> Unit, ) { - val navigator = LocalCodeNavigator.current - val lastItem = navigator.lastItem - - if (screen == null || screen.key == lastItem?.key) { - DisposableEffect(lifecycleOwner) { - val job = lifecycleOwner.lifecycleScope.launch { - lifecycleOwner.repeatOnLifecycle(targetState) { - action(this) - } - } - onDispose { - job.cancel() - doOnDispose() + DisposableEffect(lifecycleOwner) { + val job = lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(targetState) { + action() } } + onDispose { + job.cancel() + doOnDispose() + } } -} \ No newline at end of file +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt index 6ec3da8f9..56a5f093b 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt @@ -20,4 +20,4 @@ abstract class BaseViewModel( open fun setIsLoading(isLoading: Boolean) {} fun getString(resId: Int): String = resources.getString(resId) -} \ No newline at end of file +} diff --git a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel2.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel2.kt index 0322aa4fc..0a2da7cec 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel2.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel2.kt @@ -63,4 +63,4 @@ data class LoadingSuccessState( } val isIdle: Boolean = state is State.Idle -} \ No newline at end of file +} diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index d80c649be..aa8d30460 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -2,6 +2,7 @@ package com.getcode.theme import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material.Colors +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Shapes import androidx.compose.material.TextFieldDefaults @@ -82,7 +83,10 @@ fun DesignSystem( typography = LocalCodeTypography.current.toMaterial() ) { // setup after MDC theme to override defaults in theme - CompositionLocalProvider(LocalTextSelectionColors provides textSelectionColors) { + CompositionLocalProvider( + LocalTextSelectionColors provides textSelectionColors, + LocalContentColor provides CodeTheme.colors.textMain, + ) { content() } } From 55c731046c9bd15705b877541dc7c9d2d11c52b4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 18 Mar 2026 11:44:23 -0400 Subject: [PATCH 2/7] fix(nav3): resolve nested navigation issues with sheets and deeplinks - Fix Phantom deeplinks by introducing pendingNavigation on ExternalWalletDeeplinkState so inner sheet screens navigate via their local navigator instead of the root backstack - Skip general deeplink destination routing for external wallet returns to prevent replaceAll from destroying the sheet - Handle user cancellation in Phantom by detecting IDLE state on TxProcessingScreen and popping back - Fix duplicate bottom bar messages by limiting the messaging decorator to only the topmost non-sheet entry (using entry.metadata instead of the broken reified T.metadata() which always resolved as NavKey) - Fix ContextThemeWrapper cast crash in PermissionCheck inside ModalBottomSheet by using Context.getActivity() unwrapper - Support inner sheet navigation for deeplinks (email verification) by adding innerRoutes to Main.Sheet and packing post-sheet routes in navigateTo() - Extract blocking views (biometrics, update required) into NavBlockingOverlayEntryDecorator --- .../com/flipcash/app/internal/ui/App.kt | 126 +++-- .../ui/navigation/AppScreenContent.kt | 308 +++++------ .../app/internal/ui/navigation/MainRoot.kt | 117 ++++- .../NavBlockingOverlayEntryDecorator.kt | 38 ++ .../decorators/NavMessagingEntryDecorator.kt | 4 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 8 +- .../app/core/extensions/CodeNavigator.kt | 51 +- .../app/core/navigation/DeeplinkAction.kt | 9 + .../contact/verification/VerificationFlows.kt | 16 + .../email/EmailVerificationViewModel.kt | 5 + .../app/login/accesskey/AccessKeyScreen.kt | 2 +- .../flipcash/app/login/router/LoginRouter.kt | 2 +- .../app/login/seed/SeedInputViewModel.kt | 2 +- .../internal/PurchaseAccountScreenContent.kt | 2 +- .../com/flipcash/app/scanner/ScannerScreen.kt | 5 +- .../internal/NavigationStateRestorer.kt | 90 ---- .../flipcash/app/scanner/internal/Scanner.kt | 51 +- .../internal/ScannerDeepLinkHandler.kt | 65 --- .../app/tokens/TokenBuySellEntryScreen.kt | 10 + .../flipcash/app/tokens/TokenInfoScreen.kt | 10 + .../app/tokens/TokenTxProcessingScreen.kt | 16 +- .../shared/onramp/deeplinks/build.gradle.kts | 1 - .../app/onramp/ExternalWalletOnRampHandler.kt | 80 +-- .../internal/ExternalWalletDeeplinkState.kt | 43 +- .../internal/PermissionScreenContent.kt | 6 +- apps/flipcash/shared/router/build.gradle.kts | 4 +- .../kotlin/com/flipcash/app/router/Router.kt | 9 +- .../app/router/inject/RouterModule.kt | 6 +- .../flipcash/app/router/internal/AppRouter.kt | 147 +++--- .../app/router/internal/AppRouterTest.kt | 482 ++++++++++++++++++ .../app/router/internal/NavigateToTest.kt | 183 +++++++ .../app/router/internal/ResolveRoutesTest.kt | 129 +++++ gradle/libs.versions.toml | 2 + .../kotlin/com/getcode/solana/keys/Key.kt | 3 +- .../com/getcode/solana/keys/PublicKey.kt | 3 +- .../util/permissions/PermissionCheck.kt | 2 +- .../ui/components/bars/BottomBarContainer.kt | 10 +- .../com/getcode/navigation/AppNavHost.kt | 9 +- .../com/getcode/navigation/NavMetadata.kt | 39 +- .../getcode/navigation/core/CodeNavigator.kt | 21 + .../scenes/ModalBottomSheetSceneStrategy.kt | 146 ++++-- 41 files changed, 1611 insertions(+), 651 deletions(-) create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBlockingOverlayEntryDecorator.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt delete mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt delete mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDeepLinkHandler.kt create mode 100644 apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt create mode 100644 apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt create mode 100644 apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt 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 6a51a9caa..c32f83851 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 @@ -1,7 +1,11 @@ package com.flipcash.app.internal.ui +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -20,15 +24,18 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.android.BuildConfig import com.flipcash.app.bill.customization.BillPlaygroundScaffold import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.contact.verification.EmailVerificationFlow +import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.internal.ui.navigation.AppPreloads import com.flipcash.app.internal.ui.navigation.appEntryProvider +import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBlockingOverlayEntryDecorator import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.onramp.ExternalWalletOnRampHandler import com.flipcash.app.onramp.LocalExternalWalletState @@ -38,7 +45,6 @@ import com.flipcash.app.payments.PaymentScaffold import com.flipcash.app.router.LocalRouter import com.flipcash.app.session.LocalSessionController import com.flipcash.app.theme.FlipcashTheme -import com.flipcash.app.updates.UpdateRequiredBlockingView import com.flipcash.features.shareapp.R import com.flipcash.services.user.AuthState import com.getcode.libs.biometrics.BiometricsError @@ -53,10 +59,10 @@ import com.getcode.solana.rpc.RpcConfig import com.getcode.theme.CodeTheme import com.getcode.ui.biometrics.LocalBiometricsState import com.getcode.ui.biometrics.rememberBiometricsState -import com.getcode.ui.biometrics.views.BiometricsBlockingView import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.components.bars.rememberBarManager import com.getcode.ui.core.RestrictionType +import com.flipcash.app.core.extensions.navigateTo import dev.bmcreations.tipkit.TipScaffold import dev.bmcreations.tipkit.engines.TipsEngine import dev.theolm.rinku.DeepLink @@ -87,15 +93,9 @@ internal fun App( } var deepLink by remember { mutableStateOf(null) } - var loginRequest by remember { mutableStateOf(null) } val userManager = LocalUserManager.current!! DeepLinkListener { analytics.deeplinkOpened(it.data) - val type = router.processType(it) - analytics.deeplinkParsed(type, it.data) - if (type is DeeplinkType.Login) { - loginRequest = type.entropy - } deepLink = it } @@ -145,8 +145,6 @@ internal fun App( state = externalWalletOnRamp, lifecycleOwner = LocalLifecycleOwner.current, navigator = codeNavigator, - router = router, - deepLink = deepLink, ) { AppNavHost( navigator = codeNavigator, @@ -155,7 +153,8 @@ internal fun App( rememberNavMessagingEntryDecorator( codeNavigator.backStack, barManager - ) + ), + rememberNavBlockingOverlayEntryDecorator(), ), sceneStrategy = ModalBottomSheetSceneStrategy( codeNavigator.resultStore @@ -164,53 +163,90 @@ internal fun App( codeNavigator.backStack.lastIndex - 1 ) } then SinglePaneSceneStrategy(), - onBack = { codeNavigator.navigateBack() }, - entryProvider = { key -> - appEntryProvider( - key = key, - resultStateRegistry = resultStateRegistry, - barManager = barManager, - deepLink = { deepLink }, - ) + transitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + } + }, + popTransitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + } }, + predictivePopTransitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + } + }, + onBack = { codeNavigator.navigateBack() }, + entryProvider = appEntryProvider( + resultStateRegistry = resultStateRegistry, + barManager = barManager, + deepLink = { deepLink }, + ), ) } LaunchedEffect(deepLink) { - if (codeNavigator.currentRouteKey is AppRoute.Loading) return@LaunchedEffect - if (deepLink != null) { - val routes = router.processDestination(deepLink) - if (routes.isNotEmpty()) { - codeNavigator.replaceAll(routes) - } - deepLink = null - } - } + val link = deepLink ?: return@LaunchedEffect - LaunchedEffect( - loginRequest, - codeNavigator.lastItem, - userManager.authState - ) { - if (codeNavigator.currentRouteKey is AppRoute.Loading) return@LaunchedEffect - if (userManager.authState !is AuthState.LoggedInWithUser) { - loginRequest = null + if (codeNavigator.currentRouteKey is AppRoute.Loading) { + // Cold start — MainRoot handles it via the deepLink lambda return@LaunchedEffect } - loginRequest?.let { entropy -> - viewModel.handleLoginEntropy( - entropy, + + when (val action = router.dispatch(link)) { + is DeeplinkAction.Navigate -> { + // If a verification code targets a screen already open, + // deliver via side-channel and skip navigation. + val verification = action.routes + .filterIsInstance() + .firstOrNull() + val email = verification?.email + val code = verification?.emailVerificationCode + val delivered = if (email != null && code != null) { + EmailVerificationFlow.deliverCode(email, code) + } else false + + if (!delivered) { + codeNavigator.navigateTo(action.routes) + } + } + is DeeplinkAction.ExternalWallet -> externalWalletOnRamp.handleWalletDeeplink(action.type) + is DeeplinkAction.Login -> viewModel.handleLoginEntropy( + action.entropy, onSwitchAccount = { - loginRequest = null codeNavigator.replaceAll( AppRoute.Onboarding.Login( - entropy, + action.entropy, fromDeeplink = true ) ) }, - onDismissed = { loginRequest = null } + onDismissed = { } ) + is DeeplinkAction.OpenCashLink -> session.openCashLink(action.entropy) + DeeplinkAction.None -> {} + } + deepLink = null + } + + LaunchedEffect(userState.authState) { + if (userState.authState == AuthState.LoggedOut) { + val current = codeNavigator.currentRouteKey + if (current !is AppRoute.Loading && current !is AppRoute.Onboarding) { + codeNavigator.pendingSheetDismiss = null + codeNavigator.replaceAll(AppRoute.Onboarding.Login()) + } } } @@ -243,8 +279,6 @@ internal fun App( } } - BiometricsBlockingView(modifier = Modifier.fillMaxSize(), biometricsState) - UpdateRequiredBlockingView(modifier = Modifier.fillMaxSize(), biometricsState = biometricsState) } } } 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 a41c169c1..d9878f7a0 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 @@ -1,6 +1,8 @@ package com.flipcash.app.internal.ui.navigation import androidx.activity.compose.BackHandler +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -9,7 +11,9 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.advanced.AdvancedFeaturesScreen import com.flipcash.app.appsettings.AppSettingsScreen @@ -49,9 +53,9 @@ import com.flipcash.app.withdrawal.WithdrawalDestinationScreen import com.flipcash.app.withdrawal.WithdrawalEntryScreen import com.flipcash.app.withdrawal.WithdrawalFlow import com.getcode.navigation.AppNavHost +import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.core.rememberCodeNavigator -import com.getcode.navigation.metadata import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy @@ -65,194 +69,162 @@ fun AppPreloads() { } fun appEntryProvider( - key: NavKey, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, deepLink: () -> DeepLink?, -): NavEntry { - return when (key) { - is AppRoute.Loading -> NavEntry(key = key, metadata = key.metadata()) { - MainRoot(deepLink) - } - - is AppRoute.Onboarding.Login -> NavEntry(key = key, metadata = key.metadata()) { - LoginRouter(key.seed, key.fromDeeplink) - } - is AppRoute.Onboarding.SeedInput -> NavEntry(key = key, metadata = key.metadata()) { - SeedInputScreen() - } - is AppRoute.Onboarding.AccessKey -> NavEntry(key = key, metadata = key.metadata()) { - AccessKeyScreen() - } - is AppRoute.Onboarding.AccessKeySavedLocation -> NavEntry(key = key, metadata = key.metadata()) { - PhotoAccessKeyScreen() - } - is AppRoute.Onboarding.Purchase -> NavEntry(key = key, metadata = key.metadata()) { - PurchaseAccountScreen(key.fromLogin) - } - is AppRoute.Onboarding.NotificationPermission, - is AppRoute.Onboarding.CameraPermission -> NavEntry(key = key, metadata = key.metadata()) { - // Deprecated — permissions requested at time of use - } - - is AppRoute.Main.Sheet -> sheetEntry(key, resultStateRegistry, barManager) - is AppRoute.Main.AppRestricted -> NavEntry(key = key, metadata = key.metadata()) { - AppRestrictedScreen(key.restrictionType) - } - is AppRoute.Main.Scanner -> NavEntry(key = key, metadata = key.metadata()) { - ScannerScreen(key.deeplink) - } - is AppRoute.Sheets.Give -> NavEntry(key = key, metadata = key.metadata()) { - CashScreen(key.mint, key.fromTokenInfo) - } - is AppRoute.Main.RegionSelection -> NavEntry(key = key, metadata = key.metadata()) { - RegionSelectionScreen(key.kind) - } - - is AppRoute.Token.Info -> NavEntry(key = key, metadata = key.metadata()) { - TokenInfoScreen(key.mint, key.forNeededFunds, key.fromDeeplink) - } - is AppRoute.Token.Transactions -> NavEntry(key = key, metadata = key.metadata()) { - TransactionHistoryScreen(key.mint) - } - is AppRoute.Token.SwapTransact -> NavEntry(key = key, metadata = key.metadata()) { - remember { BuySellFlow.start(key.forNeededFunds) } - TokenBuySellEntryScreen(key.purpose) - } - is AppRoute.Token.TxProcessing -> NavEntry(key = key, metadata = key.metadata()) { - TokenTxProcessingScreen(key.swapId, key.awaitExternalWallet) - } - is AppRoute.Token.SellReceipt -> NavEntry(key = key, metadata = key.metadata()) { - TokenSellReceiptScreen() - } - - is AppRoute.Sheets.TokenSelection -> NavEntry(key = key, metadata = key.metadata()) { - TokenSelectScreen(key.purpose) - } - is AppRoute.Sheets.Wallet -> NavEntry(key = key, metadata = key.metadata()) { - BalanceScreen() - } - is AppRoute.Sheets.ShareApp -> NavEntry(key = key, metadata = key.metadata()) { - ShareAppScreen() - } - is AppRoute.Sheets.Menu -> NavEntry(key = key, metadata = key.metadata()) { - MenuScreen() - } - is AppRoute.Sheets.Lab -> NavEntry(key = key, metadata = key.metadata()) { - StandaloneLabsScreen() - } - - is AppRoute.Verification -> NavEntry(key = key, metadata = key.metadata()) { - VerificationFlowScreen( - origin = key.origin, - target = key.target, - includePhone = key.includePhone, - includeEmail = key.includeEmail, - emailAddress = key.email, - emailVerificationCode = key.emailVerificationCode, - ) - } - - is AppRoute.OnRamp.ProviderList -> NavEntry(key = key, metadata = key.metadata()) { - remember { OnRampFlowTracker.start(key.from) } - OnRampProviderListScreen( - neededAmount = key.neededAmount?.quarks, - neededCurrency = key.neededAmount?.currencyCode, - ) - } - is AppRoute.OnRamp.AmountEntry -> NavEntry(key = key, metadata = key.metadata()) { - OnRampCustomAmountScreen() - } - - is AppRoute.Menu.AppSettings -> NavEntry(key = key, metadata = key.metadata()) { - AppSettingsScreen() - } - is AppRoute.Menu.Lab -> NavEntry(key = key, metadata = key.metadata()) { - LabsScreen() - } - is AppRoute.Menu.MyAccount -> NavEntry(key = key, metadata = key.metadata()) { - MyAccountScreen() - } - is AppRoute.Menu.Deposit -> NavEntry(key = key, metadata = key.metadata()) { - DepositScreen(key.mint) - } - is AppRoute.Menu.BackupKey -> NavEntry(key = key, metadata = key.metadata()) { - BackupKeyScreen() - } - is AppRoute.Menu.AdvancedFeatures -> NavEntry(key = key, metadata = key.metadata()) { - AdvancedFeaturesScreen() - } - - is AppRoute.Transfers.Withdrawal.Amount -> NavEntry(key = key, metadata = key.metadata()) { - remember { WithdrawalFlow.start() } - WithdrawalEntryScreen(key.mint) - } - is AppRoute.Transfers.Withdrawal.Destination -> NavEntry(key = key, metadata = key.metadata()) { - WithdrawalDestinationScreen() - } - is AppRoute.Transfers.Withdrawal.Confirmation -> NavEntry(key = key, metadata = key.metadata()) { - WithdrawalConfirmationScreen() - } +): (NavKey) -> NavEntry = entryProvider { + + // Loading / splash + annotatedEntry { MainRoot(deepLink) } + + // Onboarding + annotatedEntry { key -> LoginRouter(key.seed, key.fromDeeplink) } + annotatedEntry { SeedInputScreen() } + annotatedEntry { AccessKeyScreen() } + annotatedEntry { PhotoAccessKeyScreen() } + annotatedEntry { key -> PurchaseAccountScreen(key.fromLogin) } + annotatedEntry { } + annotatedEntry { } + + // Main + annotatedEntry { key -> + SheetContent(key, resultStateRegistry, barManager) + } + annotatedEntry { key -> AppRestrictedScreen(key.restrictionType) } + annotatedEntry { ScannerScreen() } + annotatedEntry { key -> RegionSelectionScreen(key.kind) } + + // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) + annotatedEntry { key -> CashScreen(key.mint, key.fromTokenInfo) } + annotatedEntry { key -> TokenSelectScreen(key.purpose) } + annotatedEntry { BalanceScreen() } + annotatedEntry { ShareAppScreen() } + annotatedEntry { MenuScreen() } + annotatedEntry { StandaloneLabsScreen() } + + // Tokens + annotatedEntry { key -> + TokenInfoScreen(key.mint, key.forNeededFunds, key.fromDeeplink) + } + annotatedEntry { key -> TransactionHistoryScreen(key.mint) } + annotatedEntry { key -> + remember { BuySellFlow.start(key.forNeededFunds) } + TokenBuySellEntryScreen(key.purpose) + } + annotatedEntry { key -> + TokenTxProcessingScreen(key.swapId, key.awaitExternalWallet) + } + annotatedEntry { TokenSellReceiptScreen() } + + // Verification + annotatedEntry { key -> + VerificationFlowScreen( + origin = key.origin, + target = key.target, + includePhone = key.includePhone, + includeEmail = key.includeEmail, + emailAddress = key.email, + emailVerificationCode = key.emailVerificationCode, + ) + } - else -> error("Unknown route: $key") + // OnRamp + annotatedEntry { key -> + remember { OnRampFlowTracker.start(key.from) } + OnRampProviderListScreen( + neededAmount = key.neededAmount?.quarks, + neededCurrency = key.neededAmount?.currencyCode, + ) } + annotatedEntry { OnRampCustomAmountScreen() } + + // Menu + annotatedEntry { AppSettingsScreen() } + annotatedEntry { LabsScreen() } + annotatedEntry { MyAccountScreen() } + annotatedEntry { key -> DepositScreen(key.mint) } + annotatedEntry { BackupKeyScreen() } + annotatedEntry { AdvancedFeaturesScreen() } + + // Transfers + annotatedEntry { key -> + remember { WithdrawalFlow.start() } + WithdrawalEntryScreen(key.mint) + } + annotatedEntry { WithdrawalDestinationScreen() } + annotatedEntry { WithdrawalConfirmationScreen() } } /** - * Sheet entry with nested [AppNavHost] for inner-sheet navigation. + * Sheet content with nested [AppNavHost] for inner-sheet navigation. * Uses slide transitions for intra-sheet navigation. */ -private fun sheetEntry( +@Composable +private fun SheetContent( key: AppRoute.Main.Sheet, resultStateRegistry: NavResultStateRegistry, barManager: BarManager, -): NavEntry { - return NavEntry(key = key, metadata = key.metadata()) { - val sheetDismissDispatcher = LocalBottomSheetDismissDispatcher.current - val backStack = rememberNavBackStack(key.initialRoute) - val navigator = rememberCodeNavigator( - backStack = backStack, - resultStateRegistry = resultStateRegistry, - onRootReached = { sheetDismissDispatcher() }, - ) - - val onBack = { - if (backStack.size > 1) { - backStack.removeAt(backStack.lastIndex) - } else { - sheetDismissDispatcher() - } +) { + val sheetDismissDispatcher = LocalBottomSheetDismissDispatcher.current + // Seed the backstack with initialRoute + innerRoutes so the sheet + // appears already on the final destination (no visible push transition). + val backStack = remember { + NavBackStack(key.initialRoute).apply { + key.innerRoutes.forEach { add(it) } } + } + val navigator = rememberCodeNavigator( + backStack = backStack, + resultStateRegistry = resultStateRegistry, + onRootReached = { sheetDismissDispatcher() }, + ) + + val onBack = { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } else { + sheetDismissDispatcher() + } + } - CompositionLocalProvider(LocalCodeNavigator provides navigator) { - AppNavHost( - navigator = navigator, - resultStateRegistry = resultStateRegistry, - decorators = listOf( - rememberNavMessagingEntryDecorator(navigator.backStack, barManager) - ), - sceneStrategy = ModalBottomSheetSceneStrategy(navigator.resultStore) { - navigator.backStack.getOrNull(navigator.backStack.lastIndex - 1) - } then SinglePaneSceneStrategy(), - transitionSpec = { + CompositionLocalProvider(LocalCodeNavigator provides navigator) { + AppNavHost( + navigator = navigator, + resultStateRegistry = resultStateRegistry, + decorators = listOf( + rememberNavMessagingEntryDecorator(navigator.backStack, barManager) + ), + sceneStrategy = ModalBottomSheetSceneStrategy(navigator.resultStore) { + navigator.backStack.getOrNull(navigator.backStack.lastIndex - 1) + } then SinglePaneSceneStrategy(), + transitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { slideInHorizontally(initialOffsetX = { it }) togetherWith slideOutHorizontally(targetOffsetX = { -it }) - }, - popTransitionSpec = { + } + }, + popTransitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) - }, - predictivePopTransitionSpec = { + } + }, + predictivePopTransitionSpec = { + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) - }, - onBack = { onBack() }, - entryProvider = { innerKey -> - appEntryProvider(innerKey, resultStateRegistry, barManager, deepLink = { null }) } - ) + }, + onBack = { onBack() }, + entryProvider = appEntryProvider(resultStateRegistry, barManager, deepLink = { null }), + ) - BackHandler { onBack() } - } + BackHandler { onBack() } } } 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 af3ef0ce7..c6052c20d 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 @@ -24,10 +24,14 @@ import androidx.navigation3.runtime.NavKey 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.resolveRoutes import com.flipcash.app.router.LocalRouter import com.flipcash.app.router.Router import com.flipcash.services.internal.model.account.UserFlags import com.flipcash.services.user.AuthState +import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.theme.CodeCircularProgressIndicator @@ -94,7 +98,7 @@ internal fun MainRoot(deepLink: () -> DeepLink?) { "state" to state } ) - val routes = buildNavGraphForLaunch( + val launch = buildNavGraphForLaunch( state = state, userFlags = flags, router = router, @@ -117,56 +121,115 @@ internal fun MainRoot(deepLink: () -> DeepLink?) { } } - if (routes != null) { - navigator.replaceAll(routes) + if (launch != null) { + val current = navigator.backStack.toList() + val target = launch.resolvedBackStack() + + // Skip if the current stack already matches or extends the target. + // This prevents blowing away screens the user has navigated into + // (e.g. a verification code screen) when auth/flags re-emit. + if (!current.startsWith(target)) { + navigator.replaceAll(launch.baseRoutes) + if (launch.deeplinkRoutes.isNotEmpty()) { + navigator.navigateTo(launch.deeplinkRoutes) + } + } } }.launchIn(this) } } -private suspend fun buildNavGraphForLaunch( +/** + * Result of building the initial nav graph. + * + * [baseRoutes] are set via replaceAll (root backstack). + * [deeplinkRoutes] are applied via navigateTo, which handles sheet wrapping + * so deeplinks targeting screens inside sheets render correctly. + */ +private data class LaunchNavGraph( + val baseRoutes: List, + val deeplinkRoutes: List = emptyList(), +) { + /** + * Predict the final backstack that [baseRoutes] + [navigateTo(deeplinkRoutes)] will produce. + * Uses the shared [resolveRoutes] to apply the same sheet-wrapping as [navigateTo] + * so we can compare against the current backstack and skip redundant navigation. + */ + fun resolvedBackStack(): List { + if (deeplinkRoutes.isEmpty()) return baseRoutes + return baseRoutes + resolveRoutes(deeplinkRoutes) + } +} + +/** + * Returns true if [this] list starts with [prefix] (element-wise structural equality). + * An exact match also returns true (prefix == full list). + * + * This lets us detect when the user has navigated *deeper* than the launch target + * (e.g. opened a sheet, pushed a verification screen) so we don't blow away their + * backstack on a harmless auth/flags re-emission. + */ +private fun List.startsWith(prefix: List): Boolean { + if (size < prefix.size) return false + return prefix.indices.all { i -> this[i] == prefix[i] } +} + +private fun buildNavGraphForLaunch( state: AuthState, userFlags: UserFlags?, router: Router, deepLink: () -> DeepLink?, -): List? { +): LaunchNavGraph? { return when (state) { is AuthState.Registered -> { if (state.seenAccessKey) { - buildList { - if (userFlags?.requiresIapForRegistration == true) { - addAll( - listOf( - AppRoute.Onboarding.Login(), - AppRoute.Onboarding.AccessKey, - AppRoute.Onboarding.Purchase() - ) - ) - } else { - addAll(listOf(AppRoute.Main.Scanner())) - } + val routes = if (userFlags?.requiresIapForRegistration == true) { + listOf( + AppRoute.Onboarding.Login(), + AppRoute.Onboarding.AccessKey, + AppRoute.Onboarding.Purchase() + ) + } else { + listOf(AppRoute.Main.Scanner) } + LaunchNavGraph(routes) } else { - listOf( - AppRoute.Onboarding.Login(), - AppRoute.Onboarding.AccessKey + LaunchNavGraph( + listOf( + AppRoute.Onboarding.Login(), + AppRoute.Onboarding.AccessKey + ) ) } } AuthState.LoggedInWithUser -> { - val routes = router.processDestination(deepLink()) - - routes.ifEmpty { - listOf(AppRoute.Main.Scanner()) + val link = deepLink() + if (link != null) { + when (val action = router.dispatch(link)) { + is DeeplinkAction.Navigate -> LaunchNavGraph( + baseRoutes = listOf(AppRoute.Main.Scanner), + deeplinkRoutes = action.routes, + ) + // ExternalWallet/Login/OpenCashLink can't be handled on cold start + // (encryption state lost, no session yet) — fall through to Scanner + else -> LaunchNavGraph(listOf(AppRoute.Main.Scanner)) + } + } else { + LaunchNavGraph(listOf(AppRoute.Main.Scanner)) } } AuthState.LoggedOut, AuthState.Unknown -> { - val routes = router.processDestination(deepLink()) - routes.ifEmpty { - listOf(AppRoute.Onboarding.Login()) + val link = deepLink() + if (link != null) { + when (val action = router.dispatch(link)) { + is DeeplinkAction.Navigate -> LaunchNavGraph(action.routes) + else -> LaunchNavGraph(listOf(AppRoute.Onboarding.Login())) + } + } else { + LaunchNavGraph(listOf(AppRoute.Onboarding.Login())) } } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBlockingOverlayEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBlockingOverlayEntryDecorator.kt new file mode 100644 index 000000000..9b49e66ce --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBlockingOverlayEntryDecorator.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.internal.ui.navigation.decorators + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import com.flipcash.app.updates.UpdateRequiredBlockingView +import com.getcode.ui.biometrics.LocalBiometricsState +import com.getcode.ui.biometrics.views.BiometricsBlockingView + +/** + * Entry decorator that renders biometrics and update-required blocking + * overlays on top of each navigation entry. + */ +@Suppress("FunctionName") +fun NavBlockingOverlayEntryDecorator(): NavEntryDecorator { + return NavEntryDecorator { entry -> + Box { + entry.Content() + + val biometricsState = LocalBiometricsState.current + BiometricsBlockingView( + modifier = Modifier.fillMaxSize(), + state = biometricsState, + ) + UpdateRequiredBlockingView( + modifier = Modifier.fillMaxSize(), + biometricsState = biometricsState, + ) + } + } +} + +@Composable +fun rememberNavBlockingOverlayEntryDecorator() = remember { NavBlockingOverlayEntryDecorator() } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt index ffeaa7a8a..c8f15b8b0 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt @@ -7,7 +7,6 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntryDecorator import androidx.navigation3.runtime.NavKey import com.getcode.navigation.NavMetadataKeys -import com.getcode.navigation.metadata import com.getcode.ui.components.bars.BarManager import com.getcode.ui.components.bars.BottomBarContainer import com.getcode.ui.components.bars.TopBarContainer @@ -20,7 +19,8 @@ fun NavMessagingEntryDecorator( return NavEntryDecorator { entry -> Box { entry.Content() - if (backStack.lastOrNull()?.metadata()[NavMetadataKeys.IsSheet.key] != true) { + val isTopEntry = entry.contentKey == backStack.lastOrNull()?.toString() + if (isTopEntry && entry.metadata[NavMetadataKeys.IsSheet.key] != true) { TopBarContainer(barManager.barMessages) BottomBarContainer(barManager.barMessages) } 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 21f0eca26..4c67dd7a9 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 @@ -3,7 +3,6 @@ package com.flipcash.app.core import android.os.Parcelable import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.money.RegionSelectionKind -import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.tokens.TokenSwapPurpose import com.getcode.navigation.Sheet @@ -41,14 +40,17 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize sealed interface Main: AppRoute { @Serializable data class AppRestricted(val restrictionType: RestrictionType) : Main - @Serializable data class Scanner(val deeplink: DeeplinkType? = null) : Main + @Serializable data object Scanner : Main // TODO: is there a better place for this to live? @Serializable data class RegionSelection(val kind: RegionSelectionKind) : Main @Serializable @Parcelize - data class Sheet(val initialRoute: Sheets) : Main, com.getcode.navigation.Sheet + data class Sheet( + val initialRoute: Sheets, + val innerRoutes: List = emptyList(), + ) : Main, com.getcode.navigation.Sheet } @Serializable 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 c88d0d249..1ebc57e90 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 @@ -20,11 +20,56 @@ fun CodeNavigator.navigateTo(route: NavKey, options: NavOptions = NavOptions()) /** * Navigate to multiple routes, wrapping [AppRoute.Sheets] in [AppRoute.Main.Sheet]. - * The first route uses the provided [options], subsequent routes use default options. + * Routes after a [AppRoute.Sheets] entry are packed into the sheet's inner backstack + * 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. */ fun CodeNavigator.navigateTo(routes: List, options: NavOptions = NavOptions()) { if (routes.isEmpty()) return - routes.forEachIndexed { index, route -> - navigateTo(route, if (index == 0) options else NavOptions()) + + val resolved = resolveRoutes(routes) + val needsSheet = resolved.any { it is AppRoute.Main.Sheet } + 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 = { + sheetGeneration++ + resolved.forEachIndexed { index, route -> + val navOptions = if (index == 0) options else NavOptions() + navigate(route, navOptions) + } + } + } else { + resolved.forEachIndexed { index, route -> + val navOptions = if (index == 0) options else NavOptions() + navigate(route, navOptions) + } + } +} + +/** + * Resolve a list of routes into their final backstack representation. + * + * Wraps [AppRoute.Sheets] entries (and any routes after them) into + * [AppRoute.Main.Sheet] with inner routes, mirroring what [navigateTo] pushes + * onto the backstack. Useful for predicting the resulting stack without navigating. + */ +fun resolveRoutes(routes: List): List { + if (routes.isEmpty()) return emptyList() + + val sheetIndex = routes.indexOfFirst { it is AppRoute.Sheets } + return if (sheetIndex >= 0) { + val before = routes.take(sheetIndex) + val sheetRoute = routes[sheetIndex] as AppRoute.Sheets + val innerRoutes = routes.drop(sheetIndex + 1).filterIsInstance() + before + AppRoute.Main.Sheet(sheetRoute, innerRoutes) + } else { + routes } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt new file mode 100644 index 000000000..e39876e0b --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt @@ -0,0 +1,9 @@ +package com.flipcash.app.core.navigation + +sealed interface DeeplinkAction { + data class Navigate(val routes: List) : DeeplinkAction + data class ExternalWallet(val type: DeeplinkType) : DeeplinkAction + data class Login(val entropy: String) : DeeplinkAction + data class OpenCashLink(val entropy: String) : DeeplinkAction + data object None : DeeplinkAction +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlows.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlows.kt index a63b0ab34..9c13f2c52 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlows.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlows.kt @@ -5,6 +5,8 @@ import com.flipcash.app.core.AppRoute import com.getcode.opencode.utils.base64 import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -40,6 +42,20 @@ object EmailVerificationFlow { clientData = Json.encodeToString(data) } + private val _pendingCode = MutableSharedFlow>(extraBufferCapacity = 1) + + /** Observed by [EmailVerificationViewModel] to receive codes from deeplinks. */ + val pendingCode = _pendingCode.asSharedFlow() + + /** + * Deliver a verification code to the already-open verification screen. + * Returns true if the screen is active and will receive the code. + */ + fun deliverCode(email: String, code: String): Boolean { + if (_pendingCode.subscriptionCount.value == 0) return false + return _pendingCode.tryEmit(email to code) + } + @OptIn(ExperimentalUuidApi::class) fun start(source: EmailDeeplinkOrigin?) { this.source = source diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt index 3a10790f4..18b12a283 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt @@ -105,6 +105,11 @@ class EmailVerificationViewModel @Inject constructor( }.onEach { handleSendVerificationCode(it) } .launchIn(viewModelScope) + // Receive verification codes delivered by deeplinks while this screen is already open + EmailVerificationFlow.pendingCode + .onEach { (email, code) -> dispatchEvent(Event.OnDataProvided(email, code)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { (email, code) -> diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt index 06c071b8b..ecdfa9783 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt @@ -31,7 +31,7 @@ fun AccessKeyScreen() { if (requiresIap) { navigator.push(AppRoute.Onboarding.Purchase()) } else { - navigator.replaceAll(AppRoute.Main.Scanner()) + navigator.replaceAll(AppRoute.Main.Scanner) } } } 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 3bb41a519..f1959ba2a 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 @@ -34,7 +34,7 @@ fun LoginRouter( vm.eventFlow .filterIsInstance() .onEach { delay(500) } - .onEach { navigator.replaceAll(AppRoute.Main.Scanner()) } + .onEach { navigator.replaceAll(AppRoute.Main.Scanner) } .launchIn(this) } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt index aaa9122ff..d08151c5f 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt @@ -134,7 +134,7 @@ class SeedInputViewModel @Inject constructor( navigator.push(AppRoute.Onboarding.Purchase(true)) } - else -> navigator.replaceAll(AppRoute.Main.Scanner()) + else -> navigator.replaceAll(AppRoute.Main.Scanner) } } diff --git a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt index 6024f558e..7e4e1c073 100644 --- a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt +++ b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountScreenContent.kt @@ -54,7 +54,7 @@ internal fun PurchaseAccountScreen(viewModel: PurchaseAccountViewModel) { viewModel.eventFlow .filterIsInstance() .onEach { - navigator.replaceAll(AppRoute.Main.Scanner()) + navigator.replaceAll(AppRoute.Main.Scanner) }.launchIn(this) } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt index 2d840e94b..2147d3daf 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/ScannerScreen.kt @@ -1,10 +1,9 @@ package com.flipcash.app.scanner import androidx.compose.runtime.Composable -import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.scanner.internal.Scanner @Composable -fun ScannerScreen(deepLink: DeeplinkType? = null) { - Scanner(deepLink) +fun ScannerScreen() { + Scanner() } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt deleted file mode 100644 index 38d975cf1..000000000 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.flipcash.app.scanner.internal - -import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.navigateTo -import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.app.core.onramp.deeplinks.OnRampDeeplinkOrigin -import com.flipcash.app.core.tokens.TokenSwapPurpose -import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin -import com.getcode.navigation.core.CodeNavigator -import com.getcode.solana.keys.Mint -import com.getcode.ui.core.scaled -import kotlinx.coroutines.delay - -class NavigationStateRestorer( - private val navigator: CodeNavigator, - private val analytics: FlipcashAnalyticsService, -) { - suspend fun restoreState(deeplink: DeeplinkType.Navigatable, animationScale: Float) { - when (deeplink) { - is DeeplinkType.TokenInfo -> { - delay(200.scaled(animationScale)) - navigator.navigateTo(AppRoute.Sheets.Wallet) - navigator.push(AppRoute.Token.Info(deeplink.mint, fromDeeplink = true)) - } - - - is DeeplinkType.ExternalWalletStep -> { - val screens = when (val origin = deeplink.origin) { - OnRampDeeplinkOrigin.Menu -> buildOnRampScreenFlow(AppRoute.Sheets.Menu) + AppRoute.OnRamp.AmountEntry - is OnRampDeeplinkOrigin.Give -> buildOnRampScreenFlow(AppRoute.Sheets.Give(origin.tokenAddress)) + AppRoute.OnRamp.AmountEntry - OnRampDeeplinkOrigin.Wallet -> buildOnRampScreenFlow(AppRoute.Sheets.Wallet) + AppRoute.OnRamp.AmountEntry - OnRampDeeplinkOrigin.Reserves -> buildOnRampScreenFlow(AppRoute.Token.Info(Mint.usdf)) + AppRoute.OnRamp.AmountEntry - is OnRampDeeplinkOrigin.TokenInfo -> listOf( - AppRoute.Sheets.Wallet, - AppRoute.Token.Info(origin.mint), - AppRoute.Token.SwapTransact(TokenSwapPurpose.FundWithWallet(origin.mint)) - ) - } - - navigator.navigateTo(screens) - } - - is DeeplinkType.EmailVerification -> { - val origin = EmailDeeplinkOrigin.deserialize(deeplink.origin.orEmpty()) - val screens = when (origin) { - is EmailDeeplinkOrigin.OnRamp -> when (val source = origin.source) { - is AppRoute.Sheets.Menu -> { - buildOnRampScreenFlow(source) + AppRoute.Verification( - origin = source, - target = AppRoute.OnRamp.AmountEntry, - includePhone = false, - email = deeplink.email, - emailVerificationCode = deeplink.code - ) - } - else -> emptyList() - } - - EmailDeeplinkOrigin.MyAccount -> - listOf( - AppRoute.Sheets.Menu, - AppRoute.Menu.MyAccount - ) + AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, - target = null, - includePhone = false, - email = deeplink.email, - emailVerificationCode = deeplink.code - ) - - null -> emptyList() - } - - if (screens.isNotEmpty()) { - analytics.deeplinkRouted(deeplink) - navigator.navigateTo(screens) - } else { - analytics.deeplinkRouted(deeplink, IllegalStateException("Failed to route deeplink")) - } - } - } - } -} - -private fun buildOnRampScreenFlow(origin: List) = - origin.dropLast(1) + - AppRoute.OnRamp.ProviderList(origin.last()) - -private fun buildOnRampScreenFlow(origin: AppRoute) = buildOnRampScreenFlow(listOf(origin)) 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 bd6157fb8..032498fc3 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 @@ -10,10 +10,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.lifecycle.Lifecycle import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.core.extensions.navigateTo -import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.BillContainer import com.flipcash.app.session.LocalSessionController @@ -22,6 +21,7 @@ import com.getcode.libs.code.detection.CodeScanResult import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.opencode.model.financial.orZero +import com.getcode.ui.biometrics.LocalBiometricsState import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.scanner.CodeScanner import com.getcode.ui.scanner.NoCamerasAvailableException @@ -31,9 +31,11 @@ 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.navigation.DeeplinkType @Composable -internal fun Scanner(deepLink: DeeplinkType?) { +internal fun Scanner() { val router = LocalRouter.current!! val navigator = LocalCodeNavigator.current val session = LocalSessionController.current!! @@ -61,21 +63,21 @@ internal fun Scanner(deepLink: DeeplinkType?) { } val context = LocalContext.current - - var deepLinkSaved by remember(deepLink) { - mutableStateOf(deepLink) - } + val focusManager = LocalFocusManager.current + val biometricsState = LocalBiometricsState.current val vibrator = LocalVibrator.current - ScannerDeepLinkHandler( - deepLink = deepLinkSaved, - previewing = previewing, - session = session, - navigator = navigator, - analytics = analytics, - ) { - deepLinkSaved = null + LaunchedEffect(biometricsState, previewing) { + if (previewing == true) { + focusManager.clearFocus() + } + + if (!biometricsState.passed) return@LaunchedEffect + + if (previewing != null) { + session.onCameraScanning(previewing!!) + } } @SuppressLint("LocalContextGetResourceValueCall") @@ -115,13 +117,28 @@ internal fun Scanner(deepLink: DeeplinkType?) { is CodeScanResult.QrCode -> { val urls = result.results val deeplink = urls.firstNotNullOfOrNull { url -> - val type = router.processType(DeepLink(url)) + val type = router.classify(DeepLink(url)) analytics.deeplinkParsed(type, url) type } if (deeplink != null) { vibrator.vibrate(duration = 50) - deepLinkSaved = deeplink + when (deeplink) { + is DeeplinkType.CashLink -> session.openCashLink(deeplink.entropy) + is DeeplinkType.Navigatable -> { + val routes = when (deeplink) { + is DeeplinkType.TokenInfo -> listOf( + com.flipcash.app.core.AppRoute.Sheets.Wallet, + com.flipcash.app.core.AppRoute.Token.Info(deeplink.mint, fromDeeplink = true) + ) + else -> emptyList() + } + if (routes.isNotEmpty()) { + navigator.navigateTo(routes) + } + } + is DeeplinkType.Login -> Unit + } } } is KikCodeResult -> { diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDeepLinkHandler.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDeepLinkHandler.kt deleted file mode 100644 index b64b53861..000000000 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDeepLinkHandler.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.flipcash.app.scanner.internal - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalFocusManager -import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.app.session.SessionController -import com.getcode.navigation.core.CodeNavigator -import com.getcode.ui.biometrics.LocalBiometricsState -import com.getcode.ui.core.rememberAnimationScale - -@Composable -internal fun ScannerDeepLinkHandler( - deepLink: DeeplinkType?, - previewing: Boolean?, - session: SessionController, - navigator: CodeNavigator, - analytics: FlipcashAnalyticsService, - onDeeplinkHandled: () -> Unit, -) { - - val focusManager = LocalFocusManager.current - val biometricsState = LocalBiometricsState.current - - val animationScale by rememberAnimationScale() - - val stateRestorer = remember(navigator, analytics) { - NavigationStateRestorer(navigator, analytics) - } - - LaunchedEffect(biometricsState, previewing) { - if (previewing == true) { - focusManager.clearFocus() - } - - if (!biometricsState.passed) return@LaunchedEffect - - if (previewing != null) { - session.onCameraScanning(previewing) - } - } - - LaunchedEffect(deepLink, biometricsState.passed) { - if (!biometricsState.passed) return@LaunchedEffect - - val link = deepLink ?: return@LaunchedEffect - - when (link) { - is DeeplinkType.CashLink -> { - session.openCashLink(link.entropy) - } - is DeeplinkType.Login -> Unit - is DeeplinkType.Navigatable -> { - stateRestorer.restoreState(link, animationScale) - } - } - - onDeeplinkHandled() - } -} \ No newline at end of file diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt index dacdcfb77..6fd389949 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuySellEntryScreen.kt @@ -93,4 +93,14 @@ fun TokenBuySellEntryScreen( navigator.push(AppRoute.Token.TxProcessing(swapId)) }.launchIn(this) } + + // Navigate to pending routes from ExternalWalletOnRampHandler using the + // sheet's inner navigator (which the handler can't access directly). + val pendingNav = externalWalletOnRamp.pendingNavigation + LaunchedEffect(pendingNav) { + if (pendingNav is AppRoute.Token.TxProcessing) { + navigator.push(pendingNav) + externalWalletOnRamp.pendingNavigation = null + } + } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 2d4add1e0..4ee34def3 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -125,5 +125,15 @@ fun TokenInfoScreen( externalWalletOnRamp.start(OnRampFlowTracker.source, OnRampProvider.Phantom) }.launchIn(this) } + + // Navigate to pending routes from ExternalWalletOnRampHandler using the + // sheet's inner navigator (which the handler can't access directly). + val pendingNav = externalWalletOnRamp.pendingNavigation + LaunchedEffect(pendingNav) { + if (pendingNav is AppRoute.Token.SwapTransact) { + navigator.push(pendingNav) + externalWalletOnRamp.pendingNavigation = null + } + } } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt index 0c4ae748c..e472aef1f 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt @@ -20,7 +20,7 @@ import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.ui.utils.DisableSheetGestures import com.getcode.view.LoadingSuccessState import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -45,9 +45,15 @@ fun TokenTxProcessingScreen( if (awaitExternalWallet) { val externalWalletState = LocalExternalWalletState.current LaunchedEffect(viewModel, swapId) { - // Wait for the transaction to be submitted before starting swap polling - snapshotFlow { externalWalletState.deeplinkState } - .first { it == ExternalWalletState.TRANSACTED } + // Wait for the transaction to be submitted or cancelled/errored + val terminalState = snapshotFlow { externalWalletState.deeplinkState } + .firstOrNull { it == ExternalWalletState.TRANSACTED || it == ExternalWalletState.IDLE } + + if (terminalState != ExternalWalletState.TRANSACTED) { + // User cancelled or error occurred — pop back to previous screen + navigator.pop() + return@LaunchedEffect + } externalWalletState.reset() viewModel.dispatchEvent(Event.OnSwapIdChanged(swapId)) @@ -57,7 +63,7 @@ fun TokenTxProcessingScreen( // the indicator's remember(processingState) won't reset, so the timer // and progress continue seamlessly with no jump. snapshotFlow { viewModel.stateFlow.value.processingProgress } - .first { it.loading } + .firstOrNull { it.loading } awaitingWallet = false } diff --git a/apps/flipcash/shared/onramp/deeplinks/build.gradle.kts b/apps/flipcash/shared/onramp/deeplinks/build.gradle.kts index f735ff42d..815a3227e 100644 --- a/apps/flipcash/shared/onramp/deeplinks/build.gradle.kts +++ b/apps/flipcash/shared/onramp/deeplinks/build.gradle.kts @@ -10,7 +10,6 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(project(":apps:flipcash:shared:analytics")) - implementation(project(":apps:flipcash:shared:router")) implementation(project(":libs:crypto:solana")) implementation(project(":libs:messaging")) } diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt index 05c33c813..ff1dd0382 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt @@ -5,11 +5,7 @@ import android.annotation.SuppressLint import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.Lifecycle @@ -20,14 +16,12 @@ import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.android.extensions.canNativelyHandle -import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.tokens.TokenSwapPurpose import com.flipcash.app.onramp.internal.ExternalWalletDeeplinkState import com.flipcash.app.onramp.internal.ExternalWalletState import com.flipcash.app.onramp.internal.buildConnectDeeplink import com.flipcash.app.onramp.internal.buildTransactionDeeplink import com.flipcash.app.onramp.internal.packageName -import com.flipcash.app.router.Router import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.shared.onramp.deeplinks.R import com.getcode.libs.analytics.LocalAnalytics @@ -39,7 +33,6 @@ import com.getcode.util.permissions.LocalPermissionChecker import com.getcode.util.permissions.notificationPermissionCheck import com.getcode.utils.TraceType import com.getcode.utils.trace -import dev.theolm.rinku.DeepLink import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -51,8 +44,6 @@ import kotlin.to fun ExternalWalletOnRampHandler( state: ExternalWalletDeeplinkState, navigator: CodeNavigator, - router: Router, - deepLink: DeepLink?, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, content: @Composable () -> Unit ) { @@ -61,6 +52,11 @@ fun ExternalWalletOnRampHandler( val analytics = rememberAnalytics() fun close(exit: Boolean) { + if (state.origin is AppRoute.Token.Info) { + // Token flow screens handle their own navigation via inner navigator + return + } + if (exit) { composeScope.launch { delay(300) @@ -76,8 +72,6 @@ fun ExternalWalletOnRampHandler( } } ?: run { navigator.popAll() } } - var preNavigatedToEntry by remember { mutableStateOf(false) } - var preNavigatedToProcessing by remember { mutableStateOf(false) } val uriHandler = LocalUriHandler.current val context = LocalContext.current @@ -134,31 +128,6 @@ fun ExternalWalletOnRampHandler( }.launchIn(this) } - LaunchedEffect(deepLink) { - val type = router.processType(deepLink) - if (type is DeeplinkType.ExternalWalletConnection) { - val result = type.result - val error = type.error - if (result != null) { - state.decrypt(connectionResult = result) - } else { - val resolvedError = DeeplinkError.fromCode(error?.errorCode) - val message = error?.errorMessage ?: "Something went wrong" - state.errors.emit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) - } - } else if (type is DeeplinkType.ExternalWalletSignedTransaction) { - val result = type.result - val error = type.error - if (result != null) { - state.decrypt(signingResult = result) - } else { - val resolvedError = DeeplinkError.fromCode(error?.errorCode) - val message = error?.errorMessage ?: "Something went wrong" - state.errors.emit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) - } - } - } - LaunchedEffect(state.deeplinkState, state.amount) { when (state.deeplinkState) { ExternalWalletState.IDLE -> Unit @@ -173,12 +142,9 @@ fun ExternalWalletOnRampHandler( if (uri?.canNativelyHandle(context) == true) { val origin = state.origin if (origin is AppRoute.Token.Info) { - preNavigatedToEntry = true - navigator.push( - AppRoute.Token.SwapTransact( - TokenSwapPurpose.FundWithWallet(origin.mint), - forNeededFunds = origin.forNeededFunds - ) + state.pendingNavigation = AppRoute.Token.SwapTransact( + TokenSwapPurpose.FundWithWallet(origin.mint), + forNeededFunds = origin.forNeededFunds ) } @@ -211,22 +177,12 @@ fun ExternalWalletOnRampHandler( message = "wallet connected", type = TraceType.Process ) - if (preNavigatedToEntry) { - // Already pushed SwapTransact at STARTED - preNavigatedToEntry = false - } else { - when (val origin = state.origin) { - is AppRoute.Token.Info -> { - navigator.push( - AppRoute.Token.SwapTransact( - TokenSwapPurpose.FundWithWallet(origin.mint), - forNeededFunds = origin.forNeededFunds - ) - ) - } - else -> { - navigator.push(AppRoute.OnRamp.AmountEntry) - } + when (state.origin) { + is AppRoute.Token.Info -> { + // SwapTransact already navigated via pendingNavigation at STARTED + } + else -> { + navigator.push(AppRoute.OnRamp.AmountEntry) } } } @@ -247,9 +203,8 @@ fun ExternalWalletOnRampHandler( val swapId = state.swapId if (state.origin is AppRoute.Token.Info && swapId != null) { - preNavigatedToProcessing = true - navigator.push( - AppRoute.Token.TxProcessing(swapId, awaitExternalWallet = true) + state.pendingNavigation = AppRoute.Token.TxProcessing( + swapId, awaitExternalWallet = true ) } @@ -282,9 +237,8 @@ fun ExternalWalletOnRampHandler( ) analytics.transactionSubmittedToWallet(state.provider!!) - if (preNavigatedToProcessing) { + if (state.origin is AppRoute.Token.Info) { // TxProcessingScreen observes TRANSACTED, calls reset() and dispatches OnSwapIdChanged - preNavigatedToProcessing = false return@LaunchedEffect } diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt index 0543c6e65..51c2d140a 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt @@ -6,10 +6,12 @@ import androidx.compose.runtime.setValue import com.flipcash.app.core.AppRoute import com.flipcash.app.core.encryption.boxOpen import com.flipcash.app.core.encryption.toPublicKey +import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.onramp.deeplinks.ExternalWalletConnection import com.flipcash.app.core.onramp.deeplinks.ExternallySignedTransaction import com.flipcash.app.core.onramp.deeplinks.WalletDeeplinkConnectionResult import com.flipcash.app.core.onramp.deeplinks.WalletDeeplinkSigningResult +import com.flipcash.app.onramp.DeeplinkError import com.flipcash.app.onramp.DeeplinkOnRampError import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.UserManager @@ -71,6 +73,12 @@ class ExternalWalletDeeplinkState( internal var provider: OnRampProvider.UsesDeeplinks? = null + /** + * Pending navigation route for screens to consume. + * Used by inner (sheet) navigators that the handler can't access directly. + */ + var pendingNavigation: AppRoute? by mutableStateOf(null) + internal val keyPair = Box.keypair() val curvePublicKey: String? get() = keyPair.publicKey.toPublicKey().base58() @@ -100,7 +108,8 @@ class ExternalWalletDeeplinkState( internal var signedTransaction: String? = null internal var signature: Signature? = null - internal var swapId: SwapId? = null + var swapId: SwapId? = null + internal set /** * The public key of the encryption key used by the external wallet @@ -275,6 +284,37 @@ class ExternalWalletDeeplinkState( deeplinkState = ExternalWalletState.STARTED } + /** + * Handle a pre-parsed external wallet deeplink type dispatched from the router. + */ + fun handleWalletDeeplink(type: DeeplinkType) { + when (type) { + is DeeplinkType.ExternalWalletConnection -> { + val result = type.result + val error = type.error + if (result != null) { + decrypt(connectionResult = result) + } else { + val resolvedError = DeeplinkError.fromCode(error?.errorCode) + val message = error?.errorMessage ?: "Something went wrong" + errors.tryEmit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) + } + } + is DeeplinkType.ExternalWalletSignedTransaction -> { + val result = type.result + val error = type.error + if (result != null) { + decrypt(signingResult = result) + } else { + val resolvedError = DeeplinkError.fromCode(error?.errorCode) + val message = error?.errorMessage ?: "Something went wrong" + errors.tryEmit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) + } + } + else -> {} + } + } + /** * Reset the state of the onramp flow */ @@ -282,6 +322,7 @@ class ExternalWalletDeeplinkState( origin = null provider = null deeplinkState = ExternalWalletState.IDLE + pendingNavigation = null phantomEncryptionPublicKey = null amount = null walletConnection = null diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt index 13df5ffc6..c63ef3007 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/PermissionScreenContent.kt @@ -60,10 +60,10 @@ internal fun PermissionScreenContent( if (postCreate) { analytics.action(Action.CompletedOnboarding) } - navigator.replaceAll(AppRoute.Main.Scanner()) + navigator.replaceAll(AppRoute.Main.Scanner) }, onNotGranted = { - navigator.replaceAll(AppRoute.Main.Scanner()) + navigator.replaceAll(AppRoute.Main.Scanner) } ) @@ -74,7 +74,7 @@ internal fun PermissionScreenContent( if (postCreate) { analytics.action(Action.CompletedOnboarding) } - navigator.replaceAll(AppRoute.Main.Scanner()) + navigator.replaceAll(AppRoute.Main.Scanner) } } } diff --git a/apps/flipcash/shared/router/build.gradle.kts b/apps/flipcash/shared/router/build.gradle.kts index be7943690..779241124 100644 --- a/apps/flipcash/shared/router/build.gradle.kts +++ b/apps/flipcash/shared/router/build.gradle.kts @@ -8,6 +8,8 @@ android { dependencies { api(project(":ui:navigation")) - implementation(project(":apps:flipcash:shared:analytics")) api(libs.rinku.compose) + + testImplementation(kotlin("test")) + testImplementation(libs.robolectric) } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt index 11e606b19..0111d0a0e 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt @@ -1,13 +1,16 @@ package com.flipcash.app.router import androidx.compose.runtime.staticCompositionLocalOf -import androidx.navigation3.runtime.NavKey +import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import dev.theolm.rinku.DeepLink interface Router { - suspend fun processDestination(deeplink: DeepLink?): List - fun processType(deeplink: DeepLink?): DeeplinkType? + /** Parse + classify + resolve routes in one call. Called exactly once per deeplink. */ + fun dispatch(deepLink: DeepLink): DeeplinkAction + + /** Classify a URL (for QR code scanning). Does not resolve routes. */ + fun classify(deepLink: DeepLink): DeeplinkType? } val LocalRouter = staticCompositionLocalOf { null } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt index 6b30be2b9..9b44eeb8c 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt @@ -1,6 +1,5 @@ package com.flipcash.app.router.inject -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.router.internal.AppRouter import com.flipcash.app.router.Router import com.flipcash.services.user.UserManager @@ -18,6 +17,5 @@ object RouterModule { @Provides fun providesRouter( userManager: UserManager, - analytics: FlipcashAnalyticsService, - ): Router = AppRouter(userManager, analytics) -} \ No newline at end of file + ): Router = AppRouter(authStateProvider = { userManager.authState }) +} diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index eb1f91732..3c32bbc6b 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -1,9 +1,8 @@ package com.flipcash.app.router.internal import androidx.core.net.toUri -import androidx.navigation3.runtime.NavKey -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.navigation.Key import com.flipcash.app.core.navigation.fragments @@ -11,6 +10,7 @@ import com.flipcash.app.core.onramp.deeplinks.ExternalWalletDeeplinkError import com.flipcash.app.core.onramp.deeplinks.OnRampDeeplinkOrigin import com.flipcash.app.core.onramp.deeplinks.WalletDeeplinkConnectionResult import com.flipcash.app.core.onramp.deeplinks.WalletDeeplinkSigningResult +import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin import com.flipcash.app.router.Router import com.flipcash.app.router.internal.AppRouter.Companion.cashLink import com.flipcash.app.router.internal.AppRouter.Companion.external @@ -18,20 +18,16 @@ import com.flipcash.app.router.internal.AppRouter.Companion.login import com.flipcash.app.router.internal.AppRouter.Companion.token import com.flipcash.app.router.internal.AppRouter.Companion.verification import com.flipcash.services.user.AuthState -import com.flipcash.services.user.UserManager import com.getcode.solana.keys.Mint import com.getcode.utils.decodeBase58 import com.getcode.utils.decodeBase64 import com.getcode.utils.urlDecode import dev.theolm.rinku.DeepLink -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import org.json.JSONObject internal class AppRouter( - private val userManager: UserManager, - private val analytics: FlipcashAnalyticsService, -) : Router, CoroutineScope by CoroutineScope(Dispatchers.IO) { + private val authStateProvider: () -> AuthState, +) : Router { companion object { val login = listOf("login") val cashLink = listOf("c", "cash") @@ -40,75 +36,94 @@ internal class AppRouter( val token = listOf("token") } - override suspend fun processDestination(deeplink: DeepLink?): List { - return deeplink?.let { - val type = processType(deeplink) ?: return emptyList() - when (type) { - is DeeplinkType.Login -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(AppRoute.Main.Scanner(type)) - } else { - listOf(AppRoute.Onboarding.Login(type.entropy, true)) - } - } - is DeeplinkType.CashLink -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(AppRoute.Main.Scanner(type)) - } else { - listOf(AppRoute.Onboarding.Login()) - } - } + override fun dispatch(deepLink: DeepLink): DeeplinkAction { + val type = classify(deepLink) ?: return DeeplinkAction.None - is DeeplinkType.ExternalWalletConnection -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(AppRoute.Main.Scanner()) - } else { - listOf(AppRoute.Onboarding.Login()) - } - } + // Not logged in — redirect to login (or login deeplink itself) + if (authStateProvider() !is AuthState.LoggedInWithUser) { + return when (type) { + is DeeplinkType.Login -> DeeplinkAction.Navigate( + listOf(AppRoute.Onboarding.Login(type.entropy, fromDeeplink = true)) + ) + else -> DeeplinkAction.Navigate(listOf(AppRoute.Onboarding.Login())) + } + } - is DeeplinkType.ExternalWalletSignedTransaction -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(AppRoute.Main.Scanner()) - } else { - listOf(AppRoute.Onboarding.Login()) - } - } + // Logged in — resolve action + return when (type) { + is DeeplinkType.Login -> DeeplinkAction.Login(type.entropy) - is DeeplinkType.EmailVerification -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(AppRoute.Main.Scanner(type)) - } else { - listOf(AppRoute.Onboarding.Login()) - } - } + is DeeplinkType.CashLink -> DeeplinkAction.OpenCashLink(type.entropy) - is DeeplinkType.TokenInfo -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(AppRoute.Main.Scanner(type)) - } else { - listOf(AppRoute.Onboarding.Login()) - } - } - } - }.orEmpty() + is DeeplinkType.TokenInfo -> DeeplinkAction.Navigate( + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(type.mint, fromDeeplink = true)) + ) + + is DeeplinkType.ExternalWalletConnection, + is DeeplinkType.ExternalWalletSignedTransaction -> + DeeplinkAction.ExternalWallet(type) + + is DeeplinkType.EmailVerification -> resolveEmailVerification(type) + } } - override fun processType(deeplink: DeepLink?): DeeplinkType? { - return deeplink?.let { - when { - deeplink.isLogin() -> deeplink.handleLoginLink() - deeplink.isCashLink() -> deeplink.handleCashLink() - deeplink.isToken() -> deeplink.handleTokenLink() - deeplink.isExternalWalletConnection() -> deeplink.handleWalletConnect() - deeplink.isExternalWalletSignedTransaction() -> deeplink.handleWalletSignedTransaction() - deeplink.isEmailVerification() -> deeplink.handleEmailVerification() - else -> null + override fun classify(deepLink: DeepLink): DeeplinkType? { + return when { + deepLink.isLogin() -> deepLink.handleLoginLink() + deepLink.isCashLink() -> deepLink.handleCashLink() + deepLink.isToken() -> deepLink.handleTokenLink() + deepLink.isExternalWalletConnection() -> deepLink.handleWalletConnect() + deepLink.isExternalWalletSignedTransaction() -> deepLink.handleWalletSignedTransaction() + deepLink.isEmailVerification() -> deepLink.handleEmailVerification() + else -> null + } + } + + private fun resolveEmailVerification(type: DeeplinkType.EmailVerification): DeeplinkAction { + val origin = EmailDeeplinkOrigin.deserialize(type.origin.orEmpty()) + val routes: List = when (origin) { + is EmailDeeplinkOrigin.OnRamp -> when (val source = origin.source) { + is AppRoute.Sheets.Menu -> { + buildOnRampScreenFlow(source) + AppRoute.Verification( + origin = source, + target = AppRoute.OnRamp.AmountEntry, + includePhone = false, + email = type.email, + emailVerificationCode = type.code + ) + } + else -> emptyList() } + + EmailDeeplinkOrigin.MyAccount -> + listOf( + AppRoute.Sheets.Menu, + AppRoute.Menu.MyAccount + ) + AppRoute.Verification( + origin = AppRoute.Menu.MyAccount, + target = null, + includePhone = false, + email = type.email, + emailVerificationCode = type.code + ) + + null -> emptyList() + } + + return if (routes.isNotEmpty()) { + DeeplinkAction.Navigate(routes) + } else { + DeeplinkAction.None } } } +private fun buildOnRampScreenFlow(origin: List) = + origin.dropLast(1) + + AppRoute.OnRamp.ProviderList(origin.last()) + +private fun buildOnRampScreenFlow(origin: AppRoute) = buildOnRampScreenFlow(listOf(origin)) + private fun DeepLink.isLogin(): Boolean = login.contains(pathSegments[0]) private fun DeepLink.isCashLink(): Boolean = cashLink.contains(pathSegments[0]) private fun DeepLink.isToken(): Boolean = token.contains(pathSegments[0]) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt new file mode 100644 index 000000000..819b0ac9a --- /dev/null +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -0,0 +1,482 @@ +package com.flipcash.app.router.internal + +import android.util.Base64 +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.DeeplinkAction +import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.core.onramp.deeplinks.OnRampDeeplinkOrigin +import com.flipcash.services.user.AuthState +import com.getcode.solana.keys.Mint +import com.getcode.vendor.Base58 +import dev.theolm.rinku.DeepLink +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.net.URLEncoder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AppRouterTest { + + private var authState: AuthState = AuthState.LoggedInWithUser + + private val router = AppRouter(authStateProvider = { authState }) + + private fun loggedIn() { authState = AuthState.LoggedInWithUser } + private fun loggedOut() { authState = AuthState.LoggedOut } + + // region classify — Login + + @Test + fun `classify recognizes login deeplink with entropy fragment`() { + val type = router.classify(DeepLink("https://app.flipcash.com/login/e=abc123")) + assertIs(type) + assertEquals("abc123", type.entropy) + } + + @Test + fun `classify recognizes login deeplink with data query param`() { + val type = router.classify(DeepLink("https://app.flipcash.com/login?data=xyz789")) + assertIs(type) + assertEquals("xyz789", type.entropy) + } + + @Test + fun `classify returns null for login without entropy`() { + val type = router.classify(DeepLink("https://app.flipcash.com/login")) + assertNull(type) + } + + // endregion + + // region classify — CashLink + + @Test + fun `classify recognizes cash link with c prefix`() { + val type = router.classify(DeepLink("https://app.flipcash.com/c/e=someEntropy")) + assertIs(type) + assertEquals("someEntropy", type.entropy) + } + + @Test + fun `classify recognizes cash link with cash prefix`() { + val type = router.classify(DeepLink("https://app.flipcash.com/cash/e=someEntropy")) + assertIs(type) + assertEquals("someEntropy", type.entropy) + } + + @Test + fun `classify returns null for cash link without entropy`() { + val type = router.classify(DeepLink("https://app.flipcash.com/c/other")) + assertNull(type) + } + + // endregion + + // region classify — TokenInfo + + @Test + fun `classify recognizes token info deeplink`() { + val mintAddress = "So11111111111111111111111111111111111111112" + val type = router.classify(DeepLink("https://app.flipcash.com/token/$mintAddress")) + assertIs(type) + assertEquals(Mint(mintAddress), type.mint) + } + + // endregion + + // region classify — ExternalWallet + + @Test + fun `classify recognizes external wallet connection`() { + val pubKey = Base58.encode(ByteArray(32) { 1 }) + val nonce = Base58.encode(ByteArray(24) { 2 }) + val data = Base58.encode(ByteArray(48) { 3 }) + + val url = "https://app.flipcash.com/external/phantom/connected" + + "?origin=menu" + + "&phantom_encryption_public_key=$pubKey" + + "&nonce=$nonce" + + "&data=$data" + + val type = router.classify(DeepLink(url)) + assertIs(type) + assertEquals(OnRampDeeplinkOrigin.Menu, type.origin) + assertTrue(type.result != null) + assertNull(type.error) + } + + @Test + fun `classify recognizes external wallet connection error`() { + val url = "https://app.flipcash.com/external/phantom/connected" + + "?origin=menu" + + "&errorCode=4001" + + "&errorMessage=User+rejected+request" + + val type = router.classify(DeepLink(url)) + assertIs(type) + assertNull(type.result) + assertEquals("4001", type.error?.errorCode) + } + + @Test + fun `classify recognizes external wallet signed transaction`() { + val nonce = Base58.encode(ByteArray(24) { 5 }) + val data = Base58.encode(ByteArray(64) { 6 }) + + val url = "https://app.flipcash.com/external/phantom/signed" + + "?origin=menu" + + "&nonce=$nonce" + + "&data=$data" + + val type = router.classify(DeepLink(url)) + assertIs(type) + assertEquals(OnRampDeeplinkOrigin.Menu, type.origin) + assertTrue(type.result != null) + } + + @Test + fun `classify recognizes wallet origin`() { + val nonce = Base58.encode(ByteArray(24) { 5 }) + val data = Base58.encode(ByteArray(64) { 6 }) + + val url = "https://app.flipcash.com/external/phantom/signed" + + "?origin=wallet" + + "&nonce=$nonce" + + "&data=$data" + + val type = router.classify(DeepLink(url)) + assertIs(type) + assertEquals(OnRampDeeplinkOrigin.Wallet, type.origin) + } + + // endregion + + // region classify — EmailVerification + + @Test + fun `classify recognizes email verification deeplink`() { + val type = router.classify( + DeepLink("https://app.flipcash.com/verify?email=test%40example.com&code=123456") + ) + assertIs(type) + assertEquals("test@example.com", type.email) + assertEquals("123456", type.code) + } + + @Test + fun `classify returns null for verify without email`() { + val type = router.classify(DeepLink("https://app.flipcash.com/verify?code=123456")) + assertNull(type) + } + + @Test + fun `classify parses email verification with client data origin`() { + val origin = Base64.encodeToString("myaccount".toByteArray(), Base64.NO_WRAP) + val clientData = """{"origin":"$origin"}""" + val url = "https://app.flipcash.com/verify" + + "?email=test%40example.com" + + "&code=123456" + + "&client_data=${URLEncoder.encode(clientData, "UTF-8")}" + + val type = router.classify(DeepLink(url)) + assertIs(type) + assertEquals("myaccount", type.origin) + } + + // endregion + + // region classify — Unknown + + @Test + fun `classify returns null for unknown path`() { + val type = router.classify(DeepLink("https://app.flipcash.com/unknown/path")) + assertNull(type) + } + + // endregion + + // region dispatch — Not logged in + + @Test + fun `dispatch redirects login deeplink to onboarding with entropy when logged out`() { + loggedOut() + val action = router.dispatch(DeepLink("https://app.flipcash.com/login/e=seed123")) + assertIs(action) + val route = action.routes.single() + assertIs(route) + assertEquals("seed123", route.seed) + assertTrue(route.fromDeeplink) + } + + @Test + fun `dispatch redirects non-login deeplink to plain login when logged out`() { + loggedOut() + val mintAddress = "So11111111111111111111111111111111111111112" + val action = router.dispatch(DeepLink("https://app.flipcash.com/token/$mintAddress")) + assertIs(action) + val route = action.routes.single() + assertIs(route) + assertNull(route.seed) + } + + @Test + fun `dispatch redirects cash link to login when auth state is unknown`() { + authState = AuthState.Unknown + val action = router.dispatch(DeepLink("https://app.flipcash.com/c/e=entropy")) + assertIs(action) + assertIs(action.routes.single()) + } + + @Test + fun `dispatch redirects external wallet to login when logged out`() { + loggedOut() + val pubKey = Base58.encode(ByteArray(32) { 1 }) + val nonce = Base58.encode(ByteArray(24) { 2 }) + val data = Base58.encode(ByteArray(48) { 3 }) + + val url = "https://app.flipcash.com/external/phantom/connected" + + "?origin=menu" + + "&phantom_encryption_public_key=$pubKey" + + "&nonce=$nonce" + + "&data=$data" + + val action = router.dispatch(DeepLink(url)) + assertIs(action) + assertIs(action.routes.single()) + } + + // endregion + + // region dispatch — Logged in: Login + + @Test + fun `dispatch returns Login action for login deeplink when logged in`() { + loggedIn() + val action = router.dispatch(DeepLink("https://app.flipcash.com/login/e=seed456")) + assertIs(action) + assertEquals("seed456", action.entropy) + } + + // endregion + + // region dispatch — Logged in: CashLink + + @Test + fun `dispatch returns OpenCashLink for cash deeplink when logged in`() { + loggedIn() + val action = router.dispatch(DeepLink("https://app.flipcash.com/c/e=cashEntropy")) + assertIs(action) + assertEquals("cashEntropy", action.entropy) + } + + @Test + fun `dispatch returns OpenCashLink for cash prefix deeplink`() { + loggedIn() + val action = router.dispatch(DeepLink("https://app.flipcash.com/cash/e=moreEntropy")) + assertIs(action) + assertEquals("moreEntropy", action.entropy) + } + + // endregion + + // region dispatch — Logged in: TokenInfo (sheet navigation) + + @Test + fun `dispatch returns Navigate with wallet sheet and token info for token deeplink`() { + loggedIn() + val mint = "So11111111111111111111111111111111111111112" + val action = router.dispatch(DeepLink("https://app.flipcash.com/token/$mint")) + assertIs(action) + assertEquals(2, action.routes.size) + assertIs(action.routes[0]) + val tokenInfo = action.routes[1] + assertIs(tokenInfo) + assertEquals(Mint(mint), tokenInfo.mint) + assertTrue(tokenInfo.fromDeeplink) + } + + // endregion + + // region dispatch — Logged in: ExternalWallet + + @Test + fun `dispatch returns ExternalWallet for wallet connection deeplink`() { + loggedIn() + val pubKey = Base58.encode(ByteArray(32) { 1 }) + val nonce = Base58.encode(ByteArray(24) { 2 }) + val data = Base58.encode(ByteArray(48) { 3 }) + + val url = "https://app.flipcash.com/external/phantom/connected" + + "?origin=menu" + + "&phantom_encryption_public_key=$pubKey" + + "&nonce=$nonce" + + "&data=$data" + + val action = router.dispatch(DeepLink(url)) + assertIs(action) + assertIs(action.type) + } + + @Test + fun `dispatch returns ExternalWallet for signed transaction deeplink`() { + loggedIn() + val nonce = Base58.encode(ByteArray(24) { 5 }) + val data = Base58.encode(ByteArray(64) { 6 }) + + val url = "https://app.flipcash.com/external/phantom/signed" + + "?origin=wallet" + + "&nonce=$nonce" + + "&data=$data" + + val action = router.dispatch(DeepLink(url)) + assertIs(action) + assertIs(action.type) + } + + @Test + fun `dispatch returns ExternalWallet even when wallet reports error`() { + loggedIn() + val url = "https://app.flipcash.com/external/phantom/connected" + + "?origin=menu" + + "&errorCode=4001" + + "&errorMessage=User+rejected" + + val action = router.dispatch(DeepLink(url)) + assertIs(action) + val type = action.type + assertIs(type) + assertNull(type.result) + assertEquals("4001", type.error?.errorCode) + } + + // endregion + + // region dispatch — Logged in: EmailVerification (route building) + + @Test + fun `dispatch builds my account routes for email verification`() { + loggedIn() + val origin = Base64.encodeToString("myaccount".toByteArray(), Base64.NO_WRAP) + val clientData = """{"origin":"$origin"}""" + val url = "https://app.flipcash.com/verify" + + "?email=test%40example.com" + + "&code=123456" + + "&client_data=${URLEncoder.encode(clientData, "UTF-8")}" + + val action = router.dispatch(DeepLink(url)) + assertIs(action) + assertEquals(3, action.routes.size) + assertIs(action.routes[0]) + assertIs(action.routes[1]) + val verification = action.routes[2] + assertIs(verification) + assertEquals("test@example.com", verification.email) + assertEquals("123456", verification.emailVerificationCode) + assertEquals(false, verification.includePhone) + } + + @Test + fun `dispatch builds onramp routes for email verification from menu`() { + loggedIn() + val origin = Base64.encodeToString("onramp|menu|null".toByteArray(), Base64.NO_WRAP) + val clientData = """{"origin":"$origin"}""" + val url = "https://app.flipcash.com/verify" + + "?email=user%40mail.com" + + "&code=654321" + + "&client_data=${URLEncoder.encode(clientData, "UTF-8")}" + + val action = router.dispatch(DeepLink(url)) + assertIs(action) + assertEquals(2, action.routes.size) + assertIs(action.routes[0]) + val verification = action.routes[1] + assertIs(verification) + assertEquals("user@mail.com", verification.email) + assertEquals("654321", verification.emailVerificationCode) + } + + @Test + fun `dispatch returns None for email verification with unknown origin`() { + loggedIn() + val origin = Base64.encodeToString("unknown".toByteArray(), Base64.NO_WRAP) + val clientData = """{"origin":"$origin"}""" + val url = "https://app.flipcash.com/verify" + + "?email=test%40example.com" + + "&code=123456" + + "&client_data=${URLEncoder.encode(clientData, "UTF-8")}" + + val action = router.dispatch(DeepLink(url)) + assertIs(action) + } + + @Test + fun `dispatch returns None for email verification without client data`() { + loggedIn() + val action = router.dispatch( + DeepLink("https://app.flipcash.com/verify?email=test%40example.com&code=123456") + ) + assertIs(action) + } + + // endregion + + // region dispatch — None + + @Test + fun `dispatch returns None for unknown deeplink`() { + loggedIn() + val action = router.dispatch(DeepLink("https://app.flipcash.com/unknown")) + assertIs(action) + } + + // endregion + + // region dispatch — Auth state transitions + + @Test + fun `dispatch respects auth state changes between calls`() { + loggedOut() + val loginUrl = "https://app.flipcash.com/login/e=seed" + + // Logged out: should redirect to onboarding + val action1 = router.dispatch(DeepLink(loginUrl)) + assertIs(action1) + assertIs(action1.routes.single()) + + // Log in + loggedIn() + + // Now should return Login action + val action2 = router.dispatch(DeepLink(loginUrl)) + assertIs(action2) + assertEquals("seed", action2.entropy) + } + + // endregion + + // region classify/dispatch consistency + + @Test + fun `dispatch and classify agree on the deeplink type`() { + loggedIn() + val mint = "So11111111111111111111111111111111111111112" + val deepLink = DeepLink("https://app.flipcash.com/token/$mint") + + val classified = router.classify(deepLink) + assertIs(classified) + + val dispatched = router.dispatch(deepLink) + assertIs(dispatched) + val tokenInfo = dispatched.routes[1] + assertIs(tokenInfo) + assertEquals(classified.mint, tokenInfo.mint) + } + + // endregion +} 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 new file mode 100644 index 000000000..12a49b0cf --- /dev/null +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/NavigateToTest.kt @@ -0,0 +1,183 @@ +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.resolveRoutes +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.EmptyCodeNavigator +import com.getcode.navigation.core.NavOptions +import com.getcode.solana.keys.Mint +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class NavigateToTest { + + private val quietOptions = NavOptions(debugRouting = false) + + private fun createNavigator(vararg entries: NavKey): CodeNavigator { + val backStack = NavBackStack(entries.first()) + entries.drop(1).forEach { backStack.add(it) } + return CodeNavigator( + backStack = backStack, + resultStore = EmptyCodeNavigator.resultStore, + onRootReached = {}, + ) + } + + // region Direct navigation (no existing sheet) + + @Test + fun `navigateTo without existing sheet navigates directly`() { + val navigator = createNavigator(AppRoute.Main.Scanner) + val mint = Mint("So11111111111111111111111111111111111111112") + + navigator.navigateTo( + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), + options = quietOptions, + ) + + assertNull(navigator.pendingSheetDismiss) + assertEquals(2, navigator.backStack.size) + assertIs(navigator.backStack.last()) + } + + @Test + fun `navigateTo non-sheet routes navigates directly even with existing sheet`() { + val navigator = createNavigator( + AppRoute.Main.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + ) + + navigator.navigateTo(listOf(AppRoute.Menu.MyAccount), options = quietOptions) + + assertNull(navigator.pendingSheetDismiss) + } + + // endregion + + // region Dismiss-then-replace (existing sheet + new sheet) + + @Test + fun `navigateTo with existing sheet sets pendingSheetDismiss`() { + val navigator = createNavigator( + AppRoute.Main.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + ) + val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + + navigator.navigateTo( + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), + options = quietOptions, + ) + + assertNotNull(navigator.pendingSheetDismiss) + // Backstack unchanged until the callback fires + assertEquals(2, navigator.backStack.size) + } + + @Test + fun `pendingSheetDismiss callback increments sheetGeneration`() { + val navigator = createNavigator( + AppRoute.Main.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + ) + val initialGeneration = navigator.sheetGeneration + + navigator.navigateTo(listOf(AppRoute.Sheets.Wallet), options = quietOptions) + + // Simulate what ModalBottomSheetScene does: remove old sheet, then invoke callback + navigator.backStack.removeAt(navigator.backStack.lastIndex) + navigator.pendingSheetDismiss!!.invoke() + + assertEquals(initialGeneration + 1, navigator.sheetGeneration) + } + + @Test + fun `pendingSheetDismiss callback navigates to new routes`() { + val navigator = createNavigator( + AppRoute.Main.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + ) + val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + + navigator.navigateTo( + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint)), + options = quietOptions, + ) + + // Simulate dismiss: remove old sheet entry, then callback fires + navigator.backStack.removeAt(navigator.backStack.lastIndex) + navigator.pendingSheetDismiss!!.invoke() + + val last = navigator.backStack.last() + assertIs(last) + assertEquals(AppRoute.Sheets.Wallet, last.initialRoute) + assertIs(last.innerRoutes.single()) + } + + @Test + fun `repeated dismiss-then-replace increments generation each time`() { + val navigator = createNavigator( + AppRoute.Main.Scanner, + AppRoute.Main.Sheet(AppRoute.Sheets.Wallet), + ) + + // First replace + navigator.navigateTo(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.backStack.removeAt(navigator.backStack.lastIndex) + navigator.pendingSheetDismiss!!.invoke() + assertEquals(2, navigator.sheetGeneration) + } + + // endregion + + // region Edge cases + + @Test + fun `empty routes is a no-op`() { + val navigator = createNavigator(AppRoute.Main.Scanner) + + navigator.navigateTo(emptyList(), options = quietOptions) + + assertEquals(1, navigator.backStack.size) + assertNull(navigator.pendingSheetDismiss) + } + + @Test + fun `same token dismiss-then-replace works`() { + val mint = Mint("So11111111111111111111111111111111111111112") + val navigator = createNavigator( + AppRoute.Main.Scanner, + AppRoute.Main.Sheet( + AppRoute.Sheets.Wallet, + listOf(AppRoute.Token.Info(mint, fromDeeplink = true)), + ), + ) + + navigator.navigateTo( + listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true)), + options = quietOptions, + ) + + // Should still go through dismiss-then-replace path + assertNotNull(navigator.pendingSheetDismiss) + } + + // endregion +} diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt new file mode 100644 index 000000000..144ffbb79 --- /dev/null +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/ResolveRoutesTest.kt @@ -0,0 +1,129 @@ +package com.flipcash.app.router.internal + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.extensions.resolveRoutes +import com.getcode.solana.keys.Mint +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ResolveRoutesTest { + + // region Empty / non-sheet routes + + @Test + fun `empty list returns empty`() { + assertEquals(emptyList(), resolveRoutes(emptyList())) + } + + @Test + fun `non-sheet routes pass through unchanged`() { + val routes = listOf(AppRoute.Main.Scanner, AppRoute.Menu.MyAccount) + assertEquals(routes, resolveRoutes(routes)) + } + + // endregion + + // region Sheet wrapping + + @Test + fun `single sheet route is wrapped in Main Sheet`() { + val resolved = resolveRoutes(listOf(AppRoute.Sheets.Wallet)) + assertEquals(1, resolved.size) + val sheet = resolved.single() + assertIs(sheet) + assertEquals(AppRoute.Sheets.Wallet, sheet.initialRoute) + assertEquals(emptyList(), sheet.innerRoutes) + } + + @Test + fun `sheet with inner routes bundles into Main Sheet`() { + val mint = Mint("So11111111111111111111111111111111111111112") + val routes = listOf( + AppRoute.Sheets.Wallet, + AppRoute.Token.Info(mint, fromDeeplink = true), + ) + + val resolved = resolveRoutes(routes) + assertEquals(1, resolved.size) + val sheet = resolved.single() + assertIs(sheet) + assertEquals(AppRoute.Sheets.Wallet, sheet.initialRoute) + assertEquals(1, sheet.innerRoutes.size) + assertIs(sheet.innerRoutes[0]) + } + + @Test + fun `sheet with multiple inner routes bundles all`() { + val routes = listOf( + AppRoute.Sheets.Menu, + AppRoute.Menu.MyAccount, + AppRoute.Verification( + origin = AppRoute.Menu.MyAccount, + includePhone = false, + email = "test@example.com", + emailVerificationCode = "123456", + ), + ) + + val resolved = resolveRoutes(routes) + assertEquals(1, resolved.size) + val sheet = resolved.single() + assertIs(sheet) + assertEquals(AppRoute.Sheets.Menu, sheet.initialRoute) + assertEquals(2, sheet.innerRoutes.size) + assertIs(sheet.innerRoutes[0]) + assertIs(sheet.innerRoutes[1]) + } + + @Test + fun `routes before sheet stay on root backstack`() { + val routes = listOf( + AppRoute.Main.Scanner, + AppRoute.Sheets.Wallet, + AppRoute.Token.Info(Mint("So11111111111111111111111111111111111111112")), + ) + + val resolved = resolveRoutes(routes) + assertEquals(2, resolved.size) + assertIs(resolved[0]) + val sheet = resolved[1] + assertIs(sheet) + assertEquals(AppRoute.Sheets.Wallet, sheet.initialRoute) + assertEquals(1, sheet.innerRoutes.size) + } + + // endregion + + // region Equality for stack diffing + + @Test + fun `resolved routes are structurally equal when inputs match`() { + val mint = Mint("So11111111111111111111111111111111111111112") + val routes = listOf( + AppRoute.Sheets.Wallet, + AppRoute.Token.Info(mint, fromDeeplink = true), + ) + + val resolved1 = resolveRoutes(routes) + val resolved2 = resolveRoutes(routes) + assertEquals(resolved1, resolved2) + } + + @Test + fun `resolved routes differ when inner routes differ`() { + val mintA = Mint("So11111111111111111111111111111111111111112") + val mintB = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + + val resolvedA = resolveRoutes(listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mintA))) + val resolvedB = resolveRoutes(listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mintB))) + assert(resolvedA != resolvedB) + } + + // endregion +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d887c5770..786b48b02 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,6 +67,7 @@ androidx-test-runner = "1.7.0" junit = "4.13.2" androidx-junit = "1.3.0" espresso = "3.7.0" +robolectric = "4.14.1" mixpanel = "8.3.0" timber = "5.0.1" @@ -264,6 +265,7 @@ junit = { module = "junit:junit", version.ref = "junit" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" } espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } # Screenshot testing screenshot-validation-api = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "screenshot" } diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt index b0adcbcb3..f3355640f 100644 --- a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Key.kt @@ -55,8 +55,7 @@ open class Key32(bytes: List) : KeyType(bytes), Comparable { override fun equals(other: Any?): Boolean { if (this === other) return true - - other as Key32 + if (other !is Key32) return false return size == other.size && bytes == other.bytes } diff --git a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt index e1adee9ce..f52c2c72e 100644 --- a/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt +++ b/libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/PublicKey.kt @@ -38,8 +38,7 @@ open class PublicKey(bytes: List) : Key32(bytes), Parcelable { override fun equals(other: Any?): Boolean { if (this === other) return true - - other as PublicKey + if (other !is PublicKey) return false return size == other.size && bytes == other.bytes } diff --git a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionCheck.kt b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionCheck.kt index 63872c556..17a27b51d 100644 --- a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionCheck.kt +++ b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionCheck.kt @@ -31,7 +31,7 @@ fun getPermissionLauncher( MockPermissionsLauncher() } else { val context = LocalContext.current - val activity = context as Activity + val activity = context.getActivity()!! DefaultPermissionsLauncher { isGranted: Boolean -> // This block will be triggered after the user chooses to grant or deny the permission diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index 231a2fc09..002cb90dd 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -76,15 +76,17 @@ fun BottomBarContainer(barMessages: BarMessages) { val animationScale by rememberAnimationScale() val onClose: suspend (selection: SelectedBottomBarAction, fromTimeout: Boolean) -> Unit = { selection, fromTimeout -> - bottomBarMessageDismissId = bottomBarMessage?.id ?: 0 + val dismissingMessage = bottomBarMessage + bottomBarMessageDismissId = dismissingMessage?.id ?: 0 + // Remove message immediately so other screens don't re-show it + BottomBarManager.setMessageShown(bottomBarMessageDismissId) bottomBarVisibleState.targetState = false delay(300.scaled(animationScale)) - BottomBarManager.setMessageShown(bottomBarMessageDismissId) if (fromTimeout) { - bottomBarMessage?.onTimeout?.invoke() + dismissingMessage?.onTimeout?.invoke() } else { - bottomBarMessage?.onClose?.invoke(selection) + dismissingMessage?.onClose?.invoke(selection) } } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt index e1ecedd80..ee663de5c 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt @@ -6,10 +6,13 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.navigation3.scene.OverlayScene import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,7 +41,11 @@ fun AppNavHost( resultStateRegistry: NavResultStateRegistry = rememberNavResultStateRegistry(), sceneStrategy: SceneStrategy = SinglePaneSceneStrategy(), transitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = { - fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + if (targetState is OverlayScene<*> || initialState is OverlayScene<*>) { + EnterTransition.None togetherWith ExitTransition.None + } else { + fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + } }, popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = transitionSpec, predictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = { 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 5c3239ec6..98e416e95 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -16,28 +16,39 @@ enum class NavMetadataKeys(val key: String, ) { NavResultKey("navresult_key"), } -inline fun EntryProviderScope.annotatedEntry(noinline content: @Composable (T) -> Unit) { - val metadata = T::class.metadata() - return entry(metadata = metadata, content = content) +/** + * DSL helper: registers an entry whose metadata is derived from [T]'s marker interfaces. + */ +inline fun EntryProviderScope.annotatedEntry( + noinline content: @Composable (T) -> Unit +) { + entry(metadata = T::class.metadata(), content = content) } -inline fun T.metadata(): Map { - val retValType = T::class.supertypes.find { it.classifier == NavigationRetVal::class } +/** + * Compute metadata from a [KClass] by inspecting its marker interfaces. + */ +fun KClass<*>.metadata(): Map { + val retValType = supertypes.find { it.classifier == NavigationRetVal::class } val resultClass = retValType?.arguments?.firstOrNull()?.type?.classifier as? KClass<*> - val metadata = mapOf( - NavMetadataKeys.IsSheet.key to (this is Sheet), - NavMetadataKeys.IsSolitarySheet.key to (SolitarySheet::class.java.isAssignableFrom(T::class.java)), - NavMetadataKeys.IsNonDismissable.key to (NonDismissableRoute::class.java.isAssignableFrom(T::class.java)), - NavMetadataKeys.NavResultKey.key to (if (NavigationRetVal::class.isSuperclassOf(T::class)) { + return mapOf( + NavMetadataKeys.IsSheet.key to Sheet::class.java.isAssignableFrom(this.java), + NavMetadataKeys.IsSolitarySheet.key to SolitarySheet::class.java.isAssignableFrom(this.java), + NavMetadataKeys.IsNonDismissable.key to NonDismissableRoute::class.java.isAssignableFrom(this.java), + NavMetadataKeys.NavResultKey.key to (if (NavigationRetVal::class.isSuperclassOf(this)) { @Suppress("UNCHECKED_CAST") - (NavResultKey( - T::class as KClass>, + NavResultKey( + this as KClass>, resultClass as KClass - )) + ) } else { "" }) ) - return metadata } + +/** + * Instance convenience: compute metadata from this object's runtime type. + */ +inline fun T.metadata(): Map = T::class.metadata() 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 5b7291750..886e93b8f 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 @@ -2,7 +2,11 @@ package com.getcode.navigation.core import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.staticCompositionLocalOf import androidx.navigation3.runtime.NavBackStack @@ -44,6 +48,23 @@ data class CodeNavigator( val resultStore: NavResultStore, val onRootReached: () -> Unit, ) { + /** + * When set, signals the active sheet to animate its dismiss. The callback + * is invoked after the dismiss animation finishes and the sheet entry is removed + * from the backstack — use it to navigate to the replacement sheet. + * Uses [mutableStateOf] so any composable reading this property is guaranteed + * to recompose when it changes. + */ + var pendingSheetDismiss: (() -> Unit)? by mutableStateOf(null) + + /** + * Monotonic counter incremented on each dismiss-then-replace. Used as part of the + * composition key in [ModalBottomSheetScene] to force a fresh composition scope + * when the same route key is reused (otherwise Compose reuses the old Hidden + * SheetState because onBack + navigateTo happen in the same snapshot). + */ + var sheetGeneration by mutableIntStateOf(0) + val currentRouteKey: NavKey? get() = backStack.lastOrNull() 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 147029163..650cc9962 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 @@ -13,6 +13,8 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier @@ -23,6 +25,7 @@ import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SceneStrategy import androidx.navigation3.scene.SceneStrategyScope import com.getcode.navigation.NavMetadataKeys +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.results.NavResultKey import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStore @@ -51,67 +54,105 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c @OptIn(ExperimentalMaterial3Api::class) override val content: @Composable (() -> Unit) = { - val isNonDismissable = - (metadata[NavMetadataKeys.IsNonDismissable.key] as? Boolean) ?: false - - val handleBackResult = { - val navResultKey = - metadata[NavMetadataKeys.NavResultKey.key] as? NavResultKey, Parcelable> - if (navResultKey != null) { - returnNavKey?.let { navKey -> - navResultStore.deliverOrPersist( - navKey, - navResultKey, - NavResultOrCanceled.Canceled - ) + // 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. + val navigator = LocalCodeNavigator.current + key(key, navigator.sheetGeneration) { + val isNonDismissable = + (metadata[NavMetadataKeys.IsNonDismissable.key] as? Boolean) ?: false + + val handleBackResult = { + val navResultKey = + metadata[NavMetadataKeys.NavResultKey.key] as? NavResultKey, Parcelable> + if (navResultKey != null) { + returnNavKey?.let { navKey -> + navResultStore.deliverOrPersist( + navKey, + navResultKey, + NavResultOrCanceled.Canceled + ) + } } } - } - - var sheetState: SheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { value -> - // prevent dismissing via gesture if non-dismissable - !(value == SheetValue.Hidden && isNonDismissable) - }, - ) - val composeScope = rememberCoroutineScope() + var sheetState: SheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { value -> + // prevent dismissing via gesture if non-dismissable + !(value == SheetValue.Hidden && isNonDismissable) + }, + ) + + // 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) { + sheetState.show() + } - val dismiss = { hide: Boolean -> - if (hide && sheetState.isVisible) { - composeScope.launch { - sheetState.hide() - }.invokeOnCompletion { + val composeScope = rememberCoroutineScope() + + val dismiss = { hide: Boolean -> + if (hide && sheetState.isVisible) { + composeScope.launch { + sheetState.hide() + }.invokeOnCompletion { + handleBackResult() + onBack() + } + } else { handleBackResult() onBack() } - } else { - handleBackResult() - onBack() } - } - - // Remove inset padding. Default adds nav bar padding. - // Remove grab bar for bleed to top edge of sheet - ModalBottomSheet( - sheetState = sheetState, - onDismissRequest = { dismiss(false) }, - scrimColor = CodeTheme.colors.scrim, - properties = modalBottomSheetProperties, - dragHandle = null, - contentWindowInsets = { WindowInsets() }, - containerColor = CodeTheme.colors.surface, - ) { - Box( - modifier = Modifier.fillMaxWidth() - .fillMaxHeight(CodeTheme.dimens.modalHeightRatio) + + // Observe external dismiss requests (e.g. deeplink replacing current sheet). + // Reading pendingSheetDismiss registers a snapshot read — when navigateTo sets it, + // Compose guarantees recomposition (no coroutine dispatch ordering ambiguity). + val pendingDismiss = navigator.pendingSheetDismiss + if (pendingDismiss != null) { + LaunchedEffect(pendingDismiss) { + try { + sheetState.hide() + } finally { + handleBackResult() + onBack() + navigator.pendingSheetDismiss = null + pendingDismiss() + } + } + } + + // Remove inset padding. Default adds nav bar padding. + // Remove grab bar for bleed to top edge of sheet + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { dismiss(false) }, + scrimColor = CodeTheme.colors.scrim, + properties = modalBottomSheetProperties, + dragHandle = null, + contentWindowInsets = { WindowInsets() }, + containerColor = CodeTheme.colors.surface, ) { - CompositionLocalProvider(LocalBottomSheetDismissDispatcher provides { dismiss(true) }) { - entry.Content() + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(CodeTheme.dimens.modalHeightRatio) + ) { + CompositionLocalProvider(LocalBottomSheetDismissDispatcher provides { + dismiss( + true + ) + }) { + entry.Content() + } } } - } + } // end key(key) } } @@ -147,8 +188,9 @@ class ModalBottomSheetSceneStrategy( } }.ifEmpty { return null } - val bottomSheetProperties = lastEntry.metadata[BOTTOM_SHEET_KEY] as? ModalBottomSheetProperties - ?: ModalBottomSheetProperties() + val bottomSheetProperties = + lastEntry.metadata[BOTTOM_SHEET_KEY] as? ModalBottomSheetProperties + ?: ModalBottomSheetProperties() @Suppress("UNCHECKED_CAST") return ModalBottomSheetScene( From 684fda02824beb530fc4b4cb47f133419d9219da Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 18 Mar 2026 17:27:30 -0400 Subject: [PATCH 3/7] fix(nav3): force light status bar icons in sheet popup window Material3's ModalBottomSheet creates a separate popup window that defaults to dark (black) status bar icons, making them invisible against the dark scrim. Clear the APPEARANCE_LIGHT_STATUS_BARS flag on the popup's root view to force white icons. Signed-off-by: Brandon McAnsh --- .../scenes/ModalBottomSheetSceneStrategy.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 650cc9962..feef59ebf 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 @@ -14,10 +14,12 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.OverlayScene @@ -138,6 +140,15 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c contentWindowInsets = { WindowInsets() }, containerColor = CodeTheme.colors.surface, ) { + // The sheet's popup window defaults to dark (black) status bar icons. + // Force light icons so they're visible against the dark scrim. + val view = LocalView.current + SideEffect { + view.rootView.windowInsetsController?.setSystemBarsAppearance( + 0, // clear light status bars flag → light (white) icons + android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + ) + } Box( modifier = Modifier .fillMaxWidth() From 21f3fd0975aafb1b2b335751679d4d5fb7a4b040 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 18 Mar 2026 17:40:32 -0400 Subject: [PATCH 4/7] chore(nav3): clean up ViewModel scoping extensions Rename Voyager.kt to ViewModelExtensions.kt, remove deprecated getStackScopedViewModel, and fix deprecated hiltViewModel import. Signed-off-by: Brandon McAnsh --- .../ui/navigation/AppRestrictedScreen.kt | 2 +- .../com/flipcash/app/balance/BalanceScreen.kt | 2 +- .../kotlin/com/flipcash/app/lab/LabsScreen.kt | 2 +- .../{Voyager.kt => ViewModelExtensions.kt} | 25 +------------------ 4 files changed, 4 insertions(+), 27 deletions(-) rename ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/{Voyager.kt => ViewModelExtensions.kt} (61%) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt index d08c0e4cf..7146a7f44 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import com.flipcash.app.core.AppRoute import com.flipcash.app.internal.ui.HomeViewModel -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.extensions.getActivityScopedViewModel +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.restrictions.ContentRestrictedView import com.getcode.ui.core.RestrictionType import kotlinx.coroutines.launch diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt index e33b3e895..72af5d998 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import com.getcode.navigation.extensions.getActivityScopedViewModel import com.flipcash.app.balance.internal.BalanceScreen import com.flipcash.app.balance.internal.BalanceViewModel import com.flipcash.app.core.AppRoute @@ -16,7 +17,6 @@ import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getActivityScopedViewModel import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt index a4ab27090..05f2b4d66 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/LabsScreen.kt @@ -8,9 +8,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.flipcash.app.lab.internal.LabsScreenContent import com.flipcash.app.lab.internal.LabsScreenViewModel +import com.getcode.navigation.extensions.getActivityScopedViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.extensions.getActivityScopedViewModel import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/ViewModelExtensions.kt similarity index 61% rename from ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt rename to ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/ViewModelExtensions.kt index 73c22be75..9a36b02f4 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/ViewModelExtensions.kt @@ -4,7 +4,7 @@ import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.HasDefaultViewModelProviderFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -25,29 +25,6 @@ inline fun getActivityScopedViewModel(): T { } } -/** - * Get a ViewModel scoped to a key (for multi-step flows sharing state). - * Falls back to activity-scoped hiltViewModel when no key is provided. - */ -@Deprecated( - "Use flowScopedViewModel(key) for flow-shared VMs or hiltViewModel() for entry-scoped VMs", - ReplaceWith("flowScopedViewModel(key)", "com.getcode.navigation.extensions.flowScopedViewModel") -) -@Composable -inline fun getStackScopedViewModel(key: String? = null): T { - return if (key != null) { - val activity = LocalContext.current.getActivity() as ComponentActivity - remember(key1 = key) { - val factory = activity.defaultViewModelProviderFactory - val extras = activity.defaultViewModelCreationExtras - val provider = ViewModelProvider(activity.viewModelStore, factory, extras) - provider[key, T::class.java] - } - } else { - hiltViewModel() - } -} - /** * Get a Hilt ViewModel shared across multiple screens in a multi-step flow. * The ViewModel is scoped to the activity's ViewModelStore with the given [key], From 85d16face47d5b93120752fcb06f95acf7b93ef1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 18 Mar 2026 19:12:34 -0400 Subject: [PATCH 5/7] feat(nav3): add NonDraggableRoute and NonDismissableRoute support for inner sheet routes Use Material3 sheetGesturesEnabled and ModalBottomSheetProperties to block drag gestures and scrim/back dismissal on sheets whose inner route implements these marker interfaces. Inner route state is communicated to the outer ModalBottomSheet via reactive CodeNavigator properties and LocalSheetNavigator composition local. Signed-off-by: Brandon McAnsh --- .../ui/navigation/AppScreenContent.kt | 24 +++++++++++++ .../kotlin/com/flipcash/app/core/AppRoute.kt | 4 ++- .../app/tokens/TokenTxProcessingScreen.kt | 2 -- .../com/getcode/navigation/NavMetadata.kt | 2 ++ .../kotlin/com/getcode/navigation/Types.kt | 1 + .../getcode/navigation/core/CodeNavigator.kt | 14 +++++++- .../scenes/ModalBottomSheetSceneStrategy.kt | 36 ++++++++++++------- 7 files changed, 66 insertions(+), 17 deletions(-) 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 d9878f7a0..37229661b 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 @@ -8,6 +8,9 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -53,11 +56,14 @@ import com.flipcash.app.withdrawal.WithdrawalDestinationScreen import com.flipcash.app.withdrawal.WithdrawalEntryScreen import com.flipcash.app.withdrawal.WithdrawalFlow import com.getcode.navigation.AppNavHost +import com.getcode.navigation.NonDismissableRoute +import com.getcode.navigation.NonDraggableRoute import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.core.rememberCodeNavigator import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher +import com.getcode.navigation.scenes.LocalSheetNavigator import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.ui.components.bars.BarManager import dev.theolm.rinku.DeepLink @@ -187,6 +193,24 @@ private fun SheetContent( } } + // Toggle the outer sheet's drag/dismiss behavior based on the current inner route. + val sheetNavigator = LocalSheetNavigator.current + val currentInnerRoute by remember { + derivedStateOf { backStack.lastOrNull() } + } + if (sheetNavigator != null) { + val isDragDisabled = currentInnerRoute is NonDraggableRoute + val isDismissDisabled = currentInnerRoute is NonDismissableRoute + DisposableEffect(isDragDisabled, isDismissDisabled) { + sheetNavigator.sheetDragDisabled = isDragDisabled + sheetNavigator.sheetDismissDisabled = isDismissDisabled + onDispose { + sheetNavigator.sheetDragDisabled = false + sheetNavigator.sheetDismissDisabled = false + } + } + } + CompositionLocalProvider(LocalCodeNavigator provides navigator) { AppNavHost( navigator = navigator, 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 4c67dd7a9..39a6f33e5 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 @@ -5,6 +5,8 @@ import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.tokens.TokenSwapPurpose +import com.getcode.navigation.NonDismissableRoute +import com.getcode.navigation.NonDraggableRoute import com.getcode.navigation.Sheet import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.financial.Fiat @@ -81,7 +83,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class Info(val mint: Mint, val forNeededFunds: Boolean = false, val fromDeeplink: Boolean = false): Token @Serializable data class Transactions(val mint: Mint): Token @Serializable data class SwapTransact(val purpose: TokenSwapPurpose, val forNeededFunds: Boolean = false): Token - @Serializable data class TxProcessing(val swapId: SwapId, val awaitExternalWallet: Boolean = false): Token + @Serializable data class TxProcessing(val swapId: SwapId, val awaitExternalWallet: Boolean = false): Token, NonDismissableRoute, NonDraggableRoute @Serializable data object SellReceipt: Token } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt index e472aef1f..5becd5da1 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenTxProcessingScreen.kt @@ -17,7 +17,6 @@ import com.flipcash.app.tokens.ui.BuySellSwapTokenViewModel.Event import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.extensions.flowScopedViewModel import com.getcode.opencode.internal.solana.model.SwapId -import com.getcode.ui.utils.DisableSheetGestures import com.getcode.view.LoadingSuccessState import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.firstOrNull @@ -94,5 +93,4 @@ fun TokenTxProcessingScreen( } BackHandler { /* intercept */ } - DisableSheetGestures() } 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 98e416e95..1af36687b 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -11,6 +11,7 @@ import kotlin.reflect.full.isSuperclassOf enum class NavMetadataKeys(val key: String, ) { IsNonDismissable("non_dismissable"), + IsNonDraggable("non_draggable"), IsSheet("sheet"), IsSolitarySheet("sheet_solitary"), NavResultKey("navresult_key"), @@ -36,6 +37,7 @@ fun KClass<*>.metadata(): Map { NavMetadataKeys.IsSheet.key to Sheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsSolitarySheet.key to SolitarySheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsNonDismissable.key to NonDismissableRoute::class.java.isAssignableFrom(this.java), + NavMetadataKeys.IsNonDraggable.key to NonDraggableRoute::class.java.isAssignableFrom(this.java), NavMetadataKeys.NavResultKey.key to (if (NavigationRetVal::class.isSuperclassOf(this)) { @Suppress("UNCHECKED_CAST") NavResultKey( diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt index a37180b17..8730ba211 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt @@ -4,4 +4,5 @@ import androidx.navigation3.runtime.NavKey interface Sheet: NavKey interface NonDismissableRoute: NavKey +interface NonDraggableRoute: NavKey interface SolitarySheet: NavKey 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 886e93b8f..8444e7f79 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 @@ -63,6 +63,18 @@ data class CodeNavigator( * when the same route key is reused (otherwise Compose reuses the old Hidden * SheetState because onBack + navigateTo happen in the same snapshot). */ + /** + * When true, the enclosing bottom sheet disables drag gestures. + * Inner sheet content sets this based on the current route's metadata. + */ + var sheetDragDisabled by mutableStateOf(false) + + /** + * When true, the enclosing bottom sheet blocks scrim tap and back press dismissal. + * Inner sheet content sets this based on the current route's metadata. + */ + var sheetDismissDisabled by mutableStateOf(false) + var sheetGeneration by mutableIntStateOf(0) val currentRouteKey: NavKey? @@ -195,7 +207,7 @@ data class CodeNavigator( } /** Hide/dismiss a sheet (pops the current route). */ - fun hide() = navigateBack() + fun hide() = popAll() /** Replace the current route with a new one. */ fun replace(route: NavKey) = navigate(route, NavOptions(popUpTo = NavOptions.PopUpTo.PopLast)) 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 feef59ebf..1c1957f23 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 @@ -9,11 +9,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.SideEffect import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope @@ -27,6 +27,7 @@ import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SceneStrategy import androidx.navigation3.scene.SceneStrategyScope import com.getcode.navigation.NavMetadataKeys +import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.results.NavResultKey import com.getcode.navigation.results.NavResultOrCanceled @@ -64,7 +65,8 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c val navigator = LocalCodeNavigator.current key(key, navigator.sheetGeneration) { val isNonDismissable = - (metadata[NavMetadataKeys.IsNonDismissable.key] as? Boolean) ?: false + (metadata[NavMetadataKeys.IsNonDismissable.key] as? Boolean ?: false) + || navigator.sheetDismissDisabled val handleBackResult = { val navResultKey = @@ -82,10 +84,6 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c var sheetState: SheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, - confirmValueChange = { value -> - // prevent dismissing via gesture if non-dismissable - !(value == SheetValue.Hidden && isNonDismissable) - }, ) // Ensure the sheet shows when entering composition. Material3's internal @@ -133,9 +131,15 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c // Remove grab bar for bleed to top edge of sheet ModalBottomSheet( sheetState = sheetState, - onDismissRequest = { dismiss(false) }, + onDismissRequest = { if (!isNonDismissable) dismiss(false) }, + sheetGesturesEnabled = !navigator.sheetDragDisabled, scrimColor = CodeTheme.colors.scrim, - properties = modalBottomSheetProperties, + properties = if (isNonDismissable) { + ModalBottomSheetProperties( + shouldDismissOnBackPress = false, + shouldDismissOnClickOutside = false, + ) + } else modalBottomSheetProperties, dragHandle = null, contentWindowInsets = { WindowInsets() }, containerColor = CodeTheme.colors.surface, @@ -154,11 +158,10 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c .fillMaxWidth() .fillMaxHeight(CodeTheme.dimens.modalHeightRatio) ) { - CompositionLocalProvider(LocalBottomSheetDismissDispatcher provides { - dismiss( - true - ) - }) { + CompositionLocalProvider( + LocalBottomSheetDismissDispatcher provides { dismiss(true) }, + LocalSheetNavigator provides navigator, + ) { entry.Content() } } @@ -235,3 +238,10 @@ class ModalBottomSheetSceneStrategy( } val LocalBottomSheetDismissDispatcher = staticCompositionLocalOf { {} } + +/** + * Provides the outer (root) [CodeNavigator] to inner sheet content. + * Used to toggle [CodeNavigator.sheetDragDisabled] from within nested navigators. + */ +val LocalSheetNavigator: ProvidableCompositionLocal = + staticCompositionLocalOf { null } From bc92bb0f15b95d51a71a24953a4aca869d5cf4dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 18 Mar 2026 19:17:13 -0400 Subject: [PATCH 6/7] fix(nav3): dismiss sheet animation when navigator.hide() is called hide() was calling popAll() which cleared the inner backstack without triggering the sheet dismiss animation. Changed to call onRootReached() which fires the sheetDismissDispatcher chain to properly animate out. Signed-off-by: Brandon McAnsh --- .../main/kotlin/com/getcode/navigation/core/CodeNavigator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8444e7f79..5ab61914a 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 @@ -207,7 +207,7 @@ data class CodeNavigator( } /** Hide/dismiss a sheet (pops the current route). */ - fun hide() = popAll() + fun hide() = onRootReached() /** Replace the current route with a new one. */ fun replace(route: NavKey) = navigate(route, NavOptions(popUpTo = NavOptions.PopUpTo.PopLast)) From 21e61b9b30b897deea5e26911e654bc91eb0351a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 18 Mar 2026 19:23:17 -0400 Subject: [PATCH 7/7] fix(tokens): ensure token selection after hydrating from persistence hydrateFromPersistence() loaded cached tokens but never called ensureValidTokenSelection(), leaving a window where no token was selected after login until the network update arrived. Signed-off-by: Brandon McAnsh --- .../src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 22f69d335..cc14da450 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -311,6 +311,7 @@ class TokenCoordinator @Inject constructor( } applyTokenUpdates(persisted) + ensureValidTokenSelection() trace(tag = TAG, message = "Hydrated ${persisted.size} tokens from persistence", type = TraceType.Process) }