From a5b64eda9c69418a3cbb8bec70c340a44ad0f7a8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 5 Aug 2026 14:30:38 -0400 Subject: [PATCH 1/2] feat(onboarding): collect display name in place of phone number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the phone-verification step in onboarding with display-name entry, reusing the existing UpdateUserProfile subflow. Onboarding is now ordered: create account → access key → name → permissions (push). - Drop phone verification from onboarding. The contact-verification module stays intact (still used for Coinbase onramp / payment linking). - Route the access-key (non-IAP) and purchase exits through the UpdateUserProfile subflow when no display name is set; its `target` hands off to the permissions phase on success. Full-screen rendering is inherent (the route is not a Sheet), matching the old phone step. - Rename AuthState.ResumePoint.PhoneNumber -> DisplayName; on app restart the name step resumes after the access key. - Remove the now-dead needsDisplayName plumbing from LoginViewModel. - Update the maestro onboarding subflow (name after access key) and the BuildNavGraphForLaunch / login VM tests. --- .../app/internal/ui/navigation/MainRoot.kt | 18 +-- .../navigation/BuildNavGraphForLaunchTest.kt | 12 +- .../app/login/OnboardingFlowScreen.kt | 72 +++++++----- .../app/login/router/LoginViewModel.kt | 14 --- .../router/LoginViewModelCreateAccountTest.kt | 3 - .../login/router/LoginViewModelErrorTest.kt | 3 - .../LoginViewModelPhoneVerificationTest.kt | 108 ------------------ .../com/flipcash/app/auth/AuthManager.kt | 17 ++- .../session/internal/RealSessionController.kt | 4 +- maestro/subflows/create_account.yaml | 17 ++- maestro/tipping_setup.yaml | 13 +-- .../com/flipcash/services/user/UserManager.kt | 4 +- 12 files changed, 86 insertions(+), 199 deletions(-) delete mode 100644 apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelPhoneVerificationTest.kt 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 f3b6fea9c..8059f8e24 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 @@ -187,16 +187,18 @@ internal fun buildNavGraphForLaunch( ): LaunchNavGraph? { return when (state) { is AuthState.Onboarding -> when (state.resumePoint) { - // Resume directly into phone verification; on success it replaces the - // stack with the access-key resume (see VerificationFlowScreen target). - AuthState.ResumePoint.PhoneNumber -> LaunchNavGraph( + // Access key already seen; resume into display-name entry. On success it replaces + // the stack with the permissions phase (see UpdateUserProfileFlowScreen target). + AuthState.ResumePoint.DisplayName -> LaunchNavGraph( listOf( - AppRoute.Verification( + AppRoute.UpdateUserProfile( origin = AppRoute.OnboardingFlow(), - includePhone = true, - includeEmail = false, - target = AppRoute.OnboardingFlow(resumeAt = AppRoute.OnboardingFlow.ResumePoint.AccessKey), - fullScreen = true, + includeName = true, + includePhoto = false, + target = AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + skipContacts = true, + ), ) ) ) diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index c8e406de4..fac2f9d98 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -151,13 +151,13 @@ class BuildNavGraphForLaunchTest { } @Test - fun `onboarding at PhoneNumber resume point routes to phone verification then access key`() { - val result = build(AuthState.Onboarding(AuthState.ResumePoint.PhoneNumber))!! - val route = assertIs(result.baseRoutes.single()) - assertTrue(route.includePhone) - assertEquals(false, route.includeEmail) + fun `onboarding at DisplayName resume point routes to display name entry then permissions`() { + val result = build(AuthState.Onboarding(AuthState.ResumePoint.DisplayName))!! + val route = assertIs(result.baseRoutes.single()) + assertTrue(route.includeName) + assertEquals(false, route.includePhoto) val target = assertIs(route.target) - assertEquals(AppRoute.OnboardingFlow.ResumePoint.AccessKey, target.resumeAt) + assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) } // -- Authenticating -- diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 4d53e9d62..40b3c7745 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -48,6 +48,7 @@ import com.flipcash.app.purchase.internal.PurchaseAccountScreenContent import com.flipcash.app.purchase.internal.PurchaseAccountViewModel import com.flipcash.features.login.R import com.flipcash.services.user.AuthState +import com.flipcash.services.user.UserManager import com.getcode.libs.analytics.LocalAnalytics import com.getcode.utils.TraceType import com.getcode.utils.trace @@ -56,6 +57,7 @@ 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.FlowNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.results.NavResultStateRegistry @@ -78,30 +80,30 @@ import kotlin.time.Duration.Companion.milliseconds * ``` * 1. New account (ResumePoint.Login → ProceedToVerification) * - * Start → Verification² → AccessKey ──┬──────────────→ Contacts¹ → Notifications → Scanner - * └→ Purchase ─┘ + * Start → AccessKey ──┬────────────→ Name² → Contacts¹ → Notifications → Scanner + * └→ Purchase ─┘ * * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput) * - * Start → SeedInput ──┬──────────────→ Contacts¹ → Notifications → Scanner + * Start → SeedInput ──┬────────────→ Name² → Contacts¹ → Notifications → Scanner * └→ Purchase ─┘ * * 3. App resume (ResumePoint.PostAccessKey) * * → Notifications → Scanner - * (contacts and verification skipped — existing users encounter these in-app) + * (contacts and name entry skipped — existing users encounter these in-app) * - * 4. Mid-flow resume (ResumePoint.AccessKey / AccessKeyThenPurchase) + * 4. Mid-flow resume (ResumePoint.AccessKey / AccessKeyThenPurchase / DisplayName) * - * Same as (1) but initialStack resumes at the AccessKey or Purchase step. + * Same as (1) but initialStack resumes at the AccessKey, Purchase, or Name step. * ``` * * ¹ Contact permission is shown only when [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]. - * ² Phone verification is shown only when no phone is linked. - * Uses `target` to replace the nav stack with AccessKey on success. + * ² Display-name entry is shown only when no display name is set. It reuses the + * UpdateUserProfile subflow, whose `target` replaces the stack with the permissions phase. */ @Composable fun OnboardingFlowScreen( @@ -244,6 +246,35 @@ internal fun resolvePostAccountRoute( } } +/** + * Called once the access key (and optional purchase) is done. When the account has no display + * name yet, collect one via the reusable [AppRoute.UpdateUserProfile] subflow, then hand off to + * the permissions phase (the subflow's `target` replaces the stack on success). Otherwise proceed + * straight to permissions. This keeps onboarding ordered as access key → name → permissions. + */ +private fun FlowNavigator.proceedToNameOrPermissions( + userManager: UserManager?, +) { + val needsDisplayName = userManager?.profile?.displayName.isNullOrEmpty() + if (needsDisplayName) { + trace(tag = "Onboarding", message = "Access key done — collecting display name", type = TraceType.Process) + navigate( + AppRoute.UpdateUserProfile( + origin = AppRoute.OnboardingFlow(), + includeName = true, + includePhoto = false, + target = AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + skipContacts = true, + ), + ) + ) + } else { + trace(tag = "Onboarding", message = "Access key done — proceeding to permissions", type = TraceType.Process) + exitWithResult(OnboardingResult.ProceedToVerification) + } +} + private fun onboardingEntryProvider( route: AppRoute.OnboardingFlow, ): (NavKey) -> NavEntry = entryProvider { @@ -295,23 +326,8 @@ private fun LoginStepContent(seed: String?) { vm.eventFlow .filterIsInstance() .onEach { - if (state.needsPhoneVerification) { - trace(tag = "Onboarding", message = "Account created — navigating to phone verification", type = TraceType.Process) - flowNavigator.navigate( - AppRoute.Verification( - origin = AppRoute.OnboardingFlow(), - includePhone = true, - includeEmail = false, - target = AppRoute.OnboardingFlow( - resumeAt = AppRoute.OnboardingFlow.ResumePoint.AccessKey, - ), - fullScreen = true, - ) - ) - } else { - trace(tag = "Onboarding", message = "Account created — navigating to access key", type = TraceType.Process) - flowNavigator.navigateTo(OnboardingStep.AccessKey) - } + trace(tag = "Onboarding", message = "Account created — navigating to access key", type = TraceType.Process) + flowNavigator.navigateTo(OnboardingStep.AccessKey) } .launchIn(this) } @@ -401,6 +417,7 @@ private fun SeedInputStepContent() { private fun AccessKeyStepContent() { val viewModel = hiltViewModel() val flowNavigator = rememberFlowNavigator() + val userManager = LocalUserManager.current Column( modifier = Modifier.fillMaxSize(), @@ -417,7 +434,7 @@ private fun AccessKeyStepContent() { if (requiresIap) { flowNavigator.navigateTo(OnboardingStep.Purchase) } else { - flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification) + flowNavigator.proceedToNameOrPermissions(userManager) } } @@ -430,11 +447,12 @@ private fun PurchaseStepContent() { val viewModel = hiltViewModel() val flowNavigator = rememberFlowNavigator() val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val userManager = LocalUserManager.current LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification) } + .onEach { flowNavigator.proceedToNameOrPermissions(userManager) } .launchIn(this) } 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 f731eb8da..0c511bdb8 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 @@ -6,7 +6,6 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager 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 @@ -34,7 +33,6 @@ class LoginViewModel @Inject constructor( private val accounts: AccountController, private val resources: ResourceHelper, private val analytics: FlipcashAnalyticsService, - userManager: UserManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -47,7 +45,6 @@ class LoginViewModel @Inject constructor( val creatingAccount: LoadingSuccessState = LoadingSuccessState(), val logoTapCount: Int = 0, val betaOptionsVisible: Boolean = false, - val needsPhoneVerification: Boolean = false, ) sealed interface Event { @@ -62,18 +59,11 @@ class LoginViewModel @Inject constructor( data object OnAccountCreated : Event data object CreateAccountSettled : Event data object CreateFailed : Event - data class PhoneVerificationUpdated(val needed: Boolean) : Event } private val createInFlight = AtomicBoolean(false) init { - userManager.state - .map { it.userProfile?.verifiedPhoneNumber == null } - .onEach { needed -> - dispatchEvent(Event.PhoneVerificationUpdated(needed)) - }.launchIn(viewModelScope) - eventFlow .filterIsInstance() .map { stateFlow.value.logoTapCount } @@ -217,10 +207,6 @@ class LoginViewModel @Inject constructor( ) ) } - - is Event.PhoneVerificationUpdated -> { state -> - state.copy(needsPhoneVerification = event.needed) - } } } } diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt index 256ee5401..21f53e921 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt @@ -6,7 +6,6 @@ import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.services.controllers.AccountController -import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.util.resources.FakeResourceHelper import io.mockk.every @@ -46,7 +45,6 @@ class LoginViewModelCreateAccountTest { private val accounts: AccountController = mock() private val resources = FakeResourceHelper() private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) - private val userManager: UserManager = mockk(relaxed = true) private lateinit var dispatchers: TestDispatchers @@ -68,7 +66,6 @@ class LoginViewModelCreateAccountTest { accounts = accounts, resources = resources, analytics = analytics, - userManager = userManager, dispatchers = dispatchers, ) 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 11279ad63..6aa3fadc5 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 @@ -6,7 +6,6 @@ import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.services.controllers.AccountController -import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.util.resources.FakeResourceHelper import io.mockk.every @@ -41,7 +40,6 @@ class LoginViewModelErrorTest { // MockK for everything else private val resources = FakeResourceHelper() private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) - private val userManager: UserManager = mockk(relaxed = true) private lateinit var dispatchers: TestDispatchers @@ -65,7 +63,6 @@ class LoginViewModelErrorTest { accounts = accounts, resources = resources, analytics = analytics, - userManager = userManager, dispatchers = dispatchers, ) } diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelPhoneVerificationTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelPhoneVerificationTest.kt deleted file mode 100644 index acf7b4afe..000000000 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelPhoneVerificationTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -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.core.MainCoroutineRule -import com.flipcash.app.core.dispatchers.TestDispatchers -import com.flipcash.services.controllers.AccountController -import com.flipcash.services.models.UserProfile -import com.flipcash.services.user.UserManager -import com.getcode.manager.BottomBarManager -import com.getcode.util.resources.FakeResourceHelper -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Regression coverage for the OnboardingPhoneVerification / PhoneNumberSend flag removal. - * Both flags were `launched = true`, so phone verification is now driven purely by whether - * the account has a verified phone number — the flag no longer gates it. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class LoginViewModelPhoneVerificationTest { - - @get:Rule - val instantExecutorRule = InstantTaskExecutorRule() - - @get:Rule - var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - - private val authManager: AuthManager = mockk(relaxed = true) - private val accounts: AccountController = mockk(relaxed = true) - private val resources = FakeResourceHelper() - private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) - private val userManager: UserManager = mockk(relaxed = true) - - private lateinit var dispatchers: TestDispatchers - - @Before - fun setUp() { - BottomBarManager.clear() - mockkStatic(android.util.Base64::class) - every { android.util.Base64.encodeToString(any(), any()) } answers { - java.util.Base64.getEncoder().encodeToString(firstArg()) - } - } - - @After - fun tearDown() { - BottomBarManager.clear() - unmockkStatic(android.util.Base64::class) - } - - private fun stubProfileWithPhone(verifiedPhoneNumber: String?) { - val profile = mockk(relaxed = true) { - every { this@mockk.verifiedPhoneNumber } returns verifiedPhoneNumber - } - val state = mockk(relaxed = true) { - every { userProfile } returns profile - } - every { userManager.state } returns MutableStateFlow(state) - } - - private fun createViewModel() = LoginViewModel( - authManager = authManager, - accounts = accounts, - resources = resources, - analytics = analytics, - userManager = userManager, - dispatchers = dispatchers, - ) - - @Test - fun `phone verification needed when no verified phone linked`() = - runTest(mainCoroutineRule.dispatcher) { - dispatchers = TestDispatchers(testScheduler) - stubProfileWithPhone(verifiedPhoneNumber = null) - - val vm = createViewModel() - advanceUntilIdle() - - assertTrue(vm.stateFlow.value.needsPhoneVerification) - } - - @Test - fun `phone verification not needed when a verified phone is linked`() = - runTest(mainCoroutineRule.dispatcher) { - dispatchers = TestDispatchers(testScheduler) - stubProfileWithPhone(verifiedPhoneNumber = "+15005550000") - - val vm = createViewModel() - advanceUntilIdle() - - assertFalse(vm.stateFlow.value.needsPhoneVerification) - } -} 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 d67e8f383..a3ed02526 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 @@ -254,20 +254,19 @@ class AuthManager @Inject constructor( // Soft logins (app restart) can trust the persisted flag. val completedOnboarding = if (!isSoftLogin) false else credentialManager.hasCompletedOnboarding() - // If phone verification is required but not yet completed, onboarding - // should resume at the phone-verification step (which precedes the - // access key) rather than jumping ahead to the access key. - val phoneUnverified = userManager.profile?.verifiedPhoneNumber == null + // Display-name entry follows the access key, so it only gates the resume + // point once the access key has been seen — before that, resume at the + // access key regardless of whether a name is set. + val displayNameMissing = userManager.profile?.displayName.isNullOrEmpty() if (flags != null) { userManager.set(flags) if (flags.isRegistered && seenAccessKey && completedOnboarding) { userManager.set(AuthState.Ready) } else { val resumePoint = when { - !seenAccessKey && phoneUnverified -> - AuthState.ResumePoint.PhoneNumber !seenAccessKey -> AuthState.ResumePoint.AccessKey flags.requiresIapForRegistration -> AuthState.ResumePoint.AccessKeyThenPurchase + displayNameMissing -> AuthState.ResumePoint.DisplayName else -> AuthState.ResumePoint.PostAccessKey } trace(tag = "Onboarding", message = "Resuming onboarding at $resumePoint", type = TraceType.Process) @@ -279,9 +278,9 @@ class AuthManager @Inject constructor( userManager.set(authState = AuthState.Ready) } else { val resumePoint = when { - seenAccessKey -> AuthState.ResumePoint.PostAccessKey - phoneUnverified -> AuthState.ResumePoint.PhoneNumber - else -> AuthState.ResumePoint.AccessKey + !seenAccessKey -> AuthState.ResumePoint.AccessKey + displayNameMissing -> AuthState.ResumePoint.DisplayName + else -> AuthState.ResumePoint.PostAccessKey } trace(tag = "Onboarding", message = "Resuming onboarding at $resumePoint (flags unavailable)", type = TraceType.Process) userManager.set(authState = AuthState.Onboarding(resumePoint)) 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 5af111971..f7ba05de4 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 @@ -362,9 +362,9 @@ class RealSessionController @Inject constructor( AuthState.ResumePoint.AccessKey -> currentState.resumePoint - // Phone verification precedes the access key and is + // Display-name entry follows the access key and is // unrelated to IAP correction — leave it unchanged. - AuthState.ResumePoint.PhoneNumber -> currentState.resumePoint + AuthState.ResumePoint.DisplayName -> currentState.resumePoint } if (corrected != currentState.resumePoint) { userManager.set(authState = AuthState.Onboarding(corrected)) diff --git a/maestro/subflows/create_account.yaml b/maestro/subflows/create_account.yaml index 0fb8912d9..4b62b52e9 100644 --- a/maestro/subflows/create_account.yaml +++ b/maestro/subflows/create_account.yaml @@ -1,6 +1,6 @@ # Create a fresh account through onboarding, for flows that must run on a brand-new -# account (e.g. one-run-per-account setup). Uses the test phone +1 (500) 555-0000 and -# the all-zero OTP. Enables beta flags at launch via BETA_FLAGS (comma-separated +# account (e.g. one-run-per-account setup). Sets the display name "Test User" during +# onboarding. Enables beta flags at launch via BETA_FLAGS (comma-separated # FeatureFlag.key list; debug builds only). appId: com.flipcash.app.android --- @@ -13,18 +13,17 @@ appId: com.flipcash.app.android - extendedWaitUntil: { visible: { id: login_screen }, timeout: 15000 } - tapOn: { id: create_account_button } -# Phone verification (test number + all-zero OTP). -- extendedWaitUntil: { visible: { id: phone_entry_screen }, timeout: 15000 } -- inputText: "5005550000" -- tapOn: "Next" -- extendedWaitUntil: { visible: { id: phone_code_screen }, timeout: 15000 } -- inputText: "000000" - # Access key — take the "wrote it down" path (confirm the dialog). - extendedWaitUntil: { visible: { id: access_key_screen }, timeout: 15000 } - tapOn: "Wrote the 12 Words Down Instead?" - tapOn: "Wrote the 12 Words Down Instead?" +# Display name entry (follows the access key). +- extendedWaitUntil: { visible: { id: name_screen }, timeout: 15000 } +- tapOn: { text: "Your Name" } +- inputText: "Test User" +- tapOn: "Next" + # A push-notification permission dialog appears only if it isn't already granted. - tapOn: text: "Allow" diff --git a/maestro/tipping_setup.yaml b/maestro/tipping_setup.yaml index 2e9152d2a..b7157ff83 100644 --- a/maestro/tipping_setup.yaml +++ b/maestro/tipping_setup.yaml @@ -15,14 +15,11 @@ tags: - tapOn: "Tips" - extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } -# Set up the tip card: name is the next step. -- tapOn: "Start Receiving Tips" -- extendedWaitUntil: { visible: { id: name_screen }, timeout: 8000 } -- tapOn: { text: "Your Name" } -- inputText: "Test Tipper" -- tapOn: "Next" +# The display name is captured during onboarding, so tip setup lands directly on the +# Tips list — no name step here. Open the tip card from the list. +- tapOn: "Show My Tip Card" -# The tip card renders with the chosen name. +# The tip card renders with the display name set during onboarding. - extendedWaitUntil: { visible: { id: tip_card_screen }, timeout: 8000 } - assertVisible: "My Tip Card" -- assertVisible: "Tip Test Tipper" +- assertVisible: "Tip Test User" diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt index 372377e23..bfd819adf 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt @@ -49,8 +49,8 @@ sealed interface AuthState { data object Unknown : AuthState enum class ResumePoint { - /** User created account but has not verified their phone number yet. */ - PhoneNumber, + /** User created account but has not set their display name yet. */ + DisplayName, /** User created account but has not seen their access key. */ AccessKey, /** User has seen their access key but must complete IAP before registration. */ From 31ce365db7f92d16ef1ae2edc5baef7b120d237f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 5 Aug 2026 14:36:28 -0400 Subject: [PATCH 2/2] feat(onboarding): hide back button on the mandatory name step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AppRoute.UpdateUserProfile.allowBack (default true). Onboarding launches the display-name subflow with allowBack = false so NameEntryScreen renders no back affordance and swallows the system back gesture — the step is mandatory. Tip-card setup and profile editing keep the back button. --- .../flipcash/app/internal/ui/navigation/MainRoot.kt | 1 + .../src/main/kotlin/com/flipcash/app/core/AppRoute.kt | 3 +++ .../com/flipcash/app/login/OnboardingFlowScreen.kt | 1 + .../app/userprofile/UserProfileSetupFlowScreen.kt | 2 +- .../app/userprofile/internal/name/NameEntryScreen.kt | 10 ++++++++-- 5 files changed, 14 insertions(+), 3 deletions(-) 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 8059f8e24..babd5e52a 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 @@ -199,6 +199,7 @@ internal fun buildNavGraphForLaunch( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, ), + allowBack = false, ) ) ) 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 173b56d33..5805f2e70 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 @@ -147,6 +147,9 @@ sealed interface AppRoute : NavKey, Parcelable { val includeName: Boolean = true, val includePhoto: Boolean = true, val target: AppRoute? = null, + // When false, the first step has no back affordance and system back is swallowed — + // used in onboarding where display-name entry is a mandatory, non-dismissable step. + val allowBack: Boolean = true, ): AppRoute, FlowRouteWithResult { override val initialStack: List get() = buildUpdateUserProfileStack(includeName, includePhoto) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 40b3c7745..74d2c6ace 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -267,6 +267,7 @@ private fun FlowNavigator.proceedToNameOrPermi phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, ), + allowBack = false, ) ) } else { diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index 8e63137fe..565955148 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -58,7 +58,7 @@ private fun profileUpdateProvider( route: AppRoute.UpdateUserProfile, ): (NavKey) -> NavEntry = entryProvider { annotatedEntry { - NameEntryScreen() + NameEntryScreen(allowBack = route.allowBack) } annotatedEntry { PhotoSelectionScreen() diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt index fa6b0cecd..5f37815e4 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt @@ -1,5 +1,6 @@ package com.flipcash.app.userprofile.internal.name +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -38,7 +39,9 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @Composable -internal fun NameEntryScreen() { +internal fun NameEntryScreen( + allowBack: Boolean = true, +) { val flowNavigator = rememberFlowNavigator() val viewModel = hiltViewModel() @@ -46,9 +49,12 @@ internal fun NameEntryScreen() { val keyboard = rememberKeyboardController() + // In onboarding this is a mandatory step, so hide the back affordance and swallow system back. + BackHandler(enabled = !allowBack) { /* no-op: cannot back out of the name step */ } + Column { AppBarWithTitle( - backButton = true, + backButton = allowBack, onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back()