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..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,9 +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.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 +15,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 +22,21 @@ 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.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.internal.startup.DiscreteBondingCurveInitializer -import com.flipcash.app.internal.ui.navigation.AppScreenContent -import com.flipcash.app.internal.ui.navigation.MainRoot +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 import com.flipcash.app.onramp.OnRampAmountScaffold @@ -46,30 +45,24 @@ 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.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 com.flipcash.app.core.extensions.navigateTo import dev.bmcreations.tipkit.TipScaffold import dev.bmcreations.tipkit.engines.TipsEngine import dev.theolm.rinku.DeepLink @@ -80,7 +73,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,26 +92,17 @@ 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) - analytics.deeplinkParsed(type, it.data) - if (type is DeeplinkType.Login) { - loginRequest = type.entropy - } 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,158 @@ 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 ) { - 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 + AppNavHost( + navigator = codeNavigator, + resultStateRegistry = resultStateRegistry, + decorators = listOf( + rememberNavMessagingEntryDecorator( + codeNavigator.backStack, + barManager + ), + rememberNavBlockingOverlayEntryDecorator(), + ), + sceneStrategy = ModalBottomSheetSceneStrategy( + codeNavigator.resultStore + ) { + codeNavigator.backStack.getOrNull( + codeNavigator.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 }) } - - 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) - } + }, + 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.lastItem !is MainRoot) { - if (deepLink != null) { - val screenSet = - router.processDestination(deepLink) - if (screenSet.isNotEmpty()) { - codeNavigator.replaceAll(screenSet) - } + LaunchedEffect(deepLink) { + val link = deepLink ?: return@LaunchedEffect - deepLink = null - } - } - } + if (codeNavigator.currentRouteKey is AppRoute.Loading) { + // Cold start — MainRoot handles it via the deepLink lambda + return@LaunchedEffect + } - 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( - entropy, - onSwitchAccount = { - loginRequest = null - codeNavigator.replaceAll( - ScreenRegistry.get( - AppRoute.Onboarding.Login( - entropy, - fromDeeplink = true - ) - ) - ) - }, - onDismissed = { loginRequest = null } - ) - } - } + 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 - LaunchedEffect(userState.isTimelockUnlocked) { - if (userState.isTimelockUnlocked) { - codeNavigator.replaceAll( - ScreenRegistry.get( - AppRoute.Main.AppRestricted( - RestrictionType.TIMELOCK_UNLOCKED - ) - ) - ) - } + if (!delivered) { + codeNavigator.navigateTo(action.routes) } + } + is DeeplinkAction.ExternalWallet -> externalWalletOnRamp.handleWalletDeeplink(action.type) + is DeeplinkAction.Login -> viewModel.handleLoginEntropy( + action.entropy, + onSwitchAccount = { + codeNavigator.replaceAll( + AppRoute.Onboarding.Login( + action.entropy, + fromDeeplink = true + ) + ) + }, + onDismissed = { } + ) + is DeeplinkAction.OpenCashLink -> session.openCashLink(action.entropy) + DeeplinkAction.None -> {} + } + deepLink = null + } - OnLifecycleEvent { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - session.onAppInForeground() - } + 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()) + } + } + } - 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 } } } @@ -260,59 +279,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..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 @@ -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.navigation.core.LocalCodeNavigator 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..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 @@ -1,8 +1,23 @@ 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 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.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 +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 import com.flipcash.app.backupkey.BackupKeyScreen @@ -13,6 +28,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 +55,200 @@ 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.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 @Composable -internal fun AppScreenContent(content: @Composable () -> Unit) { - ScreenRegistry { - register { - LoginRouter(it.seed, it.fromDeeplink) - } - - register { - SeedInputScreen() - } - - register { - AccessKeyScreen() - } - - register { - PhotoAccessKeyScreen() - } - - register { - PurchaseAccountScreen(it.fromLogin) - } - - register { - StandaloneLabsScreen() - } - - register { - AppRestrictedScreen(it.restrictionType) - } - - register { - ScannerScreen(it.deeplink) - } - - register { - CashScreen(it.mint, it.fromTokenInfo) - } - - register { - TokenInfoScreen(it.mint, it.forNeededFunds, it.fromDeeplink) - } - - register { - TransactionHistoryScreen(it.mint) - } - - register { - BuySellFlow.start(it.forNeededFunds) - TokenBuySellEntryScreen(it.purpose) - } - - register { - TokenTxProcessingScreen(it.swapId, it.awaitExternalWallet) - } - - register { - TokenSellReceiptScreen() - } - - register { - TokenSelectScreen(it.purpose) - } - - register { - BalanceScreen() - } - - register { - RegionSelectionScreen(it.kind) - } - - register { - ShareAppScreen() - } - - register { - VerificationFlowScreen( - origin = it.origin, - target = it.target, - includePhone = it.includePhone, - includeEmail = it.includeEmail, - emailAddress = it.email, - emailVerificationCode = it.emailVerificationCode - ) - } - - register { - OnRampFlowTracker.start(it.from) - OnRampProviderListScreen( - neededAmount = it.neededAmount?.quarks, - neededCurrency = it.neededAmount?.currencyCode - ) - } - - register { - OnRampCustomAmountScreen() - } - - register { - MenuScreen() - } - - register { - AppSettingsScreen() - } - - register { - LabsScreen() - } - - register { - WithdrawalFlow.start() - WithdrawalEntryScreen(it.mint) - } - - register { - WithdrawalDestinationScreen() - } - - register { - WithdrawalConfirmationScreen() - } - - register { - MyAccountScreen() - } +fun AppPreloads() { + PreloadBalance() + PreloadLabs() +} + +fun appEntryProvider( + resultStateRegistry: NavResultStateRegistry, + barManager: BarManager, + deepLink: () -> DeepLink?, +): (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, + ) + } - register { - DepositScreen(it.mint) + // 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 content with nested [AppNavHost] for inner-sheet navigation. + * Uses slide transitions for intra-sheet navigation. + */ +@Composable +private fun SheetContent( + key: AppRoute.Main.Sheet, + resultStateRegistry: NavResultStateRegistry, + barManager: BarManager, +) { + 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) } } - - register { - BackupKeyScreen() + } + val navigator = rememberCodeNavigator( + backStack = backStack, + resultStateRegistry = resultStateRegistry, + onRootReached = { sheetDismissDispatcher() }, + ) + + val onBack = { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } else { + sheetDismissDispatcher() } + } - register { - AdvancedFeaturesScreen() + // 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 + } } } - PreloadBalance() - PreloadLabs() - - content() -} \ No newline at end of file + 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 = { + 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 = { onBack() }, + entryProvider = appEntryProvider(resultStateRegistry, barManager, deepLink = { null }), + ) + + 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 cb46a4287..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 @@ -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,20 +20,19 @@ 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 +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.screens.AppScreen +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.utils.trace @@ -44,149 +42,197 @@ 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 launch = 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) - } - } + } - 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()) - ) - ) - } else { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner())) + 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) } } - } else { + } + }.launchIn(this) + } +} + +/** + * 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?, +): LaunchNavGraph? { + return when (state) { + is AuthState.Registered -> { + if (state.seenAccessKey) { + val routes = if (userFlags?.requiresIapForRegistration == true) { listOf( - ScreenRegistry.get(AppRoute.Onboarding.Login()), - ScreenRegistry.get(AppRoute.Onboarding.AccessKey) + AppRoute.Onboarding.Login(), + AppRoute.Onboarding.AccessKey, + AppRoute.Onboarding.Purchase() ) + } else { + listOf(AppRoute.Main.Scanner) } + LaunchNavGraph(routes) + } else { + LaunchNavGraph( + listOf( + AppRoute.Onboarding.Login(), + AppRoute.Onboarding.AccessKey + ) + ) } + } - AuthState.LoggedInWithUser -> { - val screens = router.processDestination(deepLink()) - - screens.ifEmpty { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner())) + AuthState.LoggedInWithUser -> { + 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 screens = router.processDestination(deepLink()) - screens.ifEmpty { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) + AuthState.LoggedOut, + AuthState.Unknown -> { + 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())) } - - AuthState.LoggedInAwaitingUser -> null } + + AuthState.LoggedInAwaitingUser -> null } } - 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 new file mode 100644 index 000000000..c8f15b8b0 --- /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.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() + val isTopEntry = entry.contentKey == backStack.lastOrNull()?.toString() + if (isTopEntry && entry.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..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 @@ -1,49 +1,61 @@ 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.NonDismissableRoute +import com.getcode.navigation.NonDraggableRoute +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 object Scanner : Main // TODO: is there a better place for this to live? - data class RegionSelection(val kind: RegionSelectionKind) : Main - - data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Main + @Serializable data class RegionSelection(val kind: RegionSelectionKind) : Main + + @Serializable + @Parcelize + data class Sheet( + val initialRoute: Sheets, + val innerRoutes: List = emptyList(), + ) : Main, com.getcode.navigation.Sheet } + @Serializable @Parcelize data class Verification( val origin: AppRoute, @@ -54,54 +66,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, NonDismissableRoute, NonDraggableRoute + @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..1ebc57e90 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/CodeNavigator.kt @@ -0,0 +1,75 @@ +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]. + * 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 + + 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/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/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/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..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 @@ -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,8 @@ 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.getcode.navigation.extensions.getActivityScopedViewModel import com.flipcash.app.balance.internal.BalanceScreen import com.flipcash.app.balance.internal.BalanceViewModel import com.flipcash.app.core.AppRoute @@ -20,76 +17,59 @@ 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.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/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/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/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/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..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 @@ -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.getcode.navigation.extensions.getActivityScopedViewModel 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..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 @@ -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..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 @@ -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..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 @@ -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..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 @@ -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..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,25 +1,9 @@ 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() { + 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 e56ab7571..000000000 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/NavigationStateRestorer.kt +++ /dev/null @@ -1,99 +0,0 @@ -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.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.show( - listOf( - ScreenRegistry.get(AppRoute.Sheets.Wallet), - ScreenRegistry.get(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) - 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))) - ) - } - - navigator.show(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) + ScreenRegistry.get( - 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 - ) - ) - - null -> emptyList() - } - - if (screens.isNotEmpty()) { - analytics.deeplinkRouted(deeplink) - navigator.show(screens) - } else { - analytics.deeplinkRouted(deeplink, IllegalStateException("Failed to route deeplink")) - } - } - } - } -} - -private fun buildOnRampScreenFlow(origin: List) = - origin.dropLast(1).map { ScreenRegistry.get(it) } + - ScreenRegistry.get(AppRoute.OnRamp.ProviderList(origin.last()) -) - -private fun buildOnRampScreenFlow(origin: AppRoute) = buildOnRampScreenFlow(listOf(origin)) \ No newline at end of file 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..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,11 +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 cafe.adriel.voyager.core.registry.ScreenRegistry -import cafe.adriel.voyager.navigator.currentOrThrow import com.flipcash.app.analytics.rememberAnalytics -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 @@ -23,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 @@ -32,12 +31,14 @@ 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?) { - val router = LocalRouter.currentOrThrow +internal fun Scanner() { + 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() @@ -62,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") @@ -100,7 +101,7 @@ internal fun Scanner(deepLink: DeeplinkType?) { } else -> Unit } - navigator.show(ScreenRegistry.get(it.screen)) + navigator.navigateTo(it.screen) }, scannerView = { CodeScanner( @@ -116,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 -> { @@ -174,8 +190,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 +204,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/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/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..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 @@ -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,91 @@ 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(AppRoute.Token.TxProcessing(swapId)) + }.launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.swapId } - .onEach { swapId -> - navigator.push(ScreenRegistry.get(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 } } -} \ 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..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 @@ -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,107 @@ 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 + ) + } - LaunchedEffect(Unit) { - viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, forNeededFunds)) - } + TokenInfoScreen(viewModel, forNeededFunds) - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.pop() } - .launchIn(this) - } + LaunchedEffect(Unit) { + viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, forNeededFunds)) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.screen } - .onEach { - navigator.push(ScreenRegistry.get(it)) - }.launchIn(this) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.pop() } + .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) + 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) + } + + // 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 } } } - -} \ 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..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 @@ -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,99 +8,89 @@ 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 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 -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 or cancelled/errored + val terminalState = snapshotFlow { externalWalletState.deeplinkState } + .firstOrNull { it == ExternalWalletState.TRANSACTED || it == ExternalWalletState.IDLE } - // 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) } - - TokenTxProcessingScreen( - viewModel = viewModel, - processingProgressOverride = if (awaitingWallet) LoadingSuccessState(loading = true) else null, - ) - - 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 } + 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)) + 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 } + // 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 } + .firstOrNull { 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 */ } } 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/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/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..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,42 +5,34 @@ 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 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 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 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 import com.getcode.utils.trace -import dev.theolm.rinku.DeepLink import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -52,8 +44,6 @@ import kotlin.to fun ExternalWalletOnRampHandler( state: ExternalWalletDeeplinkState, navigator: CodeNavigator, - router: Router, - deepLink: DeepLink?, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, content: @Composable () -> Unit ) { @@ -62,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) @@ -70,17 +65,13 @@ 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() } } - var preNavigatedToEntry by remember { mutableStateOf(false) } - var preNavigatedToProcessing by remember { mutableStateOf(false) } val uriHandler = LocalUriHandler.current val context = LocalContext.current @@ -137,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 @@ -176,14 +142,9 @@ fun ExternalWalletOnRampHandler( if (uri?.canNativelyHandle(context) == true) { val origin = state.origin if (origin is AppRoute.Token.Info) { - preNavigatedToEntry = true - navigator.push( - ScreenRegistry.get( - AppRoute.Token.SwapTransact( - TokenSwapPurpose.FundWithWallet(origin.mint), - forNeededFunds = origin.forNeededFunds - ) - ) + state.pendingNavigation = AppRoute.Token.SwapTransact( + TokenSwapPurpose.FundWithWallet(origin.mint), + forNeededFunds = origin.forNeededFunds ) } @@ -216,24 +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( - ScreenRegistry.get( - AppRoute.Token.SwapTransact( - TokenSwapPurpose.FundWithWallet(origin.mint), - forNeededFunds = origin.forNeededFunds - ) - ) - ) - } - else -> { - navigator.push(ScreenRegistry.get(AppRoute.OnRamp.AmountEntry)) - } + when (state.origin) { + is AppRoute.Token.Info -> { + // SwapTransact already navigated via pendingNavigation at STARTED + } + else -> { + navigator.push(AppRoute.OnRamp.AmountEntry) } } } @@ -254,11 +203,8 @@ fun ExternalWalletOnRampHandler( val swapId = state.swapId if (state.origin is AppRoute.Token.Info && swapId != null) { - preNavigatedToProcessing = true - navigator.push( - ScreenRegistry.get( - AppRoute.Token.TxProcessing(swapId, awaitExternalWallet = true) - ) + state.pendingNavigation = AppRoute.Token.TxProcessing( + swapId, awaitExternalWallet = true ) } @@ -291,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 } @@ -302,7 +247,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 +320,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/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/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..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 @@ -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/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 455ff3c8a..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 cafe.adriel.voyager.core.screen.Screen +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 } \ No newline at end of file +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 e6c7242c6..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,10 +1,8 @@ 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 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 @@ -12,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 @@ -19,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") @@ -41,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(ScreenRegistry.get(AppRoute.Main.Scanner(type))) - } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login(type.entropy, true))) - } - } - is DeeplinkType.CashLink -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner(type))) - } else { - listOf(ScreenRegistry.get(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(ScreenRegistry.get(AppRoute.Main.Scanner())) - } else { - listOf(ScreenRegistry.get(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(ScreenRegistry.get(AppRoute.Main.Scanner())) - } else { - listOf(ScreenRegistry.get(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(ScreenRegistry.get(AppRoute.Main.Scanner(type))) - } else { - listOf(ScreenRegistry.get(AppRoute.Onboarding.Login())) - } - } + is DeeplinkType.CashLink -> DeeplinkAction.OpenCashLink(type.entropy) - is DeeplinkType.TokenInfo -> { - if (userManager.authState is AuthState.LoggedInWithUser) { - listOf(ScreenRegistry.get(AppRoute.Main.Scanner(type))) - } else { - listOf(ScreenRegistry.get(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 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 + } } - 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 + 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]) @@ -225,4 +239,4 @@ private fun DeepLink.handleEmailVerification(): DeeplinkType.EmailVerification? } return null -} \ No newline at end of file +} 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/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) } 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 0a58bfb25..b24caecfd 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" @@ -65,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" @@ -112,6 +115,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 +144,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.4.0" } @@ -257,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/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/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/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/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/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..ee663de5c --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt @@ -0,0 +1,109 @@ +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.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 +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 = { + 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 = { + 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..1af36687b --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -0,0 +1,56 @@ +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"), + IsNonDraggable("non_draggable"), + IsSheet("sheet"), + IsSolitarySheet("sheet_solitary"), + NavResultKey("navresult_key"), +} + +/** + * 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) +} + +/** + * 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<*> + + 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.IsNonDraggable.key to NonDraggableRoute::class.java.isAssignableFrom(this.java), + NavMetadataKeys.NavResultKey.key to (if (NavigationRetVal::class.isSuperclassOf(this)) { + @Suppress("UNCHECKED_CAST") + NavResultKey( + this as KClass>, + resultClass as KClass + ) + } else { + "" + }) + ) +} + +/** + * 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/Types.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt new file mode 100644 index 000000000..8730ba211 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt @@ -0,0 +1,8 @@ +package com.getcode.navigation + +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/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..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 @@ -1,122 +1,238 @@ package com.getcode.navigation.core -import android.annotation.SuppressLint 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 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, +) { + /** + * 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). + */ + /** + * 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? + 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() = onRootReached() - 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/ViewModelExtensions.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/ViewModelExtensions.kt new file mode 100644 index 000000000..9a36b02f4 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/ViewModelExtensions.kt @@ -0,0 +1,39 @@ +package com.getcode.navigation.extensions + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.getcode.ui.utils.getActivity + +@Composable +inline fun getActivityScopedViewModel(): T { + val activity = LocalContext.current.getActivity() as ComponentActivity + val viewModelStore = activity.viewModelStore + val defaultFactory = activity as HasDefaultViewModelProviderFactory + return remember(key1 = T::class) { + val provider = ViewModelProvider( + store = viewModelStore, + factory = defaultFactory.defaultViewModelProviderFactory, + defaultCreationExtras = defaultFactory.defaultViewModelCreationExtras + ) + provider[T::class.java] + } +} + +/** + * 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/extensions/Voyager.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt deleted file mode 100644 index 81011fce4..000000000 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/extensions/Voyager.kt +++ /dev/null @@ -1,91 +0,0 @@ -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.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 { - val activity = LocalContext.current.getActivity() as ComponentActivity - val defaultFactory = (LocalLifecycleOwner.current as HasDefaultViewModelProviderFactory) - val viewModelStore = LocalContext.current.getActivity()!!.viewModelStore - return remember(key1 = T::class) { - val factory = VoyagerHiltViewModelFactories.getVoyagerFactory( - activity = activity, - delegateFactory = defaultFactory.defaultViewModelProviderFactory - ) - - val provider = ViewModelProvider( - store = viewModelStore, - factory = factory, - defaultCreationExtras = defaultFactory.defaultViewModelCreationExtras - ) - provider[T::class.java] - } -} - -@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 - ) - } else { - null - } -} - -@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] - } -} 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..1c1957f23 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -0,0 +1,247 @@ +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.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 +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 +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 +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) = { + // 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) + || navigator.sheetDismissDisabled + + 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, + ) + + // 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 composeScope = rememberCoroutineScope() + + val dismiss = { hide: Boolean -> + if (hide && sheetState.isVisible) { + composeScope.launch { + sheetState.hide() + }.invokeOnCompletion { + handleBackResult() + onBack() + } + } else { + handleBackResult() + onBack() + } + } + + // 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 = { if (!isNonDismissable) dismiss(false) }, + sheetGesturesEnabled = !navigator.sheetDragDisabled, + scrimColor = CodeTheme.colors.scrim, + properties = if (isNonDismissable) { + ModalBottomSheetProperties( + shouldDismissOnBackPress = false, + shouldDismissOnClickOutside = false, + ) + } else modalBottomSheetProperties, + dragHandle = null, + 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() + .fillMaxHeight(CodeTheme.dimens.modalHeightRatio) + ) { + CompositionLocalProvider( + LocalBottomSheetDismissDispatcher provides { dismiss(true) }, + LocalSheetNavigator provides navigator, + ) { + entry.Content() + } + } + } + } // end key(key) + } +} + +/** + * 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 { {} } + +/** + * Provides the outer (root) [CodeNavigator] to inner sheet content. + * Used to toggle [CodeNavigator.sheetDragDisabled] from within nested navigators. + */ +val LocalSheetNavigator: ProvidableCompositionLocal = + staticCompositionLocalOf { null } 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() } }