Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,19 @@ 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,
),
allowBack = false,
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppRoute.Verification>(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<AppRoute.UpdateUserProfile>(result.baseRoutes.single())
assertTrue(route.includeName)
assertEquals(false, route.includePhoto)
val target = assertIs<AppRoute.OnboardingFlow>(route.target)
assertEquals(AppRoute.OnboardingFlow.ResumePoint.AccessKey, target.resumeAt)
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase)
}

// -- Authenticating --
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateProfileResult> {
override val initialStack: List<NavKey>
get() = buildUpdateUserProfileStack(includeName, includePhoto)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -244,6 +246,36 @@ 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<OnboardingStep, OnboardingResult>.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,
),
allowBack = false,
)
)
} 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<NavKey> = entryProvider {
Expand Down Expand Up @@ -295,23 +327,8 @@ private fun LoginStepContent(seed: String?) {
vm.eventFlow
.filterIsInstance<LoginViewModel.Event.CreateAccountSettled>()
.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)
}
Expand Down Expand Up @@ -401,6 +418,7 @@ private fun SeedInputStepContent() {
private fun AccessKeyStepContent() {
val viewModel = hiltViewModel<LoginAccessKeyViewModel>()
val flowNavigator = rememberFlowNavigator<OnboardingStep, OnboardingResult>()
val userManager = LocalUserManager.current

Column(
modifier = Modifier.fillMaxSize(),
Expand All @@ -417,7 +435,7 @@ private fun AccessKeyStepContent() {
if (requiresIap) {
flowNavigator.navigateTo(OnboardingStep.Purchase)
} else {
flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification)
flowNavigator.proceedToNameOrPermissions(userManager)
}
}

Expand All @@ -430,11 +448,12 @@ private fun PurchaseStepContent() {
val viewModel = hiltViewModel<PurchaseAccountViewModel>()
val flowNavigator = rememberFlowNavigator<OnboardingStep, OnboardingResult>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val userManager = LocalUserManager.current

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<PurchaseAccountViewModel.Event.OnAccountCreated>()
.onEach { flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification) }
.onEach { flowNavigator.proceedToNameOrPermissions(userManager) }
.launchIn(this)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<LoginViewModel.State, LoginViewModel.Event>(
initialState = State(),
Expand All @@ -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 {
Expand All @@ -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<Event.OnLogoTapped>()
.map { stateFlow.value.logoTapCount }
Expand Down Expand Up @@ -217,10 +207,6 @@ class LoginViewModel @Inject constructor(
)
)
}

is Event.PhoneVerificationUpdated -> { state ->
state.copy(needsPhoneVerification = event.needed)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -68,7 +66,6 @@ class LoginViewModelCreateAccountTest {
accounts = accounts,
resources = resources,
analytics = analytics,
userManager = userManager,
dispatchers = dispatchers,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -65,7 +63,6 @@ class LoginViewModelErrorTest {
accounts = accounts,
resources = resources,
analytics = analytics,
userManager = userManager,
dispatchers = dispatchers,
)
}
Expand Down
Loading
Loading