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: 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/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..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 @@ -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,31 @@ 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, + val skipContacts: Boolean = false, + ) : 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/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/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/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..d679293a2 --- /dev/null +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -0,0 +1,535 @@ +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.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 +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 unified onboarding flow. + * + * ``` + * 1. New account (ResumePoint.Login → ProceedToVerification) + * + * Start → Verification³ → AccessKey ──┬──→ Contacts¹ → Notifications → Scanner + * └→ Purchase ─┘ + * + * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput) + * + * Start → SeedInput ──┬──────────────────────→ Contacts¹ → Notifications → Scanner + * └→ Purchase → Verification² ─┘ + * + * 3. App resume (ResumePoint.PostAccessKey) + * + * → Notifications → Scanner + * (contacts and verification skipped — existing users encounter these 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. + * ³ 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( + route: AppRoute.OnboardingFlow, + resultStateRegistry: NavResultStateRegistry, +) { + when { + route.phase == AppRoute.OnboardingFlow.Phase.Permissions -> + PermissionsPhaseFlowHost(route, resultStateRegistry) + route.resumeAt == AppRoute.OnboardingFlow.ResumePoint.PostAccessKey -> + CompleteExistingUserOnboarding() + else -> + AccountPhaseFlowHost(route, resultStateRegistry) + } +} + +@Composable +private fun CompleteExistingUserOnboarding() { + val navigator = LocalCodeNavigator.current + + LaunchedEffect(Unit) { + navigator.replace( + AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + skipContacts = 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 contactPickerMode by featureFlags.observe(FeatureFlag.ContactPickerMode).collectAsStateWithLifecycle() + + val permissionsSteps = buildList { + if (!route.skipContacts && 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 }, contactPickerMode) { + val contactsGranted = checker.isGranted(contactConfig.permission) + val notificationsGranted = !notificationConfig.requiresRuntimeRequest || + checker.isGranted(notificationConfig.permission) + when { + !route.skipContacts && 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 -> { + val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null + val route = resolvePostAccountRoute(reason.result, hasLinkedPhone) + route?.let { outerNavigator.replace(it) } + } + + FlowExitReason.BackedOutOfRoot -> Unit + FlowExitReason.Canceled -> Unit + } + }, + entryProvider = onboardingEntryProvider(route), + ) +} + +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 { + 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 { + 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) + } + + 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, + ) + AccessKeyScreen( + viewModel = viewModel, + onExit = { flowNavigator.replaceStack(listOf(OnboardingStep.Start())) }, + ) { requiresIap -> + if (requiresIap) { + flowNavigator.navigateTo(OnboardingStep.Purchase) + } else { + flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification) + } + } + + BackHandler { /* swallow back during onboarding permissions */ } + } +} + +@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.ProceedToVerification) } + .launchIn(this) + } + + Column { + AppBarWithTitle() + PurchaseAccountScreenContent(state, viewModel::dispatchEvent) + } + + + BackHandler { /* swallow back during onboarding permissions */ } +} + +@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( + accessHandle = permissionState.asContactAccessHandle(), + 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/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/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/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)) + } +} 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/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/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/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/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..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 @@ -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, @@ -170,13 +172,20 @@ 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() } launch { savePrefs() } } 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 6f0940016..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 @@ -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) @@ -66,12 +68,14 @@ 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, userManager = userManager, notificationManager = notificationManager, accountController = accountController, + profileController = profileController, pushController = pushController, pushTokenProvider = pushTokenProvider, tokenCoordinator = tokenCoordinator, 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/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..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 -internal fun ContactScreenContent( - permissionState: PermissionHandle, - onSkip: () -> Unit, +fun ContactScreenContent( + 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/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/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) } } 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/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 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 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) {} }