From c89296391eb84f38bd2a936db86b4104abce0b63 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 27 May 2026 21:08:00 -0400 Subject: [PATCH 1/8] feat(onboarding): unify login into a single OnboardingFlow with linear FlowHost API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fragmented login routing (LoginRouter, standalone screens, and scattered verification logic in MainRoot) with a single OnboardingFlowScreen backed by two FlowHost phases: Account and Permissions. FlowHost API changes: - Add linear flow overload with `steps`, `resumeAt`, and `completedResult` parameters for ordered step-by-step flows - Add `FlowNavigator.proceed()` to advance through the step list, exit with completedResult at the end, or delegate to `onProceed` for custom behavior - Rename the existing overload as the non-linear variant for flows that manage their own navigation via navigateTo/exitWithResult - Extract shared logic into FlowHostImpl; support re-seeding when initialStack changes before user navigation (async flag settling) Onboarding routing: - All AuthState.Registered cases now route to AppRoute.OnboardingFlow with a ResumePoint (Login, AccessKey, AccessKeyThenPurchase, or PostAccessKey) — MainRoot no longer routes directly to Verification - PostAccessKeyRedirect checks UserProfile.verifiedPhoneNumber to skip verification when phone is already linked - Seed restore (LoggedIn) skips verification and goes straight to permissions — existing users encounter phone verification in-app via the send flow - Permissions phase uses the linear FlowHost with resumeAt to skip already-granted permissions Login module restructuring: - Delete LoginRouter, AccessKeyScreen, SeedInputScreen (standalone wrappers) — all step content is now composed inline by OnboardingFlowScreen via the entryProvider - Move ViewModels to internal package, screen content to internal/screens - Add OnboardingStep sealed interface and OnboardingResult for flow step/result modeling Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/internal/ui/App.kt | 10 +- .../ui/navigation/AppRestrictedScreen.kt | 4 +- .../ui/navigation/AppScreenContent.kt | 23 +- .../app/internal/ui/navigation/MainRoot.kt | 28 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 26 + .../app/core/onboarding/OnboardingStep.kt | 63 +++ apps/flipcash/features/login/build.gradle.kts | 2 + .../app/login/OnboardingFlowScreen.kt | 515 ++++++++++++++++++ .../app/login/accesskey/AccessKeyScreen.kt | 52 -- .../LoginAccessKeyViewModel.kt | 9 +- .../{seed => internal}/SeedInputViewModel.kt | 61 +-- .../{ => screens}/AccessKeyScreenContent.kt | 13 +- .../{ => screens}/LoginScreenContent.kt | 2 +- .../screens}/PhotoAccessKeyScreen.kt | 5 +- .../{ => screens}/SeedInputContent.kt | 21 +- .../flipcash/app/login/router/LoginRouter.kt | 89 --- .../app/login/seed/SeedInputScreen.kt | 29 - .../flipcash/app/myaccount/MyAccountScreen.kt | 4 +- .../internal/PurchaseAccountScreenContent.kt | 4 +- .../internal/PurchaseAccountViewModel.kt | 2 +- .../NotificationPermissionScreen.kt | 11 +- .../ContactPermissionScreenContent.kt | 2 +- .../NotificationPermissionScreenContent.kt | 2 +- .../NotificationRationalePermissionContent.kt | 16 +- .../flipcash/app/router/internal/AppRouter.kt | 4 +- .../app/router/internal/AppRouterTest.kt | 16 +- .../util/permissions/PermissionChecker.kt | 10 +- .../com/getcode/navigation/flow/FlowHost.kt | 183 +++++-- .../getcode/navigation/flow/FlowNavigator.kt | 15 + 29 files changed, 880 insertions(+), 341 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt create mode 100644 apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt delete mode 100644 apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt rename apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/{accesskey => internal}/LoginAccessKeyViewModel.kt (87%) rename apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/{seed => internal}/SeedInputViewModel.kt (82%) rename apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/{ => screens}/AccessKeyScreenContent.kt (97%) rename apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/{ => screens}/LoginScreenContent.kt (99%) rename apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/{accesskey => internal/screens}/PhotoAccessKeyScreen.kt (96%) rename apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/{ => screens}/SeedInputContent.kt (93%) delete mode 100644 apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginRouter.kt delete mode 100644 apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputScreen.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index 2560af122..5aa6acb45 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 @@ -266,8 +266,8 @@ internal fun App( action.entropy, onSwitchAccount = { codeNavigator.replaceAll( - AppRoute.Onboarding.Login( - action.entropy, + AppRoute.OnboardingFlow( + seed = action.entropy, fromDeeplink = true ) ) @@ -287,13 +287,13 @@ internal fun App( LaunchedEffect(userState.authState) { if (userState.authState == AuthState.LoggedOut) { val current = codeNavigator.currentRouteKey - if (current !is AppRoute.Loading && current !is AppRoute.Onboarding) { + if (current !is AppRoute.Loading && current !is AppRoute.OnboardingFlow) { codeNavigator.pendingSheetDismiss = null val switchEntropy = viewModel.consumePendingSwitchEntropy() codeNavigator.replaceAll( - AppRoute.Onboarding.Login( - switchEntropy + AppRoute.OnboardingFlow( + seed = switchEntropy ) ) } 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 7146a7f44..584097616 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 @@ -19,9 +19,7 @@ fun AppRestrictedScreen(restrictionType: RestrictionType) { coroutineScope.launch { homeViewModel.logout() .onSuccess { - navigator.replaceAll( - AppRoute.Onboarding.Login() - ) + navigator.replaceAll(AppRoute.OnboardingFlow()) } } } 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 a0f5c628e..09b2d47b8 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 @@ -34,16 +34,9 @@ import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEn import com.flipcash.app.lab.LabsScreen import com.flipcash.app.lab.NavBarSettingsScreen import com.flipcash.app.lab.StandaloneLabsScreen -import com.flipcash.app.login.accesskey.AccessKeyScreen -import com.flipcash.app.login.accesskey.PhotoAccessKeyScreen -import com.flipcash.app.login.router.LoginRouter -import com.flipcash.app.login.seed.SeedInputScreen +import com.flipcash.app.login.OnboardingFlowScreen import com.flipcash.app.menu.MenuScreen import com.flipcash.app.myaccount.MyAccountScreen -import com.flipcash.app.permissions.ContactPermissionScreen -import com.flipcash.app.permissions.NotificationPermissionRationaleScreen -import com.flipcash.app.permissions.NotificationPermissionScreen -import com.flipcash.app.purchase.PurchaseAccountScreen import com.flipcash.app.scanner.ScannerScreen import com.flipcash.app.shareapp.ShareAppScreen import com.flipcash.app.tokens.SwapFlowScreen @@ -75,16 +68,10 @@ fun appEntryProvider( // 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 { key -> ContactPermissionScreen(key.postCreate) } - annotatedEntry { key -> NotificationPermissionScreen(key.postCreate) } - annotatedEntry { key -> NotificationPermissionRationaleScreen(key.permanentlyDenied) } - annotatedEntry { } + // Onboarding flow + annotatedEntry { key -> + OnboardingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } // Main annotatedEntry { key -> diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index a3c37f471..25cb56889 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 @@ -179,25 +179,13 @@ private fun buildNavGraphForLaunch( ): LaunchNavGraph? { return when (state) { is AuthState.Registered -> { - if (state.seenAccessKey) { - val routes = if (userFlags?.requiresIapForRegistration == true) { - listOf( - 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 - ) - ) + val resumePoint = when { + !state.seenAccessKey -> AppRoute.OnboardingFlow.ResumePoint.AccessKey + userFlags?.requiresIapForRegistration == true -> + AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase + else -> AppRoute.OnboardingFlow.ResumePoint.PostAccessKey } + LaunchNavGraph(listOf(AppRoute.OnboardingFlow(resumeAt = resumePoint))) } AuthState.LoggedInWithUser -> { @@ -223,10 +211,10 @@ private fun buildNavGraphForLaunch( 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.OnboardingFlow())) } } else { - LaunchNavGraph(listOf(AppRoute.Onboarding.Login())) + LaunchNavGraph(listOf(AppRoute.OnboardingFlow())) } } 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 3eb8d4d8a..d9d99af98 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 @@ -14,8 +14,10 @@ import com.flipcash.app.core.verification.VerificationResult import com.flipcash.app.core.verification.VerificationStep import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep +import com.flipcash.app.core.onboarding.OnboardingStep import com.getcode.navigation.NonDismissableRoute import com.getcode.navigation.NonDraggableRoute +import com.getcode.navigation.flow.FlowRoute import com.getcode.navigation.flow.FlowRouteWithResult import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.internal.solana.model.SwapId @@ -62,6 +64,30 @@ sealed interface AppRoute : NavKey, Parcelable { } + @Serializable + @Parcelize + data class OnboardingFlow( + val phase: Phase = Phase.Account, + val seed: String? = null, + val fromDeeplink: Boolean = false, + val resumeAt: ResumePoint = ResumePoint.Login, + ) : AppRoute, FlowRoute { + enum class Phase { Account, Permissions } + enum class ResumePoint { Login, AccessKey, AccessKeyThenPurchase, PostAccessKey } + + override val initialStack: List + get() = when (phase) { + Phase.Account -> when (resumeAt) { + ResumePoint.Login -> listOf(OnboardingStep.Start(seed, fromDeeplink)) + ResumePoint.AccessKey -> listOf(OnboardingStep.Start(), OnboardingStep.AccessKey) + ResumePoint.AccessKeyThenPurchase -> + listOf(OnboardingStep.Start(), OnboardingStep.AccessKey, OnboardingStep.Purchase) + ResumePoint.PostAccessKey -> emptyList() + } + Phase.Permissions -> listOf(OnboardingStep.ContactPermission, OnboardingStep.NotificationPermission) + } + } + @Serializable @Parcelize sealed interface Main : AppRoute { diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt new file mode 100644 index 000000000..730c3d50c --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/onboarding/OnboardingStep.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.core.onboarding + +import android.os.Parcelable +import com.getcode.navigation.NonDismissableRoute +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Steps inside the Onboarding flow. Owned by [com.flipcash.app.core.AppRoute.OnboardingFlow] + * and rendered inside a [com.getcode.navigation.flow.FlowHost]. + */ +@Serializable +sealed interface OnboardingStep : FlowStep, Parcelable { + @Parcelize + @Serializable + data class Start(val seed: String? = null, val fromDeeplink: Boolean = false) : OnboardingStep + + @Parcelize + @Serializable + data object SeedInput : OnboardingStep + + @Parcelize + @Serializable + data object AccessKey : OnboardingStep + + @Parcelize + @Serializable + data object AccessKeySavedLocation : OnboardingStep + + @Parcelize + @Serializable + data object Purchase : OnboardingStep + + @Parcelize + @Serializable + data object ContactPermission : OnboardingStep + + @Parcelize + @Serializable + data object NotificationPermission : OnboardingStep + + @Parcelize + @Serializable + data class NotificationPermissionRationale( + val permanentlyDenied: Boolean = false, + ) : OnboardingStep, NonDismissableRoute +} + +@Serializable +sealed interface OnboardingResult : Parcelable { + @Parcelize + @Serializable + data object ProceedToVerification : OnboardingResult + + @Parcelize + @Serializable + data object LoggedIn : OnboardingResult + + @Parcelize + @Serializable + data object Completed : OnboardingResult +} diff --git a/apps/flipcash/features/login/build.gradle.kts b/apps/flipcash/features/login/build.gradle.kts index 3af3d6af4..658551e4d 100644 --- a/apps/flipcash/features/login/build.gradle.kts +++ b/apps/flipcash/features/login/build.gradle.kts @@ -16,7 +16,9 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:permissions")) implementation(project(":apps:flipcash:shared:userflags")) + implementation(project(":apps:flipcash:features:purchase")) implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt new file mode 100644 index 000000000..1bb882cb1 --- /dev/null +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -0,0 +1,515 @@ +package com.flipcash.app.login + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import com.flipcash.app.analytics.Action +import com.flipcash.app.analytics.Button +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.core.extensions.openAsSheet +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags +import com.flipcash.app.core.onboarding.OnboardingResult +import com.flipcash.app.core.onboarding.OnboardingStep +import com.flipcash.app.login.internal.LoginAccessKeyViewModel +import com.flipcash.app.login.internal.screens.PhotoAccessKeyScreen +import com.flipcash.app.login.internal.screens.AccessKeyScreen +import com.flipcash.app.login.internal.screens.LoginRouterScreenContent +import com.flipcash.app.login.internal.screens.SeedInputContent +import com.flipcash.app.login.router.LoginViewModel +import com.flipcash.app.login.internal.SeedInputViewModel +import com.flipcash.app.permissions.internal.contacts.ContactScreenContent +import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent +import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent +import com.flipcash.app.purchase.internal.PurchaseAccountScreenContent +import com.flipcash.app.purchase.internal.PurchaseAccountViewModel +import com.flipcash.features.login.R +import com.getcode.libs.analytics.LocalAnalytics +import com.getcode.navigation.annotatedEntry +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.core.NavOptions +import com.getcode.navigation.flow.FlowExitReason +import com.getcode.navigation.flow.FlowHost +import com.getcode.navigation.flow.LocalOuterCodeNavigator +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.flow.rememberInitialStack +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.util.permissions.LocalPermissionChecker +import com.getcode.util.permissions.PermissionConfigs +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.rememberContactPermission +import com.getcode.util.permissions.rememberNotificationPermission +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Entry point for the onboarding flow. Routing decisions: + * + * **New account** (`ProceedToVerification` from AccessKey): + * AccessKey → phone verification → permissions → Scanner. + * Phone is never linked yet, so verification is always shown. + * + * **Seed restore** (`LoggedIn` from SeedInput): + * SeedInput → permissions → Scanner. + * Verification is skipped — the phone may already be linked server-side, + * and existing users will encounter phone verification in-app when they + * first use the send flow. + * + * **App resume, access key seen, no IAP** (`PostAccessKey`): + * Checks [UserProfile.verifiedPhoneNumber]. If linked, skips to permissions; + * otherwise routes through verification first. Profile may be null at this + * point (fetched post-login by ProfileUpdater) — defaults to "not linked", + * which matches current behavior (shows verification). + * + * **Permissions phase** (all paths converge here): + * Contacts (if phone-number-send enabled) → notifications → Scanner. + * Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost]. + */ +@Composable +fun OnboardingFlowScreen( + route: AppRoute.OnboardingFlow, + resultStateRegistry: NavResultStateRegistry, +) { + when { + route.phase == AppRoute.OnboardingFlow.Phase.Permissions -> + PermissionsPhaseFlowHost(route, resultStateRegistry) + route.resumeAt == AppRoute.OnboardingFlow.ResumePoint.PostAccessKey -> + PostAccessKeyRedirect() + else -> + AccountPhaseFlowHost(route, resultStateRegistry) + } +} + +@Composable +private fun PostAccessKeyRedirect() { + val navigator = LocalCodeNavigator.current + val userManager = LocalUserManager.current!! + val userState by userManager.state.collectAsStateWithLifecycle() + val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null + + LaunchedEffect(Unit) { + if (hasLinkedPhone) { + navigator.replace(AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions)) + } else { + navigator.replace( + AppRoute.Verification( + origin = AppRoute.Onboarding.AccessKey, + includePhone = true, + includeEmail = false, + target = AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions), + fullScreen = true, + ) + ) + } + } +} + +@Composable +private fun PermissionsPhaseFlowHost( + route: AppRoute.OnboardingFlow, + resultStateRegistry: NavResultStateRegistry, +) { + val outerNavigator = LocalCodeNavigator.current + val checker = LocalPermissionChecker.current + val contactConfig = PermissionConfigs.contacts() + val notificationConfig = PermissionConfigs.notifications() + val analytics = LocalAnalytics.current + + val featureFlags = LocalFeatureFlags.current + val userManager = LocalUserManager.current + val userFlags = userManager?.state?.collectAsStateWithLifecycle()?.value?.flags + val phoneNumberSendFlagEnabled by featureFlags.observe(FeatureFlag.PhoneNumberSend).collectAsStateWithLifecycle() + val phoneNumberSendEnabled = remember(userFlags?.enablePhoneNumberSend, phoneNumberSendFlagEnabled) { + phoneNumberSendFlagEnabled || userFlags?.enablePhoneNumberSend == true + } + + val permissionsSteps = buildList { + if (phoneNumberSendEnabled) add(OnboardingStep.ContactPermission) + add(OnboardingStep.NotificationPermission) + } + + // Compute resumeAt once per steps-list identity. This recomputes when the flag loads + // (steps changes) but NOT when permissions are granted mid-flow, preventing a stale + // recomposition from triggering a spurious BackedOutOfRoot exit. + val resumeAt = remember(permissionsSteps.map { it::class }) { + val contactsGranted = checker.isGranted(contactConfig.permission) + val notificationsGranted = !notificationConfig.requiresRuntimeRequest || + checker.isGranted(notificationConfig.permission) + when { + phoneNumberSendEnabled && !contactsGranted -> 0 + !notificationsGranted -> permissionsSteps.indexOfFirst { + it is OnboardingStep.NotificationPermission + }.coerceAtLeast(0) + else -> permissionsSteps.size // all granted + } + } + + FlowHost( + steps = permissionsSteps, + resumeAt = resumeAt, + resultStateRegistry = resultStateRegistry, + completedResult = OnboardingResult.Completed, + onExit = { reason, _ -> + when (reason) { + is FlowExitReason.Completed -> { + analytics.action(Action.CompletedOnboarding) + outerNavigator.navigate( + route = AppRoute.Main.Scanner, + options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll), + ) + } + + FlowExitReason.BackedOutOfRoot -> { + // All permissions already granted + outerNavigator.navigate( + route = AppRoute.Main.Scanner, + options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll), + ) + } + + FlowExitReason.Canceled -> Unit + } + }, + entryProvider = onboardingEntryProvider(route), + ) +} + +@Composable +private fun AccountPhaseFlowHost( + route: AppRoute.OnboardingFlow, + resultStateRegistry: NavResultStateRegistry, +) { + val outerNavigator = LocalCodeNavigator.current + val userManager = LocalUserManager.current!! + val userState by userManager.state.collectAsStateWithLifecycle() + + val initialStack = route.rememberInitialStack() + + FlowHost( + initialStack = initialStack, + resultStateRegistry = resultStateRegistry, + onExit = { reason, _ -> + when (reason) { + is FlowExitReason.Completed -> when (reason.result) { + is OnboardingResult.ProceedToVerification -> { + val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null + if (hasLinkedPhone) { + outerNavigator.replace( + AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions) + ) + } else { + outerNavigator.replace( + AppRoute.Verification( + origin = AppRoute.Onboarding.AccessKey, + includePhone = true, + includeEmail = false, + target = AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + ), + fullScreen = true, + ) + ) + } + } + + OnboardingResult.LoggedIn -> { + outerNavigator.replace( + AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions) + ) + } + + OnboardingResult.Completed -> Unit + } + + FlowExitReason.BackedOutOfRoot -> Unit + FlowExitReason.Canceled -> Unit + } + }, + entryProvider = onboardingEntryProvider(route), + ) +} + +private fun onboardingEntryProvider( + route: AppRoute.OnboardingFlow, +): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { step -> + LoginStepContent(step.seed) + } + annotatedEntry { + SeedInputStepContent() + } + annotatedEntry { + AccessKeyStepContent() + } + annotatedEntry { + PhotoAccessKeyScreen() + } + annotatedEntry { + PurchaseStepContent() + } + annotatedEntry { + ContactPermissionStepContent() + } + annotatedEntry { + NotificationPermissionStepContent() + } + annotatedEntry { step -> + NotificationRationaleStepContent(permanentlyDenied = step.permanentlyDenied) + } +} + +// -- Step composables -- + +@Composable +private fun LoginStepContent(seed: String?) { + val vm = hiltViewModel() + val state by vm.stateFlow.collectAsState() + val flowNavigator = rememberFlowNavigator() + val outerNavigator = LocalOuterCodeNavigator.current + var visible by remember { mutableStateOf(false) } + val activity = LocalActivity.current + + LaunchedEffect(Unit) { + activity?.reportFullyDrawn() + visible = true + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { flowNavigator.navigateTo(OnboardingStep.AccessKey) } + .launchIn(this) + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { delay(500) } + .onEach { flowNavigator.exitWithResult(OnboardingResult.LoggedIn) } + .launchIn(this) + } + + LaunchedEffect(vm) { + vm.eventFlow + .filterIsInstance() + .onEach { delay(500) } + .onEach { + flowNavigator.replaceStack( + listOf( + OnboardingStep.Start(), + OnboardingStep.AccessKey, + OnboardingStep.Purchase, + ) + ) + } + .launchIn(this) + } + + LaunchedEffect(seed) { + if (seed != null) { + vm.dispatchEvent(LoginViewModel.Event.LogIn(seed, true)) + } + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(150)), + ) { + LoginRouterScreenContent( + isLoggingIn = state.loggingIn, + createAccount = { vm.dispatchEvent(LoginViewModel.Event.CreateAccount) }, + login = { flowNavigator.navigateTo(OnboardingStep.SeedInput) }, + isLabsOpen = state.betaOptionsVisible, + onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) }, + openBetaFlags = { outerNavigator.openAsSheet(AppRoute.Sheets.Lab) }, + ) + } +} + +@Composable +private fun SeedInputStepContent() { + val viewModel: SeedInputViewModel = hiltViewModel() + val flowNavigator = rememberFlowNavigator() + + Column { + AppBarWithTitle( + modifier = Modifier.fillMaxWidth(), + backButton = true, + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + title = stringResource(R.string.title_enterAccessKeyWords), + ) + SeedInputContent( + viewModel = viewModel, + onCantFind = { flowNavigator.navigateTo(OnboardingStep.AccessKeySavedLocation) }, + ) + } + + LaunchedEffect(viewModel) { + viewModel.navigationEvents.collect { event -> + when (event) { + SeedInputViewModel.NavigationEvent.LoggedIn -> + flowNavigator.exitWithResult(OnboardingResult.LoggedIn) + SeedInputViewModel.NavigationEvent.NeedsPurchase -> + flowNavigator.navigateTo(OnboardingStep.Purchase) + SeedInputViewModel.NavigationEvent.ResetToLogin -> + flowNavigator.replaceStack(listOf(OnboardingStep.Start())) + SeedInputViewModel.NavigationEvent.TimelockUnlocked -> + flowNavigator.replaceStack(listOf(OnboardingStep.Start())) + } + } + } +} + +@Composable +private fun AccessKeyStepContent() { + val viewModel = hiltViewModel() + val flowNavigator = rememberFlowNavigator() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_accessKey), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + ) + AccessKeyScreen( + viewModel = viewModel, + onExit = { flowNavigator.replaceStack(listOf(OnboardingStep.Start())) }, + ) { requiresIap -> + if (requiresIap) { + flowNavigator.navigateTo(OnboardingStep.Purchase) + } else { + flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification) + } + } + } +} + +@Composable +private fun PurchaseStepContent() { + val viewModel = hiltViewModel() + val flowNavigator = rememberFlowNavigator() + val state by viewModel.stateFlow.collectAsState() + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.exitWithResult(OnboardingResult.LoggedIn) } + .launchIn(this) + } + + Column { + AppBarWithTitle( + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + ) + PurchaseAccountScreenContent(state, viewModel::dispatchEvent) + } +} + +@Composable +private fun ContactPermissionStepContent() { + val flowNavigator = rememberFlowNavigator() + val analytics = LocalAnalytics.current + + val permissionState = rememberContactPermission { result -> + when (result) { + PermissionResult.Granted -> { + analytics.action(Button.AllowContacts) + flowNavigator.proceed() + } + PermissionResult.Denied, + PermissionResult.PermanentlyDenied -> flowNavigator.proceed() + PermissionResult.NotRequested -> Unit + } + } + + ContactScreenContent( + permissionState = permissionState, + onSkip = { + analytics.action(Button.SkipContacts) + flowNavigator.proceed() + }, + ) + + BackHandler { /* swallow back during onboarding permissions */ } +} + +@Composable +private fun NotificationPermissionStepContent() { + val flowNavigator = rememberFlowNavigator() + val analytics = LocalAnalytics.current + val checker = LocalPermissionChecker.current + val notificationConfig = PermissionConfigs.notifications() + + // Auto-complete if notification permission is already granted or not required. + val alreadyGranted = !notificationConfig.requiresRuntimeRequest || + checker.isGranted(notificationConfig.permission) + if (alreadyGranted) { + LaunchedEffect(Unit) { flowNavigator.proceed() } + return + } + + val permissionState = rememberNotificationPermission { result -> + when (result) { + PermissionResult.Granted -> { + analytics.action(Button.AllowPush) + flowNavigator.proceed() + } + PermissionResult.Denied -> flowNavigator.navigateTo( + OnboardingStep.NotificationPermissionRationale(false) + ) + PermissionResult.PermanentlyDenied -> flowNavigator.navigateTo( + OnboardingStep.NotificationPermissionRationale(true) + ) + PermissionResult.NotRequested -> Unit + } + } + + NotificationScreenContent( + permissionState = permissionState, + onSkip = { + analytics.action(Button.SkipPush) + flowNavigator.proceed() + }, + ) + + BackHandler { /* swallow back during onboarding permissions */ } +} + +@Composable +private fun NotificationRationaleStepContent(permanentlyDenied: Boolean) { + val flowNavigator = rememberFlowNavigator() + NotificationRationalePermissionContent( + permanentlyDenied = permanentlyDenied, + onComplete = { flowNavigator.proceed() }, + ) +} 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 deleted file mode 100644 index 77a012f55..000000000 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/AccessKeyScreen.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.flipcash.app.login.accesskey - -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.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.ui.components.AppBarWithTitle - -@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 { - val target = if (viewModel.isPhoneNumberSendEnabled) { - AppRoute.Onboarding.ContactPermission(postCreate = true) - } else { - AppRoute.Onboarding.NotificationPermission(postCreate = true) - } - - navigator.push( - AppRoute.Verification( - origin = AppRoute.Onboarding.AccessKey, - includePhone = true, - includeEmail = false, - target = target, - fullScreen = true, - ) - ) - } - } - } -} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/LoginAccessKeyViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModel.kt similarity index 87% rename from apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/LoginAccessKeyViewModel.kt rename to apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModel.kt index 6edc6b0dc..fa3c7f6d9 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/LoginAccessKeyViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModel.kt @@ -1,7 +1,6 @@ -package com.flipcash.app.login.accesskey +package com.flipcash.app.login.internal import com.flipcash.app.accesskey.BaseAccessKeyViewModel -import com.flipcash.app.analytics.Action import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager @@ -20,7 +19,7 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel -class LoginAccessKeyViewModel @Inject constructor( +internal class LoginAccessKeyViewModel @Inject constructor( resources: ResourceHelper, mnemonicManager: MnemonicManager, qrCodeGenerator: QRCodeGenerator, @@ -32,10 +31,6 @@ class LoginAccessKeyViewModel @Inject constructor( private val analytics: FlipcashAnalyticsService, ): BaseAccessKeyViewModel(resources, mnemonicManager, mediaSaver, userManager, qrCodeGenerator) { - val isPhoneNumberSendEnabled: Boolean - get() = featureFlags.observe(FeatureFlag.PhoneNumberSend).value || - userFlags.resolvedFlags.value.enablePhoneNumberSend.effectiveValue - suspend fun onWroteDownInstead(): Result { trackButton(Button.WroteAccessKey) uiFlow.update { it.copy(skipState = LoadingSuccessState(loading = true)) } 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/internal/SeedInputViewModel.kt similarity index 82% rename from apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputViewModel.kt rename to apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt index 984064f84..cbe373602 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/internal/SeedInputViewModel.kt @@ -1,21 +1,15 @@ -package com.flipcash.app.login.seed +package com.flipcash.app.login.internal -import android.annotation.SuppressLint import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager import com.flipcash.app.auth.internal.credentials.SelectCredentialError -import com.flipcash.app.core.AppRoute -import com.flipcash.app.userflags.ResolvedFlag import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.login.R import com.flipcash.services.controllers.AccountController -import com.flipcash.services.models.UserFlags -import com.flipcash.services.user.UserManager import com.getcode.crypt.MnemonicPhrase import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager -import com.getcode.navigation.core.CodeNavigator import com.getcode.opencode.managers.MnemonicManager import com.getcode.util.resources.ResourceHelper import androidx.lifecycle.ViewModel @@ -23,7 +17,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.Locale @@ -40,10 +36,9 @@ data class SeedInputUiModel( ) @HiltViewModel -class SeedInputViewModel @Inject constructor( +internal class SeedInputViewModel @Inject constructor( private val authManager: AuthManager, private val accountController: AccountController, - private val userManager: UserManager, private val userFlags: UserFlagsCoordinator, private val resources: ResourceHelper, private val mnemonicManager: MnemonicManager, @@ -51,6 +46,16 @@ class SeedInputViewModel @Inject constructor( val uiFlow = MutableStateFlow(SeedInputUiModel()) private val mnemonicCode = mnemonicManager.mnemonicCode + sealed interface NavigationEvent { + data object LoggedIn : NavigationEvent + data object NeedsPurchase : NavigationEvent + data object ResetToLogin : NavigationEvent + data object TimelockUnlocked : NavigationEvent + } + + private val _navigationEvents = MutableSharedFlow(extraBufferCapacity = 1) + val navigationEvents: SharedFlow = _navigationEvents + fun onTextChange(wordsString: String) { val isLoading = uiFlow.value.isLoading val isSuccess = uiFlow.value.isSuccess @@ -68,30 +73,27 @@ class SeedInputViewModel @Inject constructor( } } - fun onSubmit(navigator: CodeNavigator) { + fun onSubmit() { val userWordList = uiFlow.value.wordsString.trim().replace(Regex("(\\s)+"), " ").lowercase(Locale.getDefault()).split(" ") val mnemonic = MnemonicPhrase.newInstance(userWordList) ?: return - CoroutineScope(Dispatchers.IO).launch { val entropyB64: String try { entropyB64 = mnemonicManager.getEncodedBase64(mnemonic) } catch (e: Exception) { - showError(navigator) + showError() return@launch } - performLogin(navigator, entropyB64) + performLogin(entropyB64) } } - @SuppressLint("CheckResult") fun performLogin( - navigator: CodeNavigator, entropyB64: String, - isRestore: Boolean = false + isRestore: Boolean = false, ) { viewModelScope.launch { setState(isLoading = true, isSuccess = false, isContinueEnabled = false) @@ -102,9 +104,9 @@ class SeedInputViewModel @Inject constructor( resources.getString(R.string.error_title_timelockUnlocked), resources.getString(R.string.error_description_timelockUnlocked) ) - navigator.popAll() + _navigationEvents.tryEmit(NavigationEvent.TimelockUnlocked) } else { - showError(navigator) + showError() } setState(isLoading = false, isSuccess = false, isContinueEnabled = true) } @@ -114,7 +116,7 @@ class SeedInputViewModel @Inject constructor( if (resolvedFlags.minimumVersion.serverValue == null) { accountController.getUserFlags() .onSuccess { - postLoginNavigation(navigator, userFlags.resolvedFlags.value) + postLoginNavigation(userFlags.resolvedFlags.value) }.onFailure { setState(isLoading = false, isSuccess = false, isContinueEnabled = false) BottomBarManager.showError( @@ -123,34 +125,29 @@ class SeedInputViewModel @Inject constructor( ) } } else { - postLoginNavigation(navigator, resolvedFlags) + postLoginNavigation(resolvedFlags) } } } } - private suspend fun postLoginNavigation( - navigator: CodeNavigator, - flags: ResolvedUserFlags?, - ) { + private suspend fun postLoginNavigation(flags: ResolvedUserFlags?) { setState(isLoading = false, isSuccess = true, isContinueEnabled = false) delay(1.seconds) when { flags?.isRegistered?.effectiveValue == true && flags.requiresIapForRegistration.effectiveValue -> { - navigator.push(AppRoute.Onboarding.Purchase(true)) + _navigationEvents.emit(NavigationEvent.NeedsPurchase) } - - else -> navigator.replaceAll(AppRoute.Main.Scanner) + else -> _navigationEvents.emit(NavigationEvent.LoggedIn) } } - suspend fun restoreAccount(navigator: CodeNavigator): Result { + suspend fun restoreAccount(): Result { return authManager.selectAccount() .onSuccess { mnemonic -> performLogin( - navigator = navigator, entropyB64 = mnemonic.getBase64EncodedEntropy(), - isRestore = true + isRestore = true, ) }.onFailure { error -> when (error) { @@ -179,7 +176,7 @@ class SeedInputViewModel @Inject constructor( return userWordList.filter { it in mnemonicWordList }.size } - private fun showError(navigator: CodeNavigator) { + private fun showError() { BottomBarManager.showAlert( title = resources.getString(R.string.prompt_title_notFlipcashAccount), message = resources.getString(R.string.prompt_description_notFlipcashAccount), @@ -187,7 +184,7 @@ class SeedInputViewModel @Inject constructor( BottomBarAction( resources.getString(R.string.action_createNewFlipcashAccount) ) { - navigator.replaceAll(AppRoute.Onboarding.Login()) + _navigationEvents.tryEmit(NavigationEvent.ResetToLogin) }, BottomBarAction( resources.getString(R.string.action_tryDifferentFlipcashAccount), 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/screens/AccessKeyScreenContent.kt similarity index 97% rename from apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/AccessKeyScreenContent.kt rename to apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/AccessKeyScreenContent.kt index c113d70fa..98b5732fb 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/screens/AccessKeyScreenContent.kt @@ -1,6 +1,5 @@ -package com.flipcash.app.login.internal +package com.flipcash.app.login.internal.screens -import android.Manifest import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -46,7 +45,7 @@ import androidx.compose.ui.unit.isSpecified import com.flipcash.app.accesskey.AccessKeyUiModel import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.extensions.launchAppSettings -import com.flipcash.app.login.accesskey.LoginAccessKeyViewModel +import com.flipcash.app.login.internal.LoginAccessKeyViewModel import com.flipcash.app.theme.FlipcashPreview import com.flipcash.features.login.R import com.getcode.manager.BottomBarAction @@ -67,7 +66,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable -internal fun AccessKeyScreen(viewModel: LoginAccessKeyViewModel, onCompleted: (requiresIap: Boolean) -> Unit) { +internal fun AccessKeyScreen( + viewModel: LoginAccessKeyViewModel, + onExit: (() -> Unit)? = null, + onCompleted: (requiresIap: Boolean) -> Unit, +) { val navigator = LocalCodeNavigator.current val context = LocalContext.current val resources = LocalResources.current @@ -137,7 +140,7 @@ internal fun AccessKeyScreen(viewModel: LoginAccessKeyViewModel, onCompleted: (r dataState = dataState, onExport = onExportClick, onSkip = onSkipClick, - onExit = { + onExit = onExit ?: { navigator.replaceAll(AppRoute.Onboarding.Login()) } ) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginScreenContent.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/LoginScreenContent.kt similarity index 99% rename from apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginScreenContent.kt rename to apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/LoginScreenContent.kt index df9749b72..c7146d6b6 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginScreenContent.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/LoginScreenContent.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.login.internal +package com.flipcash.app.login.internal.screens import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement 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/internal/screens/PhotoAccessKeyScreen.kt similarity index 96% rename from apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/accesskey/PhotoAccessKeyScreen.kt rename to apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/PhotoAccessKeyScreen.kt index 58a37e0c0..b6ab19630 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/internal/screens/PhotoAccessKeyScreen.kt @@ -1,7 +1,6 @@ -package com.flipcash.app.login.accesskey +package com.flipcash.app.login.internal.screens import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio @@ -30,7 +29,7 @@ import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold @Composable -fun PhotoAccessKeyScreen() { +internal fun PhotoAccessKeyScreen() { val navigator = LocalCodeNavigator.current val context = LocalContext.current 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/screens/SeedInputContent.kt similarity index 93% rename from apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputContent.kt rename to apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/screens/SeedInputContent.kt index 34d50a211..102b43a36 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/screens/SeedInputContent.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.login.internal +package com.flipcash.app.login.internal.screens import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -41,14 +41,11 @@ 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 com.flipcash.app.core.AppRoute import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags -import com.flipcash.app.login.seed.SeedInputUiModel -import com.flipcash.app.login.seed.SeedInputViewModel +import com.flipcash.app.login.internal.SeedInputUiModel +import com.flipcash.app.login.internal.SeedInputViewModel import com.flipcash.features.login.R -import com.getcode.navigation.core.CodeNavigator -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.theme.inputColors import com.getcode.ui.core.MaskWithSpacesTransformation @@ -64,15 +61,17 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable -internal fun SeedInputContent(viewModel: SeedInputViewModel) { - val navigator: CodeNavigator = LocalCodeNavigator.current +internal fun SeedInputContent( + viewModel: SeedInputViewModel, + onCantFind: () -> Unit, +) { val dataState by viewModel.uiFlow.collectAsState() SeedInputContent( state = dataState, onTextChange = { viewModel.onTextChange(it) }, - onLogin = { viewModel.onSubmit(navigator) }, - onRestore = { viewModel.restoreAccount(navigator) }, - onCantFind = { navigator.push(AppRoute.Onboarding.AccessKeySavedLocation) } + onLogin = { viewModel.onSubmit() }, + onRestore = { viewModel.restoreAccount() }, + onCantFind = onCantFind, ) } 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 deleted file mode 100644 index f80b26d01..000000000 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginRouter.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.flipcash.app.login.router - -import androidx.activity.compose.LocalActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.extensions.openAsSheet -import com.flipcash.app.login.internal.LoginRouterScreenContent -import com.getcode.navigation.core.LocalCodeNavigator -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -@Composable -fun LoginRouter( - seed: String? = null, - fromDeeplink: Boolean = false, -) { - val vm = hiltViewModel() - val state by vm.stateFlow.collectAsState() - val navigator = LocalCodeNavigator.current - var visible by remember { mutableStateOf(false) } - val activity = LocalActivity.current - - LaunchedEffect(Unit) { - activity?.reportFullyDrawn() - visible = true - } - - LaunchedEffect(vm) { - vm.eventFlow - .filterIsInstance() - .onEach { navigator.push(AppRoute.Onboarding.AccessKey) } - .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( - routes = listOf( - AppRoute.Onboarding.AccessKey, - AppRoute.Onboarding.Purchase(true) - ) - ) - } - .launchIn(this) - } - - LaunchedEffect(seed) { - if (seed != null) { - vm.dispatchEvent(LoginViewModel.Event.LogIn(seed, fromDeeplink)) - } - } - - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(150)), - ) { - 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.openAsSheet(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 deleted file mode 100644 index ba19b59ff..000000000 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/seed/SeedInputScreen.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.flipcash.app.login.seed - -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 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.ui.components.AppBarWithTitle - -@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/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 ed6b16492..5360ec77a 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 @@ -54,7 +54,7 @@ fun MyAccountScreen() { .filterIsInstance() .onEach { navigator.hide() - navigator.replaceAll(AppRoute.Onboarding.Login()) } + navigator.replaceAll(AppRoute.OnboardingFlow()) } .launchIn(this) } @@ -63,7 +63,7 @@ fun MyAccountScreen() { .filterIsInstance() .onEach { navigator.hide() - navigator.replaceAll(AppRoute.Onboarding.Login()) } + navigator.replaceAll(AppRoute.OnboardingFlow()) } .launchIn(this) } 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 e07272069..3d32e712b 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 @@ -42,7 +42,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @Composable -internal fun PurchaseAccountScreen(viewModel: PurchaseAccountViewModel) { +fun PurchaseAccountScreen(viewModel: PurchaseAccountViewModel) { val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsState() @@ -59,7 +59,7 @@ internal fun PurchaseAccountScreen(viewModel: PurchaseAccountViewModel) { } @Composable -private fun PurchaseAccountScreenContent( +fun PurchaseAccountScreenContent( state: PurchaseAccountViewModel.State, dispatchEvent: (PurchaseAccountViewModel.Event) -> Unit ) { diff --git a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountViewModel.kt b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountViewModel.kt index c9caabdde..16c672060 100644 --- a/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountViewModel.kt +++ b/apps/flipcash/features/purchase/src/main/kotlin/com/flipcash/app/purchase/internal/PurchaseAccountViewModel.kt @@ -32,7 +32,7 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @HiltViewModel -internal class PurchaseAccountViewModel @Inject constructor( +class PurchaseAccountViewModel @Inject constructor( private val authManager: AuthManager, billingClient: BillingClient, resources: ResourceHelper, 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 dfe77a123..9694431ec 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 @@ -72,5 +72,14 @@ fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { @Composable fun NotificationPermissionRationaleScreen(permanentlyDenied: Boolean = false) { - NotificationRationalePermissionContent(permanentlyDenied) + val navigator = LocalCodeNavigator.current + NotificationRationalePermissionContent( + permanentlyDenied = permanentlyDenied, + onComplete = { + navigator.navigate( + route = AppRoute.Main.Scanner, + options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) + ) + }, + ) } diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt index 6df5c5356..b837df9dd 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt @@ -26,7 +26,7 @@ import com.getcode.util.permissions.ProvideTestPermissions import com.getcode.util.permissions.rememberContactPermission @Composable -internal fun ContactScreenContent( +fun ContactScreenContent( permissionState: PermissionHandle, onSkip: () -> Unit, ) { diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt index 1b86e5114..b5304d4e8 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt @@ -27,7 +27,7 @@ import com.getcode.util.permissions.rememberNotificationPermission @Composable -internal fun NotificationScreenContent( +fun NotificationScreenContent( permissionState: PermissionHandle, onSkip: () -> Unit, ) { diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt index 7072c2cb3..3ad3708a8 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt @@ -20,24 +20,23 @@ import androidx.compose.ui.tooling.preview.Preview import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.StubFlipcashAnalytics import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.extensions.launchAppSettings import com.flipcash.app.permissions.internal.notifications.components.AnimatedSwitchPreview import com.flipcash.app.theme.FlipcashPreview import com.flipcash.shared.permissions.R import com.getcode.libs.analytics.LocalAnalytics -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.core.NavOptions import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold @Composable -internal fun NotificationRationalePermissionContent(permanentlyDenied: Boolean = false) { +fun NotificationRationalePermissionContent( + permanentlyDenied: Boolean = false, + onComplete: () -> Unit, +) { val analytics = rememberAnalytics() val context = LocalContext.current - val navigator = LocalCodeNavigator.current CodeScaffold( bottomBar = { Column( @@ -63,10 +62,7 @@ internal fun NotificationRationalePermissionContent(permanentlyDenied: Boolean = .padding(horizontal = CodeTheme.dimens.inset), onClick = { analytics.action(Button.SkipPush) - navigator.navigate( - AppRoute.Main.Scanner, - options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) - ) + onComplete() }, text = if (permanentlyDenied) { stringResource(R.string.action_notNow) @@ -137,7 +133,7 @@ internal fun NotificationRationalePermissionContent(permanentlyDenied: Boolean = private fun PreviewNotificationRationalePermissionScreen() { FlipcashPreview(showBackground = true) { CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { - NotificationRationalePermissionContent() + NotificationRationalePermissionContent(onComplete = {}) } } } \ No newline at end of file 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 dde8ef433..ac7483145 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 @@ -37,9 +37,9 @@ internal class AppRouter( if (authStateProvider() !is AuthState.LoggedInWithUser) { return when (type) { is DeeplinkType.Login -> DeeplinkAction.Navigate( - listOf(AppRoute.Onboarding.Login(type.entropy, fromDeeplink = true)) + listOf(AppRoute.OnboardingFlow(seed = type.entropy, fromDeeplink = true)) ) - else -> DeeplinkAction.Navigate(listOf(AppRoute.Onboarding.Login())) + else -> DeeplinkAction.Navigate(listOf(AppRoute.OnboardingFlow())) } } 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 index 38b1908a2..cc5faacb4 100644 --- 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 @@ -141,33 +141,33 @@ class AppRouterTest { // region dispatch — Not logged in @Test - fun `dispatch redirects login deeplink to onboarding with entropy when logged out`() { + fun `dispatch redirects login deeplink to onboarding flow with seed 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) + assertIs(route) assertEquals("seed123", route.seed) assertTrue(route.fromDeeplink) } @Test - fun `dispatch redirects non-login deeplink to plain login when logged out`() { + fun `dispatch redirects non-login deeplink to plain onboarding flow 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) + assertIs(route) assertNull(route.seed) } @Test - fun `dispatch redirects cash link to login when auth state is unknown`() { + fun `dispatch redirects cash link to onboarding flow 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()) + assertIs(action.routes.single()) } // endregion @@ -303,10 +303,10 @@ class AppRouterTest { loggedOut() val loginUrl = "https://app.flipcash.com/login/e=seed" - // Logged out: should redirect to onboarding + // Logged out: should redirect to onboarding flow val action1 = router.dispatch(DeepLink(loginUrl)) assertIs(action1) - assertIs(action1.routes.single()) + assertIs(action1.routes.single()) // Log in loggedIn() diff --git a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionChecker.kt b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionChecker.kt index c3fb03f22..2c9d906b5 100644 --- a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionChecker.kt +++ b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionChecker.kt @@ -48,11 +48,13 @@ private class FakePermissionChecker( /** * [androidx.compose.runtime.CompositionLocal] providing the active [PermissionChecker]. * - * Internal — replaced in tests via [ProvideTestPermissions]. - * In production, provide [AndroidPermissionChecker] via your DI-aware - * composition root. + * In production, provide [AndroidPermissionChecker] via [ProvidePermissionChecker] + * in your composition root. Replaced in tests via [ProvideTestPermissions]. + * + * Use this for lightweight, side-effect-free permission checks (no launcher registration). + * For permission requests with result callbacks, use [rememberPermission] instead. */ -internal val LocalPermissionChecker: ProvidableCompositionLocal = +val LocalPermissionChecker: ProvidableCompositionLocal = staticCompositionLocalOf { DefaultPermissionChecker } /** diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt index 24597972e..afc3dc1f6 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt @@ -14,8 +14,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.Snapshot @@ -78,43 +80,93 @@ private val DefaultFlowPopTransitionSpec: } /** - * Hosts a multi-step flow inside its own private [NavBackStack]. Generalises the nested sub- - * NavHost pattern used by `SheetContent` into a reusable primitive. + * Linear flow overload — for ordered flows with [FlowNavigator.proceed]. * - * Typical use — a flow screen wrapper that lives in the outer entry provider: - * ``` - * @Composable - * fun MyFlowScreen(route: AppRoute.MyFlow, resultStateRegistry: NavResultStateRegistry) { - * val outer = LocalCodeNavigator.current - * FlowHost( - * initialStack = route.initialStack.filterIsInstance(), - * resultStateRegistry = resultStateRegistry, - * onExit = { reason, isSheetRoot -> - * val result = when (reason) { - * is FlowExitReason.Completed -> reason.result - * FlowExitReason.Canceled, - * FlowExitReason.BackedOutOfRoot -> MyResult.Canceled - * } - * if (isSheetRoot) { - * outer.pop() - * } else { - * outer.deliverFlowResult(route, NavResultOrCanceled.ReturnValue(result)) - * outer.pop() - * } - * }, - * entryProvider = myEntryProvider(route), - * ) - * } - * ``` + * The backstack is seeded with `steps[resumeAt]`. Calling [FlowNavigator.proceed] advances + * through the [steps] list in order, or exits with [completedResult] at the end. * - * The host captures [LocalViewModelStoreOwner] at the call site (the outer flow entry's own - * [androidx.lifecycle.ViewModelStoreOwner]) and exposes it as [LocalFlowViewModelStoreOwner] so - * that [flowSharedViewModel] can resolve a single shared [androidx.lifecycle.ViewModel] across - * all steps in the flow. + * @param steps Ordered list of steps in the flow. + * @param resumeAt Index in [steps] to start at. If `>= steps.size`, the flow exits + * immediately with [FlowExitReason.BackedOutOfRoot] (all steps done). + * @param completedResult Result delivered when the flow reaches the end of [steps]. + * @param onProceed Optional callback invoked by [FlowNavigator.proceed] before the default + * step-list behavior. Return `true` if handled, `false` to fall through. + * Receives [FlowNavigator] as `this` so it can call [FlowNavigator.navigateTo], + * [FlowNavigator.exitWithResult], etc. */ @Composable fun FlowHost( + steps: List, + resumeAt: Int = 0, + resultStateRegistry: NavResultStateRegistry, + onExit: (reason: FlowExitReason, isSheetRoot: Boolean) -> Unit, + completedResult: R? = null, + onProceed: (FlowNavigator.(currentStep: S) -> Boolean)? = null, + entryProvider: (NavKey) -> NavEntry, + decorators: List> = emptyList(), + sceneStrategies: List> = listOf(SinglePaneSceneStrategy()), + transitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + DefaultFlowTransitionSpec, + popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + DefaultFlowPopTransitionSpec, +) { + val clampedResumeAt = resumeAt.coerceIn(0, steps.size) + val initialStack = if (clampedResumeAt < steps.size) listOf(steps[clampedResumeAt]) else emptyList() + FlowHostImpl( + initialStack = initialStack, + steps = steps, + completedResult = completedResult, + onProceed = onProceed, + resultStateRegistry = resultStateRegistry, + onExit = onExit, + entryProvider = entryProvider, + decorators = decorators, + sceneStrategies = sceneStrategies, + transitionSpec = transitionSpec, + popTransitionSpec = popTransitionSpec, + ) +} + +/** + * Non-linear flow overload — for flows that manage their own navigation. + * + * The backstack is seeded with all items in [initialStack]. [FlowNavigator.proceed] is a no-op; + * steps use [FlowNavigator.navigateTo] / [FlowNavigator.exitWithResult] directly. + */ +@Composable +fun FlowHost( + initialStack: List, + resultStateRegistry: NavResultStateRegistry, + onExit: (reason: FlowExitReason, isSheetRoot: Boolean) -> Unit, + entryProvider: (NavKey) -> NavEntry, + decorators: List> = emptyList(), + sceneStrategies: List> = listOf(SinglePaneSceneStrategy()), + transitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + DefaultFlowTransitionSpec, + popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + DefaultFlowPopTransitionSpec, +) { + FlowHostImpl( + initialStack = initialStack, + steps = emptyList(), + completedResult = null, + onProceed = null, + resultStateRegistry = resultStateRegistry, + onExit = onExit, + entryProvider = entryProvider, + decorators = decorators, + sceneStrategies = sceneStrategies, + transitionSpec = transitionSpec, + popTransitionSpec = popTransitionSpec, + ) +} + +@Composable +private fun FlowHostImpl( initialStack: List, + steps: List, + completedResult: R?, + onProceed: (FlowNavigator.(currentStep: S) -> Boolean)?, resultStateRegistry: NavResultStateRegistry, onExit: (reason: FlowExitReason, isSheetRoot: Boolean) -> Unit, entryProvider: (NavKey) -> NavEntry, @@ -130,8 +182,11 @@ fun FlowHost( "FlowHost requires a LocalViewModelStoreOwner (the outer flow entry's owner)" } - // Exit path needs to be a stable reference to avoid re-creating the navigator. + // Stable references via rememberUpdatedState to avoid re-creating the navigator. val currentOnExit = rememberUpdatedState(onExit) + val currentOnProceed = rememberUpdatedState(onProceed) + val currentSteps = rememberUpdatedState(steps) + val currentCompletedResult = rememberUpdatedState(completedResult) if (initialStack.isEmpty()) { LaunchedEffect(Unit) { currentOnExit.value(FlowExitReason.BackedOutOfRoot, false) } @@ -162,6 +217,32 @@ fun FlowHost( } } + // Re-seed if the caller's initialStack changed before the user has navigated. + // This handles the case where an async value (e.g. feature flag) settles after + // the first composition already seeded the backstack with a stale value. + // We track what was seeded and only re-seed if the backstack is still untouched. + // Animation is suppressed during re-seed so NavDisplay doesn't slide between + // the stale and corrected content. + val seededStack = remember { initialStack.map { it::class } } + val currentInitialClasses = initialStack.map { it::class } + val suppressTransition = remember { mutableStateOf(false) } + LaunchedEffect(currentInitialClasses) { + if (currentInitialClasses == seededStack) return@LaunchedEffect + val backstackClasses = innerBackStack.map { it::class } + if (backstackClasses != seededStack) return@LaunchedEffect + suppressTransition.value = true + Snapshot.withMutableSnapshot { + innerBackStack.clear() + @Suppress("UNCHECKED_CAST") + initialStack.forEach { innerBackStack.add(it as NavKey) } + } + // Wait two frames so NavDisplay processes the change under the suppressed + // spec before restoring normal transitions. + withFrameNanos {} + withFrameNanos {} + suppressTransition.value = false + } + // Build the inner navigator + flow navigator once and keep them stable. // onRootReached and onExit read through rememberUpdatedState so the references // never change — preventing unnecessary recompositions of children that read @@ -177,6 +258,9 @@ fun FlowHost( navigator = innerNavigator, outerNavigator = outerNavigator, onExit = { reason -> currentOnExit.value(reason, isSheetRoot) }, + steps = { currentSteps.value }, + completedResult = { currentCompletedResult.value }, + onProceed = { step -> currentOnProceed.value?.invoke(this, step) ?: false }, ) } @@ -213,12 +297,14 @@ fun FlowHost( LocalFlowViewModelStoreOwner provides flowOwner, LocalFlowDismissStyle provides dismissStyle, ) { + val noTransition: AnimatedContentTransitionScope>.() -> ContentTransform = + { EnterTransition.None togetherWith ExitTransition.None } AppNavHost( navigator = innerNavigator, resultStateRegistry = resultStateRegistry, sceneStrategies = sceneStrategies, - transitionSpec = transitionSpec, - popTransitionSpec = popTransitionSpec, + transitionSpec = if (suppressTransition.value) noTransition else transitionSpec, + popTransitionSpec = if (suppressTransition.value) noTransition else popTransitionSpec, onBack = { innerNavigator.navigateBack() }, decorators = decorators, entryProvider = entryProvider, @@ -230,6 +316,9 @@ private class InnerFlowNavigator( private val navigator: CodeNavigator, private val outerNavigator: CodeNavigator, private val onExit: (FlowExitReason) -> Unit, + private val steps: () -> List, + private val completedResult: () -> R?, + private val onProceed: (FlowNavigator.(S) -> Boolean)?, ) : FlowNavigator { @Suppress("UNCHECKED_CAST") @@ -275,6 +364,32 @@ private class InnerFlowNavigator( onExit(FlowExitReason.Canceled) } + override fun proceed() { + val step = currentStep ?: return + val currentSteps = steps() + if (currentSteps.isEmpty()) return // non-linear flow, proceed is a no-op + + // Let the callback handle it first + if (onProceed?.invoke(this, step) == true) return + + // Default: advance through the steps list. + // If the current step isn't in the list (e.g. a rationale sub-step), scan the + // backstack for the most recent step that IS in the list and advance from there. + val anchorIndex = currentSteps.indexOfFirst { it::class == step::class }.let { idx -> + if (idx >= 0) return@let idx + navigator.backStack.asReversed().firstNotNullOfOrNull { entry -> + currentSteps.indexOfFirst { it::class == entry::class }.takeIf { it >= 0 } + } ?: -1 + } + val nextIndex = anchorIndex + 1 + if (anchorIndex >= 0 && nextIndex <= currentSteps.lastIndex) { + navigateTo(currentSteps[nextIndex]) + } else { + val result = completedResult() + if (result != null) exitWithResult(result) + } + } + override fun navigate(route: NavKey) { outerNavigator.push(route) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt index ba367bf9b..32d0f1b93 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt @@ -41,6 +41,20 @@ interface FlowNavigator { /** Exit the flow without delivering a result (caller sees it as a cancellation). */ fun exitCanceled() + /** + * Advance the flow from the current step. + * + * **Linear flows** (created with the `steps` + `resumeAt` overload of [FlowHost]): + * tries `onProceed` first — if it returns `true`, the callback handled navigation. + * Otherwise, advances to the next step in the `steps` list, or exits with `completedResult` + * when the end is reached. Steps not in the list (e.g. sub-steps like rationale screens) + * scan the backstack for the most recent parent that *is* in the list and advance from there. + * + * **Non-linear flows** (created with the `initialStack` overload): this is a no-op. + * Steps use [navigateTo] / [exitWithResult] directly. + */ + fun proceed() + /** * Push [route] onto the *outer* (app-level) navigator. * Use this when a flow step needs to open a screen outside the flow @@ -74,6 +88,7 @@ class PreviewFlowNavigator : FlowNavigator { override fun back(): Boolean = false override fun exitWithResult(result: R) {} override fun exitCanceled() {} + override fun proceed() {} override fun navigate(route: NavKey) {} } From a01d8b874a4857e787e3c8f26468f3b55f37b892 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 27 May 2026 21:19:10 -0400 Subject: [PATCH 2/8] feat(onboarding): skip contact permission step when ContactPickerMode is enabled --- .../com/flipcash/app/login/OnboardingFlowScreen.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 1bb882cb1..59a0cd068 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -86,7 +86,10 @@ import kotlinx.coroutines.flow.onEach * which matches current behavior (shows verification). * * **Permissions phase** (all paths converge here): - * Contacts (if phone-number-send enabled) → notifications → Scanner. + * Contacts (if phone-number-send enabled and [FeatureFlag.ContactPickerMode] is off) + * → notifications → Scanner. + * When ContactPickerMode is enabled, contacts are accessed via the system picker + * at call site (no READ_CONTACTS permission needed), so the contact step is skipped. * Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost]. */ @Composable @@ -146,16 +149,17 @@ private fun PermissionsPhaseFlowHost( val phoneNumberSendEnabled = remember(userFlags?.enablePhoneNumberSend, phoneNumberSendFlagEnabled) { phoneNumberSendFlagEnabled || userFlags?.enablePhoneNumberSend == true } + val contactPickerMode by featureFlags.observe(FeatureFlag.ContactPickerMode).collectAsStateWithLifecycle() val permissionsSteps = buildList { - if (phoneNumberSendEnabled) add(OnboardingStep.ContactPermission) + if (phoneNumberSendEnabled && !contactPickerMode) add(OnboardingStep.ContactPermission) add(OnboardingStep.NotificationPermission) } // Compute resumeAt once per steps-list identity. This recomputes when the flag loads // (steps changes) but NOT when permissions are granted mid-flow, preventing a stale // recomposition from triggering a spurious BackedOutOfRoot exit. - val resumeAt = remember(permissionsSteps.map { it::class }) { + val resumeAt = remember(permissionsSteps.map { it::class }, contactPickerMode) { val contactsGranted = checker.isGranted(contactConfig.permission) val notificationsGranted = !notificationConfig.requiresRuntimeRequest || checker.isGranted(notificationConfig.permission) From adf4293e68161a5aca6a026472ebc86764df9013 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 08:13:07 -0400 Subject: [PATCH 3/8] feat(auth): fetch user profile on login to populate verified phone number Ensures UserProfile (including verifiedPhoneNumber) is available before onboarding routing decisions that depend on phone-linked state. Signed-off-by: Brandon McAnsh --- .../src/main/kotlin/com/flipcash/app/auth/AuthManager.kt | 4 ++++ .../src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt | 3 +++ 2 files changed, 7 insertions(+) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index 188ce2432..69691246b 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -10,6 +10,7 @@ import com.flipcash.app.push.PushTokenProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.AccountController +import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager @@ -34,6 +35,7 @@ class AuthManager @Inject constructor( private val userManager: UserManager, private val notificationManager: NotificationManagerCompat, private val accountController: AccountController, + private val profileController: ProfileController, private val pushController: PushController, private val pushTokenProvider: PushTokenProvider, private val tokenCoordinator: TokenCoordinator, @@ -177,6 +179,8 @@ class AuthManager @Inject constructor( taggedTrace("Failed to get user flags after retries", type = TraceType.Error) userManager.set(authState = AuthState.Registered()) } + + profileController.updateUserProfile() } launch { savePrefs() } } diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt index 6f0940016..c54a94ec0 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt @@ -10,6 +10,7 @@ import com.flipcash.app.push.PushTokenProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.AccountController +import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController import com.flipcash.services.models.UserFlags import com.flipcash.services.user.AuthState @@ -45,6 +46,7 @@ class AuthManagerTest { private val userManager: UserManager = mockk(relaxed = true) private val notificationManager: NotificationManagerCompat = mockk(relaxed = true) private val accountController: AccountController = mockk(relaxed = true) + private val profileController: ProfileController = mockk(relaxed = true) private val pushController: PushController = mockk(relaxed = true) private val pushTokenProvider: PushTokenProvider = mockk(relaxed = true) private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) @@ -72,6 +74,7 @@ class AuthManagerTest { userManager = userManager, notificationManager = notificationManager, accountController = accountController, + profileController = profileController, pushController = pushController, pushTokenProvider = pushTokenProvider, tokenCoordinator = tokenCoordinator, From c903b6acbd0d62eac73122b6f045b5499ec962a6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 08:13:18 -0400 Subject: [PATCH 4/8] feat(onboarding): skip contact permission for existing users and extract routing logic - Add skipContacts flag to OnboardingFlow route, passed from PostAccessKey path - Purchase now exits with ProceedToVerification so IAP paths check phone-linked state and route through verification when needed - Extract resolvePostAccountRoute pure function from composable routing logic - Add OnboardingRoutingTest covering all flow chart paths - Replace prose KDoc with ASCII flow charts documenting each onboarding path Signed-off-by: Brandon McAnsh --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 1 + .../app/login/OnboardingFlowScreen.kt | 133 +++++++++--------- .../app/login/OnboardingRoutingTest.kt | 104 ++++++++++++++ 3 files changed, 172 insertions(+), 66 deletions(-) create mode 100644 apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt 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 d9d99af98..778f8d278 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 @@ -71,6 +71,7 @@ sealed interface AppRoute : NavKey, Parcelable { val seed: String? = null, val fromDeeplink: Boolean = false, val resumeAt: ResumePoint = ResumePoint.Login, + val skipContacts: Boolean = false, ) : AppRoute, FlowRoute { enum class Phase { Account, Permissions } enum class ResumePoint { Login, AccessKey, AccessKeyThenPurchase, PostAccessKey } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 59a0cd068..dff407b2b 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -67,30 +67,35 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach /** - * Entry point for the onboarding flow. Routing decisions: + * Entry point for the unified onboarding flow. * - * **New account** (`ProceedToVerification` from AccessKey): - * AccessKey → phone verification → permissions → Scanner. - * Phone is never linked yet, so verification is always shown. + * ``` + * 1. New account (ResumePoint.Login → ProceedToVerification) * - * **Seed restore** (`LoggedIn` from SeedInput): - * SeedInput → permissions → Scanner. - * Verification is skipped — the phone may already be linked server-side, - * and existing users will encounter phone verification in-app when they - * first use the send flow. + * Start → AccessKey ──┬────────────→ Verification² → Contacts¹ → Notifications → Scanner + * └→ Purchase ─┘ * - * **App resume, access key seen, no IAP** (`PostAccessKey`): - * Checks [UserProfile.verifiedPhoneNumber]. If linked, skips to permissions; - * otherwise routes through verification first. Profile may be null at this - * point (fetched post-login by ProfileUpdater) — defaults to "not linked", - * which matches current behavior (shows verification). + * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput) * - * **Permissions phase** (all paths converge here): - * Contacts (if phone-number-send enabled and [FeatureFlag.ContactPickerMode] is off) - * → notifications → Scanner. - * When ContactPickerMode is enabled, contacts are accessed via the system picker - * at call site (no READ_CONTACTS permission needed), so the contact step is skipped. + * Start → SeedInput ──┬──────────────────────→ Contacts¹ → Notifications → Scanner + * └→ Purchase → Verification² ─┘ + * + * 3. App resume (ResumePoint.PostAccessKey) + * + * ┬─ phone linked ──→ Notifications → Scanner + * └─ not linked ────→ Verification → Notifications → Scanner + * (contacts always skipped — existing users encounter contacts in-app) + * + * 4. Mid-flow resume (ResumePoint.AccessKey / AccessKeyThenPurchase) + * + * Same as (1) but initialStack resumes at the AccessKey or Purchase step. + * ``` + * + * ¹ Contact permission is shown only when [FeatureFlag.PhoneNumberSend] is enabled + * **and** [FeatureFlag.ContactPickerMode] is off. When ContactPickerMode is on, + * contacts are accessed via the system picker at call site (no READ_CONTACTS needed). * Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost]. + * ² Verification is skipped if a phone number is already linked. */ @Composable fun OnboardingFlowScreen( @@ -101,33 +106,26 @@ fun OnboardingFlowScreen( route.phase == AppRoute.OnboardingFlow.Phase.Permissions -> PermissionsPhaseFlowHost(route, resultStateRegistry) route.resumeAt == AppRoute.OnboardingFlow.ResumePoint.PostAccessKey -> - PostAccessKeyRedirect() + CompleteExistingUserOnboarding() else -> AccountPhaseFlowHost(route, resultStateRegistry) } } @Composable -private fun PostAccessKeyRedirect() { +private fun CompleteExistingUserOnboarding() { val navigator = LocalCodeNavigator.current val userManager = LocalUserManager.current!! val userState by userManager.state.collectAsStateWithLifecycle() val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null LaunchedEffect(Unit) { - if (hasLinkedPhone) { - navigator.replace(AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions)) - } else { - navigator.replace( - AppRoute.Verification( - origin = AppRoute.Onboarding.AccessKey, - includePhone = true, - includeEmail = false, - target = AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions), - fullScreen = true, - ) - ) - } + val route = resolvePostAccountRoute( + result = OnboardingResult.ProceedToVerification, + hasLinkedPhone = hasLinkedPhone, + skipContacts = true, + ) + route?.let { navigator.replace(it) } } } @@ -152,7 +150,7 @@ private fun PermissionsPhaseFlowHost( val contactPickerMode by featureFlags.observe(FeatureFlag.ContactPickerMode).collectAsStateWithLifecycle() val permissionsSteps = buildList { - if (phoneNumberSendEnabled && !contactPickerMode) add(OnboardingStep.ContactPermission) + if (!route.skipContacts && phoneNumberSendEnabled && !contactPickerMode) add(OnboardingStep.ContactPermission) add(OnboardingStep.NotificationPermission) } @@ -164,7 +162,7 @@ private fun PermissionsPhaseFlowHost( val notificationsGranted = !notificationConfig.requiresRuntimeRequest || checker.isGranted(notificationConfig.permission) when { - phoneNumberSendEnabled && !contactsGranted -> 0 + !route.skipContacts && phoneNumberSendEnabled && !contactsGranted -> 0 !notificationsGranted -> permissionsSteps.indexOfFirst { it is OnboardingStep.NotificationPermission }.coerceAtLeast(0) @@ -218,35 +216,10 @@ private fun AccountPhaseFlowHost( resultStateRegistry = resultStateRegistry, onExit = { reason, _ -> when (reason) { - is FlowExitReason.Completed -> when (reason.result) { - is OnboardingResult.ProceedToVerification -> { - val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null - if (hasLinkedPhone) { - outerNavigator.replace( - AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions) - ) - } else { - outerNavigator.replace( - AppRoute.Verification( - origin = AppRoute.Onboarding.AccessKey, - includePhone = true, - includeEmail = false, - target = AppRoute.OnboardingFlow( - phase = AppRoute.OnboardingFlow.Phase.Permissions, - ), - fullScreen = true, - ) - ) - } - } - - OnboardingResult.LoggedIn -> { - outerNavigator.replace( - AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions) - ) - } - - OnboardingResult.Completed -> Unit + is FlowExitReason.Completed -> { + val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null + val route = resolvePostAccountRoute(reason.result, hasLinkedPhone) + route?.let { outerNavigator.replace(it) } } FlowExitReason.BackedOutOfRoot -> Unit @@ -257,6 +230,34 @@ private fun AccountPhaseFlowHost( ) } +internal fun resolvePostAccountRoute( + result: OnboardingResult, + hasLinkedPhone: Boolean, + skipContacts: Boolean = false, +): AppRoute? { + val permissionsRoute = AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + skipContacts = skipContacts, + ) + return when (result) { + is OnboardingResult.ProceedToVerification -> { + if (hasLinkedPhone) { + permissionsRoute + } else { + AppRoute.Verification( + origin = AppRoute.Onboarding.AccessKey, + includePhone = true, + includeEmail = false, + target = permissionsRoute, + fullScreen = true, + ) + } + } + OnboardingResult.LoggedIn -> permissionsRoute + OnboardingResult.Completed -> null + } +} + private fun onboardingEntryProvider( route: AppRoute.OnboardingFlow, ): (NavKey) -> NavEntry = entryProvider { @@ -426,7 +427,7 @@ private fun PurchaseStepContent() { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { flowNavigator.exitWithResult(OnboardingResult.LoggedIn) } + .onEach { flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification) } .launchIn(this) } diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt new file mode 100644 index 000000000..b6baec311 --- /dev/null +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt @@ -0,0 +1,104 @@ +package com.flipcash.app.login + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.onboarding.OnboardingResult +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class OnboardingRoutingTest { + + // -- Path 1: New account (AccessKey exits with ProceedToVerification) -- + + @Test + fun `path 1 - new account routes through verification when phone not linked`() { + val route = resolvePostAccountRoute( + result = OnboardingResult.ProceedToVerification, + hasLinkedPhone = false, + ) + val verification = assertIs(route) + val target = assertIs(verification.target) + assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) + assertEquals(false, target.skipContacts) + } + + @Test + fun `path 1 - new account skips verification when phone already linked`() { + val route = resolvePostAccountRoute( + result = OnboardingResult.ProceedToVerification, + hasLinkedPhone = true, + ) + val flow = assertIs(route) + assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) + assertEquals(false, flow.skipContacts) + } + + // -- Path 2: Seed restore (SeedInput exits with LoggedIn, Purchase exits with ProceedToVerification) -- + + @Test + fun `path 2 - seed restore without IAP skips verification`() { + val route = resolvePostAccountRoute( + result = OnboardingResult.LoggedIn, + hasLinkedPhone = false, + ) + val flow = assertIs(route) + assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) + assertEquals(false, flow.skipContacts) + } + + @Test + fun `path 2 - seed restore with IAP routes through verification when phone not linked`() { + val route = resolvePostAccountRoute( + result = OnboardingResult.ProceedToVerification, + hasLinkedPhone = false, + ) + val verification = assertIs(route) + val target = assertIs(verification.target) + assertEquals(false, target.skipContacts) + } + + @Test + fun `path 2 - seed restore with IAP skips verification when phone linked`() { + val route = resolvePostAccountRoute( + result = OnboardingResult.ProceedToVerification, + hasLinkedPhone = true, + ) + val flow = assertIs(route) + assertEquals(false, flow.skipContacts) + } + + // -- Path 3: App resume (PostAccessKey, skipContacts = true) -- + + @Test + fun `path 3 - app resume skips verification and contacts when phone linked`() { + val route = resolvePostAccountRoute( + result = OnboardingResult.ProceedToVerification, + hasLinkedPhone = true, + skipContacts = true, + ) + val flow = assertIs(route) + assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) + assertEquals(true, flow.skipContacts) + } + + @Test + fun `path 3 - app resume routes through verification and skips contacts when phone not linked`() { + val route = resolvePostAccountRoute( + result = OnboardingResult.ProceedToVerification, + hasLinkedPhone = false, + skipContacts = true, + ) + val verification = assertIs(route) + val target = assertIs(verification.target) + assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) + assertEquals(true, target.skipContacts) + } + + // -- Completed (no-op) -- + + @Test + fun `completed result returns null`() { + assertNull(resolvePostAccountRoute(OnboardingResult.Completed, hasLinkedPhone = false)) + } +} From e77e9ae523f9a568b4e327ce1cf53f154d072e02 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 12:29:19 -0400 Subject: [PATCH 5/8] feat(contacts): suspend contact sync, Android 17 multi-pick picker, and loading button states Make ContactCoordinator.sync() and addPickedContacts() suspend returning Result so callers can await completion. Add ContactAccessHandle using ContactsPickerSessionContract on API 37+ with phone-only filtering, falling back to full permission on older APIs. Thread isLoading/isSuccess through ContactPermissionBottomBar for visual feedback during sync. --- apps/flipcash/app/build.gradle.kts | 1 + .../core/src/main/res/values/strings.xml | 8 +- .../myaccount/internal/MyAccountMenuItems.kt | 4 +- .../app/contacts/ContactCoordinator.kt | 29 ++-- .../contacts/device/DeviceContactReader.kt | 6 + .../device/ScopeAwareContactReader.kt | 4 +- .../device/internal/PickerContactReader.kt | 65 ++------ .../flipcash/app/featureflags/FeatureFlag.kt | 3 +- .../app/permissions/ContactAccessHandle.kt | 147 ++++++++++++++++++ .../permissions/ContactPermissionScreen.kt | 2 +- .../ContactPermissionScreenContent.kt | 15 +- .../components/ContactPermissionBottomBar.kt | 74 +++++---- .../com/getcode/ui/components/TitleBar.kt | 144 ++++++++++------- 13 files changed, 339 insertions(+), 163 deletions(-) create mode 100644 apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactAccessHandle.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 8a6859bc5..3afcf1b8e 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -185,6 +185,7 @@ dependencies { implementation(project(":apps:flipcash:features:transactions")) implementation(project(":apps:flipcash:features:bill-customization")) implementation(project(":apps:flipcash:features:currency-creator")) + implementation(project(":apps:flipcash:features:direct-send")) implementation(project(":apps:flipcash:features:discovery")) implementation(project(":apps:flipcash:features:userflags")) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 02f489531..eb62ea2e3 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -275,12 +275,16 @@ Your %1$s wallet doesn\'t have enough USDC for this purchase. Please add more USDC and try again. Please try again - Verify Phone Number + Connect Phone Number Please enter your phone number to continue An SMS message was sent to your phone number with a verification code.\nPlease enter the verification code above Please enter your email to continue - Verify Your Email + Connect Phone To Send + Connect your phone number to send cash. + Connect Your Phone Number + + Connect Your Email Resend Enter The Code diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt index b105db6c2..a859e4956 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt @@ -25,7 +25,7 @@ internal data object VerifyEmail : FullMenuItem( override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Email) override val name: String - @Composable get() = stringResource(R.string.title_verifyEmailAddress) + @Composable get() = stringResource(R.string.title_connectEmailAddress) override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnVerifyEmailClicked } @@ -33,7 +33,7 @@ internal data object VerifyPhone : FullMenuItem( override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Phone) override val name: String - @Composable get() = stringResource(R.string.title_verifyPhoneNumber) + @Composable get() = stringResource(R.string.title_connectPhoneNumber) override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnVerifyPhoneClicked } diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index 370d4da8b..f864e1d44 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import com.flipcash.app.contacts.device.DeviceContact +import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.contacts.device.ScopeAwareContactReader import com.flipcash.app.contacts.sync.ContactChecksum import com.flipcash.app.persistence.FlipcashDatabase @@ -79,7 +80,7 @@ class ContactCoordinator @Inject constructor( trace(tag = TAG, message = "User logged in, hydrating contacts", type = TraceType.User) this.cluster.value = cluster hydrateFromPersistence() - sync() + launchSync() } // endregion @@ -95,7 +96,7 @@ class ContactCoordinator @Inject constructor( .filter { it.connected } .onEach { trace(tag = TAG, message = "Network connected, triggering contact sync", type = TraceType.Process) - sync() + launchSync() } .launchIn(scope) } @@ -103,7 +104,7 @@ class ContactCoordinator @Inject constructor( override fun onStart(owner: LifecycleOwner) { if (cluster.value != null) { trace(tag = TAG, message = "Lifecycle resumed, triggering contact sync", type = TraceType.Process) - sync() + launchSync() } } @@ -115,14 +116,16 @@ class ContactCoordinator @Inject constructor( // region Public API - fun sync() { + suspend fun sync(): Result = performSync() + + private fun launchSync() { syncJob?.cancel() syncJob = scope.launch { performSync() } } - fun addPickedContacts(contactIds: List) { - contactReader.addSelectedContacts(contactIds) - sync() + suspend fun addPickedContacts(contacts: List): Result { + contactReader.addSelectedContacts(contacts) + return performSync() } suspend fun resolve(e164: String): Result { @@ -165,8 +168,8 @@ class ContactCoordinator @Inject constructor( trace(tag = TAG, message = "Hydrated ${mappings.size} contacts from persistence", type = TraceType.Process) } - private suspend fun performSync() { - if (cluster.value == null) return + private suspend fun performSync(): Result { + if (cluster.value == null) return Result.failure(IllegalStateException("No active session")) _state.update { it.copy(syncState = SyncState.Syncing) } @@ -174,13 +177,13 @@ class ContactCoordinator @Inject constructor( // 1. Read device contacts val deviceContacts = contactReader.readAll().getOrElse { error -> trace(tag = TAG, message = "Cannot read contacts: ${error.message}", type = TraceType.Log) - return + return Result.failure(error) } if (deviceContacts.isEmpty()) { trace(tag = TAG, message = "No device contacts found", type = TraceType.Process) _state.update { it.copy(syncState = SyncState.Synced) } - return + return Result.success(Unit) } // 2. Compute checksum @@ -189,7 +192,7 @@ class ContactCoordinator @Inject constructor( // 3. Diff against persisted mappings val db = FlipcashDatabase.getInstance() ?: run { _state.update { it.copy(syncState = SyncState.Error) } - return + return Result.failure(IllegalStateException("Database unavailable")) } val dao = db.contactDao() val existingMappings = dao.getAllMappings() @@ -273,10 +276,12 @@ class ContactCoordinator @Inject constructor( _state.update { it.copy(syncState = SyncState.Synced) } trace(tag = TAG, message = "Contact sync complete", type = TraceType.Process) + return Result.success(Unit) } catch (e: Exception) { trace(tag = TAG, message = "Contact sync failed: ${e.message}", error = e, type = TraceType.Error) _state.update { it.copy(syncState = SyncState.Error) } + return Result.failure(e) } } diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt index 6e6ef6901..5de3cb158 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt @@ -10,3 +10,9 @@ data class DeviceContact( val displayName: String, val photoUri: String?, ) + +data class PickedContactData( + val phoneNumber: String, + val displayName: String, + val photoUri: String? = null, +) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt index 11c95585d..33b080fa6 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt @@ -16,8 +16,8 @@ class ScopeAwareContactReader @Inject constructor( override suspend fun readAll(): Result> = activeReader().readAll() - fun addSelectedContacts(contactIds: List) { - picker.addPickedContacts(contactIds) + fun addSelectedContacts(contacts: List) { + picker.addPickedContacts(contacts) } fun reset() { diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt index a2e46e0cf..4cb29fe38 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt @@ -1,11 +1,9 @@ package com.flipcash.app.contacts.device.internal -import android.content.Context -import android.provider.ContactsContract import com.flipcash.app.contacts.device.DeviceContact import com.flipcash.app.contacts.device.DeviceContactReader +import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.phone.PhoneUtils -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -13,63 +11,34 @@ import javax.inject.Singleton @Singleton class PickerContactReader @Inject constructor( - @param:ApplicationContext private val context: Context, private val phoneUtils: PhoneUtils, ) : DeviceContactReader { - private val pickedContactIds = MutableStateFlow>(emptySet()) + private val pickedContacts = MutableStateFlow>(emptyList()) - fun addPickedContacts(contactIds: List) { - pickedContactIds.update { it + contactIds } + fun addPickedContacts(contacts: List) { + pickedContacts.update { it + contacts } } fun clearPickedContacts() { - pickedContactIds.value = emptySet() + pickedContacts.value = emptyList() } override suspend fun readAll(): Result> { - val ids = pickedContactIds.value - if (ids.isEmpty()) return Result.success(emptyMap()) + val raw = pickedContacts.value + if (raw.isEmpty()) return Result.success(emptyMap()) val result = mutableMapOf() - val projection = arrayOf( - ContactsContract.CommonDataKinds.Phone.NUMBER, - ContactsContract.CommonDataKinds.Phone.CONTACT_ID, - ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, - ContactsContract.CommonDataKinds.Phone.PHOTO_URI, - ) - - val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} IN (${ids.joinToString(",")})" - - context.contentResolver.query( - ContactsContract.CommonDataKinds.Phone.CONTENT_URI, - projection, - selection, - null, - null, - )?.use { cursor -> - val numberIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) - val contactIdIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) - val nameIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) - val photoIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.PHOTO_URI) - - while (cursor.moveToNext()) { - val rawNumber = cursor.getString(numberIdx) ?: continue - val contactId = cursor.getLong(contactIdIdx) - val displayName = cursor.getString(nameIdx) ?: continue - val photoUri = cursor.getString(photoIdx) - - val e164 = normalizeToE164(rawNumber) ?: continue - - val existing = result[e164] - if (existing == null || (existing.photoUri == null && photoUri != null)) { - result[e164] = DeviceContact( - e164 = e164, - androidContactId = contactId, - displayName = displayName, - photoUri = photoUri, - ) - } + for (contact in raw) { + val e164 = normalizeToE164(contact.phoneNumber) ?: continue + val existing = result[e164] + if (existing == null || (existing.photoUri == null && contact.photoUri != null)) { + result[e164] = DeviceContact( + e164 = e164, + androidContactId = 0L, + displayName = contact.displayName, + photoUri = contact.photoUri, + ) } } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 64e3b444e..aa2a0c0e3 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -1,5 +1,6 @@ package com.flipcash.app.featureflags +import android.os.Build import com.flipcash.app.featureflags.model.BackgroundResetTimeout import com.flipcash.app.core.navigation.NavBarConfig import com.flipcash.app.ksp.annotations.FeatureFlagMarker @@ -175,7 +176,7 @@ sealed interface FeatureFlag { override val key: String = "contact_picker_mode" override val default: Boolean = false override val launched: Boolean = false - override val visible: Boolean = true + override val visible: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN override val persistLogOut: Boolean = true } diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactAccessHandle.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactAccessHandle.kt new file mode 100644 index 000000000..26d480120 --- /dev/null +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactAccessHandle.kt @@ -0,0 +1,147 @@ +package com.flipcash.app.permissions + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.ContactsContract +import android.provider.ContactsPickerSessionContract +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import com.getcode.util.permissions.PermissionHandle +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.rememberContactPermission +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class PickedContact( + val phoneNumber: String, + val displayName: String, + val photoUri: String? = null, +) + +sealed interface ContactAccessResult { + data object Granted : ContactAccessResult + data class Picked(val contacts: List) : ContactAccessResult + data object Denied : ContactAccessResult + data object PermanentlyDenied : ContactAccessResult + data object Canceled : ContactAccessResult +} + +@Stable +class ContactAccessHandle(val launch: () -> Unit) + +fun PermissionHandle.asContactAccessHandle() = ContactAccessHandle(launch = ::launch) + +@Composable +fun rememberContactAccessHandle( + isPickerMode: Boolean, + onResult: (ContactAccessResult) -> Unit, +): ContactAccessHandle { + val currentOnResult by rememberUpdatedState(onResult) + val currentIsPickerMode by rememberUpdatedState(isPickerMode) + val context = LocalContext.current + val scope = rememberCoroutineScope() + val supportsMultiPick = Build.VERSION.SDK_INT >= 37 + + val multiPickLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + val sessionUri = result.data?.data + if (sessionUri != null) { + scope.launch { + val contacts = readContactsFromSessionUri(sessionUri, context) + if (contacts.isNotEmpty()) { + currentOnResult(ContactAccessResult.Picked(contacts)) + } else { + currentOnResult(ContactAccessResult.Canceled) + } + } + } else { + currentOnResult(ContactAccessResult.Canceled) + } + } else { + currentOnResult(ContactAccessResult.Canceled) + } + } + + val permissionHandle = rememberContactPermission { result -> + val mapped = when (result) { + PermissionResult.Granted -> ContactAccessResult.Granted + PermissionResult.Denied -> ContactAccessResult.Denied + PermissionResult.PermanentlyDenied -> ContactAccessResult.PermanentlyDenied + PermissionResult.NotRequested -> null + } + if (mapped != null) currentOnResult(mapped) + } + + return remember { + ContactAccessHandle { + if (currentIsPickerMode && supportsMultiPick) { + multiPickLauncher.launch(createMultiPickIntent()) + } else { + permissionHandle.launch() + } + } + } +} + +@RequiresApi(37) +private fun createMultiPickIntent(): Intent = + Intent(ContactsPickerSessionContract.ACTION_PICK_CONTACTS).apply { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + putStringArrayListExtra( + ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_REQUESTED_DATA_FIELDS, + arrayListOf(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE), + ) + } + +private suspend fun readContactsFromSessionUri( + sessionUri: Uri, + context: Context, +): List = withContext(Dispatchers.IO) { + val contacts = mutableMapOf() + val projection = arrayOf( + ContactsContract.Contacts.LOOKUP_KEY, + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, + ContactsContract.Data.MIMETYPE, + ContactsContract.Data.DATA1, + ) + + context.contentResolver.query(sessionUri, projection, null, null, null)?.use { cursor -> + val lookupKeyIdx = cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY) + val nameIdx = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY) + val mimeTypeIdx = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE) + val data1Idx = cursor.getColumnIndex(ContactsContract.Data.DATA1) + + while (cursor.moveToNext()) { + val lookupKey = cursor.getString(lookupKeyIdx) ?: continue + val name = cursor.getString(nameIdx) ?: "" + val mimeType = cursor.getString(mimeTypeIdx) ?: continue + val data1 = cursor.getString(data1Idx) ?: continue + + if (mimeType == ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) { + if (lookupKey !in contacts) { + contacts[lookupKey] = PickedContact( + phoneNumber = data1, + displayName = name, + ) + } + } + } + } + + contacts.values.toList() +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactPermissionScreen.kt index 8f47c095b..7612252c3 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactPermissionScreen.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactPermissionScreen.kt @@ -59,7 +59,7 @@ fun ContactPermissionScreen(fromOnboarding: Boolean) { // Only reached when status is NotRequested ContactScreenContent( - permissionState = permissionState, + accessHandle = permissionState.asContactAccessHandle(), onSkip = { analytics.action(Button.SkipContacts) navigator.push( diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt index b837df9dd..557e1b0aa 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.app.permissions.ContactAccessHandle +import com.flipcash.app.permissions.asContactAccessHandle import com.flipcash.app.permissions.internal.contacts.components.AnimatedContactListPreview import com.flipcash.app.permissions.internal.contacts.components.ContactPermissionBottomBar import com.flipcash.app.theme.FlipcashPreview @@ -21,20 +23,23 @@ import com.flipcash.shared.permissions.R import com.getcode.libs.analytics.LocalAnalytics import com.getcode.theme.CodeTheme import com.getcode.ui.theme.CodeScaffold -import com.getcode.util.permissions.PermissionHandle import com.getcode.util.permissions.ProvideTestPermissions import com.getcode.util.permissions.rememberContactPermission @Composable fun ContactScreenContent( - permissionState: PermissionHandle, - onSkip: () -> Unit, + accessHandle: ContactAccessHandle, + onSkip: (() -> Unit)? = null, + isLoading: Boolean = false, + isSuccess: Boolean = false, ) { CodeScaffold( bottomBar = { ContactPermissionBottomBar( - permission = permissionState, + accessHandle = accessHandle, onSkip = onSkip, + isLoading = isLoading, + isSuccess = isSuccess, ) } ) { @@ -71,7 +76,7 @@ private fun PreviewContactPermissionScreen() { CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { ProvideTestPermissions(granted = emptySet()) { val state = rememberContactPermission() - ContactScreenContent(state) { } + ContactScreenContent(state.asContactAccessHandle(), onSkip = { }) } } } diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/ContactPermissionBottomBar.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/ContactPermissionBottomBar.kt index c20d4b642..c4bab2373 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/ContactPermissionBottomBar.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/ContactPermissionBottomBar.kt @@ -11,18 +11,21 @@ import androidx.compose.ui.res.stringResource import com.flipcash.shared.permissions.R import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager +import com.flipcash.app.permissions.ContactAccessHandle import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton -import com.getcode.util.permissions.PermissionHandle import com.getcode.util.resources.LocalResources @Composable internal fun ContactPermissionBottomBar( - permission: PermissionHandle, - onSkip: () -> Unit, + accessHandle: ContactAccessHandle, + onSkip: (() -> Unit)? = null, + isLoading: Boolean = false, + isSuccess: Boolean = false, ) { val resources = LocalResources.current + val canSkip = onSkip != null Column( modifier = Modifier.fillMaxWidth() .navigationBarsPadding() @@ -30,40 +33,49 @@ internal fun ContactPermissionBottomBar( horizontalAlignment = Alignment.CenterHorizontally ) { CodeButton( - onClick = { permission.launch() }, - text = stringResource(R.string.action_giveAccessToContacts), + onClick = { accessHandle.launch() }, + text = if (canSkip) { + stringResource(R.string.action_giveAccessToContacts) + } else { + stringResource(R.string.action_next) + }, + isLoading = isLoading, + isSuccess = isSuccess, + enabled = !isLoading && !isSuccess, buttonState = ButtonState.Filled, modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.inset), ) - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.grid.x2) - .padding(horizontal = CodeTheme.dimens.inset), - onClick = { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_ignoredContactPermissions), - message = resources.getString(R.string.error_description_ignoredContactPermissions), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_okAllow) - ) { - permission.launch() - }, - BottomBarAction( - text = resources.getString(R.string.action_imSure), - style = BottomBarManager.BottomBarButtonStyle.Text - ) { - onSkip() - } + if (canSkip) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x2) + .padding(horizontal = CodeTheme.dimens.inset), + onClick = { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_ignoredContactPermissions), + message = resources.getString(R.string.error_description_ignoredContactPermissions), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_okAllow) + ) { + accessHandle.launch() + }, + BottomBarAction( + text = resources.getString(R.string.action_imSure), + style = BottomBarManager.BottomBarButtonStyle.Text + ) { + onSkip() + } + ) ) - ) - }, - text = stringResource(R.string.action_notNow), - buttonState = ButtonState.Subtle, - ) + }, + text = stringResource(R.string.action_notNow), + buttonState = ButtonState.Subtle, + ) + } } } \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt index 36434a523..f9a7cdc10 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt @@ -1,5 +1,6 @@ package com.getcode.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues @@ -7,8 +8,10 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsIgnoringVisibility import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -24,6 +27,7 @@ import androidx.compose.material.icons.rounded.RestorePage import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.testTag @@ -37,6 +41,7 @@ import com.getcode.navigation.flow.LocalFlowDismissStyle import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem import com.getcode.ui.core.addIf +import com.getcode.ui.core.rememberedClickable import com.getcode.ui.core.unboundedClickable import com.getcode.ui.utils.calculateHorizontalPadding import kotlin.math.max @@ -45,67 +50,68 @@ object AppBarDefaults { val ContentPadding: PaddingValues @Composable get() = PaddingValues(horizontal = CodeTheme.dimens.grid.x2) + private val IconSize = 20.dp + private val ButtonSize = 40.dp + private val ButtonBackground = Color.White.copy(alpha = 0.1f) + @Composable fun UpNavigation(modifier: Modifier = Modifier, onClick: () -> Unit) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "", - tint = Color.White, - modifier = modifier - .requiredSize(24.dp) - .unboundedClickable { onClick() } - .testTag("action_back") - ) + CircularIconButton(modifier = modifier, onClick = onClick, testTag = "action_back") { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(IconSize), + ) + } } @Composable fun Close(modifier: Modifier = Modifier, onClick: () -> Unit) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = "", - tint = Color.White, - modifier = modifier - .requiredSize(24.dp) - .unboundedClickable { onClick() } - .testTag("action_close") - ) + CircularIconButton(modifier = modifier, onClick = onClick, testTag = "action_close") { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(IconSize), + ) + } } @Composable fun Share(modifier: Modifier = Modifier, onClick: () -> Unit) { - Icon( - painter = painterResource(R.drawable.ic_remote_send), - contentDescription = "", - tint = Color.White, - modifier = modifier - .requiredSize(24.dp) - .unboundedClickable { onClick() } - .testTag("action_share") - ) + CircularIconButton(modifier = modifier, onClick = onClick, testTag = "action_share") { + Icon( + painter = painterResource(R.drawable.ic_remote_send), + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(IconSize), + ) + } } @Composable fun Leave(modifier: Modifier = Modifier, onClick: () -> Unit) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.Logout, - contentDescription = "", - tint = Color.White, - modifier = modifier - .requiredSize(24.dp) - .unboundedClickable { onClick() } - ) + CircularIconButton(modifier = modifier, onClick = onClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Logout, + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(IconSize), + ) + } } @Composable fun Settings(modifier: Modifier = Modifier, onClick: () -> Unit) { - Icon( - painter = painterResource(R.drawable.ic_settings_outline), - contentDescription = "", - tint = Color.White, - modifier = modifier - .requiredSize(24.dp) - .unboundedClickable { onClick() } - ) + CircularIconButton(modifier = modifier, onClick = onClick) { + Icon( + painter = painterResource(R.drawable.ic_settings_outline), + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(IconSize), + ) + } } @Composable @@ -113,14 +119,14 @@ object AppBarDefaults { modifier: Modifier = Modifier, onClick: () -> Unit ) { - Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = "", - tint = Color.White, - modifier = modifier - .requiredSize(24.dp) - .unboundedClickable { onClick() } - ) + CircularIconButton(modifier = modifier, onClick = onClick) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(IconSize), + ) + } } @Composable @@ -128,14 +134,14 @@ object AppBarDefaults { modifier: Modifier = Modifier, onClick: () -> Unit ) { - Icon( - imageVector = Icons.Rounded.RestorePage, - contentDescription = "", - tint = Color.White, - modifier = modifier - .requiredSize(24.dp) - .unboundedClickable { onClick() } - ) + CircularIconButton(modifier = modifier, onClick = onClick) { + Icon( + imageVector = Icons.Rounded.RestorePage, + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(IconSize), + ) + } } @Composable @@ -153,6 +159,26 @@ object AppBarDefaults { overflow = TextOverflow.Ellipsis ) } + + @Composable + private fun CircularIconButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + testTag: String? = null, + content: @Composable () -> Unit, + ) { + Box( + modifier = modifier + .size(ButtonSize) + .background(ButtonBackground, CircleShape) + .clip(CircleShape) + .rememberedClickable { onClick() } + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier), + contentAlignment = Alignment.Center, + ) { + content() + } + } } @Composable From 440e92c4d9a4e1596598538197a4aa255c65d77d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 12:29:31 -0400 Subject: [PATCH 6/8] fix(onboarding): preserve seenAccessKey flag across restart so onboarding resumes at access key onAccountPurchased was removing seenAccessKeyKey, and login always set LoggedInWithUser when flags.isRegistered was true, skipping the access key screen on restart. Now onAccountPurchased preserves the flag value, hasSeenAccessKey falls back to selectedAccountIdKey, login gates LoggedInWithUser on seenAccessKey, and RealSessionController respects incomplete onboarding state. --- .../com/flipcash/app/auth/AuthManager.kt | 9 ++++++-- .../PassphraseCredentialManager.kt | 23 +++++++++++++++---- .../com/flipcash/app/auth/AuthManagerTest.kt | 1 + .../session/internal/RealSessionController.kt | 4 +++- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index 69691246b..7ce6d815d 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -172,12 +172,17 @@ class AuthManager @Inject constructor( accountController.getUserFlags().getOrNull() } + val seenAccessKey = credentialManager.hasSeenAccessKey() if (flags != null) { userManager.set(flags) - userManager.set(if (flags.isRegistered) AuthState.LoggedInWithUser else AuthState.Registered()) + if (flags.isRegistered && seenAccessKey) { + userManager.set(AuthState.LoggedInWithUser) + } else { + userManager.set(AuthState.Registered(seenAccessKey)) + } } else { taggedTrace("Failed to get user flags after retries", type = TraceType.Error) - userManager.set(authState = AuthState.Registered()) + userManager.set(authState = AuthState.Registered(seenAccessKey)) } profileController.updateUserProfile() diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt index 77d2bd836..4310c1e4d 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/credentials/PassphraseCredentialManager.kt @@ -108,7 +108,8 @@ class PassphraseCredentialManager @Inject constructor( suspend fun onUserAccessKeySeen(): Result { storage.edit { prefs -> - prefs[temporaryUserIdKey]?.let { userId -> + val userId = prefs[temporaryUserIdKey] ?: prefs[selectedAccountIdKey] + if (userId != null) { prefs[seenAccessKeyKey(userId)] = true } } @@ -116,6 +117,18 @@ class PassphraseCredentialManager @Inject constructor( return Result.success(Unit) } + suspend fun hasSeenAccessKey(): Boolean { + val tempUserId = storage.data.map { it[temporaryUserIdKey] }.firstOrNull() + if (tempUserId != null) { + return storage.data.map { it[seenAccessKeyKey(tempUserId)] }.firstOrNull() ?: false + } + // Temporary keys cleared by onAccountPurchased — check selected account. + // Default to true so existing production users (who never had this flag) aren't affected. + val selectedId = storage.data.map { it[selectedAccountIdKey] }.firstOrNull() + ?: return true + return storage.data.map { it[seenAccessKeyKey(selectedId)] }.firstOrNull() ?: true + } + suspend fun presentSaveOption(): Result { val tempUserId = storage.data.map { it[temporaryUserIdKey] }.firstOrNull() val entropy = storage.data.map { it[temporaryEntropyKey] }.firstOrNull() @@ -150,11 +163,13 @@ class PassphraseCredentialManager @Inject constructor( return Result.failure(Throwable("No user id found")) } - // remove temporary states + // remove temporary states; persist seenAccessKey as false for the + // selected account so a restart before the access-key screen resumes there + val seenAccessKey = storage.data.map { it[seenAccessKeyKey(accountId.base58)] }.firstOrNull() ?: false storage.edit { it.remove(temporaryEntropyKey) it.remove(temporaryUserIdKey) - it.remove(seenAccessKeyKey(accountId.base58)) + it[seenAccessKeyKey(accountId.base58)] = seenAccessKey } // Store metadata @@ -202,7 +217,7 @@ class PassphraseCredentialManager @Inject constructor( val selectedMetadata = getSelectedMetadata() if (selectedMetadata != null && selectedMetadata.entropy == entropy) { storeMetadata(selectedMetadata, isSelected = true) - updateUserManager(selectedMetadata.id, AuthState.LoggedInWithUser) + userManager.set(selectedMetadata.id) return Result.success(selectedMetadata) } diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt index c54a94ec0..9ef23e56c 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt @@ -68,6 +68,7 @@ class AuthManagerTest { coEvery { credentialManager.onAccountPurchased() } returns Result.success(mockk(relaxed = true)) coEvery { pushController.addToken(any()) } returns Result.success(Unit) coEvery { pushController.deleteTokens() } returns Result.success(Unit) + coEvery { credentialManager.hasSeenAccessKey() } returns true authManager = AuthManager( credentialManager = credentialManager, diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 6592f49ea..a0ce8ca12 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -302,7 +302,9 @@ class RealSessionController @Inject constructor( accountController.getUserFlags() .onSuccess { flags -> userManager.set(flags) - if (flags.isRegistered && !userManager.authState.canAccessAuthenticatedApis) { + val currentState = userManager.authState + val onboardingIncomplete = currentState is AuthState.Registered && !currentState.seenAccessKey + if (flags.isRegistered && !currentState.canAccessAuthenticatedApis && !onboardingIncomplete) { userManager.set(authState = AuthState.LoggedInWithUser) } } From b3cddfc5f5397a903629531a971040c9fbaf3b47 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 12:29:45 -0400 Subject: [PATCH 7/8] feat(onboarding): move phone verification before access key and remove for existing users When PhoneNumberSend is enabled, new account creation now launches verification with target=OnboardingFlow(AccessKey) so the user verifies their phone before seeing the access key. Existing users (PostAccessKey) skip verification entirely and go straight to permissions. Verification gating logic moved from composable into LoginViewModel. --- .../verification/VerificationFlowScreen.kt | 2 +- .../email/EmailMagicLinkScreen.kt | 2 +- .../email/EmailVerificationScreen.kt | 2 +- .../internal/phone/PhoneEntryScreen.kt | 5 +- .../phone/PhoneVerificationScreen.kt | 2 +- .../app/login/OnboardingFlowScreen.kt | 57 ++++++++++++------- .../app/login/router/LoginViewModel.kt | 23 ++++++++ .../login/router/LoginViewModelErrorTest.kt | 6 ++ .../ContactVerificationController.kt | 9 ++- 9 files changed, 78 insertions(+), 30 deletions(-) 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 836b1b355..3ffd8fb11 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 @@ -47,7 +47,7 @@ fun VerificationFlowScreen( value = NavResultOrCanceled.ReturnValue(result), ) if (route.target != null && result is VerificationResult.Success) { - outerNavigator.replace(route.target!!) + outerNavigator.replaceAll(route.target!!) } else { outerNavigator.pop() } 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 e585819a7..4f3a13957 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 @@ -47,7 +47,7 @@ fun EmailMagicLinkContent( horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithTitle( - title = stringResource(R.string.title_verifyEmailAddress), + title = stringResource(R.string.title_connectEmailAddress), isInModal = true, titleAlignment = Alignment.CenterHorizontally, backButton = true, 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 830808320..ed7b2b955 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 @@ -43,7 +43,7 @@ fun EmailVerificationContent( horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithTitle( - title = stringResource(R.string.title_verifyEmailAddress), + title = stringResource(R.string.title_connectEmailAddress), isInModal = true, titleAlignment = Alignment.CenterHorizontally, backButton = true, diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneEntryScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneEntryScreen.kt index bd45ebf65..1970b14ed 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneEntryScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneEntryScreen.kt @@ -11,17 +11,14 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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 735540ff7..23754fcbc 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 @@ -36,7 +36,7 @@ fun PhoneVerificationContent(isInModal: Boolean = true) { horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithTitle( - title = stringResource(R.string.title_verifyPhoneNumber), + title = stringResource(R.string.title_connectPhoneNumber), isInModal = isInModal, titleAlignment = Alignment.CenterHorizontally, backButton = true, diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index dff407b2b..d679293a2 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -39,6 +39,7 @@ import com.flipcash.app.login.internal.screens.LoginRouterScreenContent import com.flipcash.app.login.internal.screens.SeedInputContent import com.flipcash.app.login.router.LoginViewModel import com.flipcash.app.login.internal.SeedInputViewModel +import com.flipcash.app.permissions.asContactAccessHandle import com.flipcash.app.permissions.internal.contacts.ContactScreenContent import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent @@ -72,8 +73,8 @@ import kotlinx.coroutines.flow.onEach * ``` * 1. New account (ResumePoint.Login → ProceedToVerification) * - * Start → AccessKey ──┬────────────→ Verification² → Contacts¹ → Notifications → Scanner - * └→ Purchase ─┘ + * Start → Verification³ → AccessKey ──┬──→ Contacts¹ → Notifications → Scanner + * └→ Purchase ─┘ * * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput) * @@ -82,9 +83,8 @@ import kotlinx.coroutines.flow.onEach * * 3. App resume (ResumePoint.PostAccessKey) * - * ┬─ phone linked ──→ Notifications → Scanner - * └─ not linked ────→ Verification → Notifications → Scanner - * (contacts always skipped — existing users encounter contacts in-app) + * → Notifications → Scanner + * (contacts and verification skipped — existing users encounter these in-app) * * 4. Mid-flow resume (ResumePoint.AccessKey / AccessKeyThenPurchase) * @@ -96,6 +96,8 @@ import kotlinx.coroutines.flow.onEach * contacts are accessed via the system picker at call site (no READ_CONTACTS needed). * Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost]. * ² Verification is skipped if a phone number is already linked. + * ³ Phone verification is shown only when [FeatureFlag.PhoneNumberSend] is enabled + * and no phone is linked. Uses `target` to replace nav stack with AccessKey on success. */ @Composable fun OnboardingFlowScreen( @@ -115,17 +117,14 @@ fun OnboardingFlowScreen( @Composable private fun CompleteExistingUserOnboarding() { val navigator = LocalCodeNavigator.current - val userManager = LocalUserManager.current!! - val userState by userManager.state.collectAsStateWithLifecycle() - val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null LaunchedEffect(Unit) { - val route = resolvePostAccountRoute( - result = OnboardingResult.ProceedToVerification, - hasLinkedPhone = hasLinkedPhone, - skipContacts = true, + navigator.replace( + AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + skipContacts = true, + ) ) - route?.let { navigator.replace(it) } } } @@ -306,7 +305,23 @@ private fun LoginStepContent(seed: String?) { LaunchedEffect(vm) { vm.eventFlow .filterIsInstance() - .onEach { flowNavigator.navigateTo(OnboardingStep.AccessKey) } + .onEach { + if (state.needsPhoneVerification) { + flowNavigator.navigate( + AppRoute.Verification( + origin = AppRoute.OnboardingFlow(), + includePhone = true, + includeEmail = false, + target = AppRoute.OnboardingFlow( + resumeAt = AppRoute.OnboardingFlow.ResumePoint.AccessKey, + ), + fullScreen = true, + ) + ) + } else { + flowNavigator.navigateTo(OnboardingStep.AccessKey) + } + } .launchIn(this) } @@ -402,8 +417,6 @@ private fun AccessKeyStepContent() { AppBarWithTitle( title = stringResource(R.string.title_accessKey), titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { flowNavigator.back() }, ) AccessKeyScreen( viewModel = viewModel, @@ -415,6 +428,8 @@ private fun AccessKeyStepContent() { flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification) } } + + BackHandler { /* swallow back during onboarding permissions */ } } } @@ -432,12 +447,12 @@ private fun PurchaseStepContent() { } Column { - AppBarWithTitle( - backButton = true, - onBackIconClicked = { flowNavigator.back() }, - ) + AppBarWithTitle() PurchaseAccountScreenContent(state, viewModel::dispatchEvent) } + + + BackHandler { /* swallow back during onboarding permissions */ } } @Composable @@ -458,7 +473,7 @@ private fun ContactPermissionStepContent() { } ContactScreenContent( - permissionState = permissionState, + accessHandle = permissionState.asContactAccessHandle(), onSkip = { analytics.action(Button.SkipContacts) flowNavigator.proceed() diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt index edbf50a60..dd1a9069e 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt @@ -4,8 +4,11 @@ import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.features.login.R import com.flipcash.services.controllers.AccountController +import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.utils.encodeBase64 @@ -13,6 +16,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNot @@ -29,6 +33,8 @@ class LoginViewModel @Inject constructor( private val accounts: AccountController, private val resources: ResourceHelper, private val analytics: FlipcashAnalyticsService, + userManager: UserManager, + featureFlags: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -40,6 +46,7 @@ class LoginViewModel @Inject constructor( val loggingIn: LoadingSuccessState = LoadingSuccessState(), val logoTapCount: Int = 0, val betaOptionsVisible: Boolean = false, + val needsPhoneVerification: Boolean = false, ) sealed interface Event { @@ -53,9 +60,21 @@ class LoginViewModel @Inject constructor( data object LogInFailed : Event data object OnAccountCreated : Event data object CreateFailed : Event + data class PhoneVerificationUpdated(val needed: Boolean) : Event } init { + combine( + userManager.state, + featureFlags.observe(FeatureFlag.PhoneNumberSend), + ) { userState, phoneNumberSendFlag -> + val enabled = phoneNumberSendFlag || userState.flags?.enablePhoneNumberSend == true + val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null + enabled && !hasLinkedPhone + }.onEach { needed -> + dispatchEvent(Event.PhoneVerificationUpdated(needed)) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .map { stateFlow.value.logoTapCount } @@ -188,6 +207,10 @@ class LoginViewModel @Inject constructor( ) ) } + + is Event.PhoneVerificationUpdated -> { state -> + state.copy(needsPhoneVerification = event.needed) + } } } } diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt index 79d826f62..412f40607 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt @@ -3,10 +3,12 @@ package com.flipcash.app.login.router import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.features.login.R import com.flipcash.services.controllers.AccountController +import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import io.mockk.every @@ -41,6 +43,8 @@ class LoginViewModelErrorTest { // MockK for everything else private val resources: ResourceHelper = mockk(relaxed = true) private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) + private val userManager: UserManager = mockk(relaxed = true) + private val featureFlags: FeatureFlagController = mockk(relaxed = true) private lateinit var dispatchers: TestDispatchers @@ -68,6 +72,8 @@ class LoginViewModelErrorTest { accounts = accounts, resources = resources, analytics = analytics, + userManager = userManager, + featureFlags = featureFlags, dispatchers = dispatchers, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt index 274fe74f5..266e63588 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt @@ -27,6 +27,13 @@ class ContactVerificationController @Inject constructor( val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) - return repository.unlink(method, owner) + return repository.unlink(method, owner).onSuccess { + val profile = userManager.profile ?: return@onSuccess + val updated = when (method) { + is ContactMethod.Phone -> profile.copy(verifiedPhoneNumber = null) + is ContactMethod.Email -> profile.copy(verifiedEmailAddress = null) + } + userManager.set(updated) + } } } \ No newline at end of file From 98f7e7b9c94cae0882ab702a4a7a01fef5772183 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 12:35:09 -0400 Subject: [PATCH 8/8] chore: add onboarding, session, and phone area labels for PR labeler Signed-off-by: Brandon McAnsh --- .github/labeler.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 12b39db78..fcd43831d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -25,13 +25,27 @@ "area: auth": - changed-files: - any-glob-to-any-file: - - "apps/flipcash/features/login/**" - "apps/flipcash/shared/authentication/**" - - "apps/flipcash/shared/accesskey/**" + +"area: session": + - changed-files: + - any-glob-to-any-file: - "apps/flipcash/shared/session/**" - - "apps/flipcash/features/contact-verification/**" + +"area: phone": + - changed-files: + - any-glob-to-any-file: - "apps/flipcash/shared/phone/**" +"area: onboarding": + - changed-files: + - any-glob-to-any-file: + - "apps/flipcash/features/login/**" + - "apps/flipcash/shared/accesskey/**" + - "apps/flipcash/features/contact-verification/**" + - "apps/flipcash/shared/permissions/**" + - "apps/flipcash/core/**/onboarding/**" + "area: tokens": - changed-files: - any-glob-to-any-file: