@@ -67,30 +67,35 @@ import kotlinx.coroutines.flow.launchIn
6767import kotlinx.coroutines.flow.onEach
6868
6969/* *
70- * Entry point for the onboarding flow. Routing decisions:
70+ * Entry point for the unified onboarding flow.
7171 *
72- * **New account** (`ProceedToVerification` from AccessKey):
73- * AccessKey → phone verification → permissions → Scanner.
74- * Phone is never linked yet, so verification is always shown.
72+ * ```
73+ * 1. New account (ResumePoint.Login → ProceedToVerification)
7574 *
76- * **Seed restore** (`LoggedIn` from SeedInput):
77- * SeedInput → permissions → Scanner.
78- * Verification is skipped — the phone may already be linked server-side,
79- * and existing users will encounter phone verification in-app when they
80- * first use the send flow.
75+ * Start → AccessKey ──┬────────────→ Verification² → Contacts¹ → Notifications → Scanner
76+ * └→ Purchase ─┘
8177 *
82- * **App resume, access key seen, no IAP** (`PostAccessKey`):
83- * Checks [UserProfile.verifiedPhoneNumber]. If linked, skips to permissions;
84- * otherwise routes through verification first. Profile may be null at this
85- * point (fetched post-login by ProfileUpdater) — defaults to "not linked",
86- * which matches current behavior (shows verification).
78+ * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput)
8779 *
88- * **Permissions phase** (all paths converge here):
89- * Contacts (if phone-number-send enabled and [FeatureFlag.ContactPickerMode] is off)
90- * → notifications → Scanner.
91- * When ContactPickerMode is enabled, contacts are accessed via the system picker
92- * at call site (no READ_CONTACTS permission needed), so the contact step is skipped.
80+ * Start → SeedInput ──┬──────────────────────→ Contacts¹ → Notifications → Scanner
81+ * └→ Purchase → Verification² ─┘
82+ *
83+ * 3. App resume (ResumePoint.PostAccessKey)
84+ *
85+ * ┬─ phone linked ──→ Notifications → Scanner
86+ * └─ not linked ────→ Verification → Notifications → Scanner
87+ * (contacts always skipped — existing users encounter contacts in-app)
88+ *
89+ * 4. Mid-flow resume (ResumePoint.AccessKey / AccessKeyThenPurchase)
90+ *
91+ * Same as (1) but initialStack resumes at the AccessKey or Purchase step.
92+ * ```
93+ *
94+ * ¹ Contact permission is shown only when [FeatureFlag.PhoneNumberSend] is enabled
95+ * **and** [FeatureFlag.ContactPickerMode] is off. When ContactPickerMode is on,
96+ * contacts are accessed via the system picker at call site (no READ_CONTACTS needed).
9397 * Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost].
98+ * ² Verification is skipped if a phone number is already linked.
9499 */
95100@Composable
96101fun OnboardingFlowScreen (
@@ -101,33 +106,26 @@ fun OnboardingFlowScreen(
101106 route.phase == AppRoute .OnboardingFlow .Phase .Permissions ->
102107 PermissionsPhaseFlowHost (route, resultStateRegistry)
103108 route.resumeAt == AppRoute .OnboardingFlow .ResumePoint .PostAccessKey ->
104- PostAccessKeyRedirect ()
109+ CompleteExistingUserOnboarding ()
105110 else ->
106111 AccountPhaseFlowHost (route, resultStateRegistry)
107112 }
108113}
109114
110115@Composable
111- private fun PostAccessKeyRedirect () {
116+ private fun CompleteExistingUserOnboarding () {
112117 val navigator = LocalCodeNavigator .current
113118 val userManager = LocalUserManager .current!!
114119 val userState by userManager.state.collectAsStateWithLifecycle()
115120 val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
116121
117122 LaunchedEffect (Unit ) {
118- if (hasLinkedPhone) {
119- navigator.replace(AppRoute .OnboardingFlow (phase = AppRoute .OnboardingFlow .Phase .Permissions ))
120- } else {
121- navigator.replace(
122- AppRoute .Verification (
123- origin = AppRoute .Onboarding .AccessKey ,
124- includePhone = true ,
125- includeEmail = false ,
126- target = AppRoute .OnboardingFlow (phase = AppRoute .OnboardingFlow .Phase .Permissions ),
127- fullScreen = true ,
128- )
129- )
130- }
123+ val route = resolvePostAccountRoute(
124+ result = OnboardingResult .ProceedToVerification ,
125+ hasLinkedPhone = hasLinkedPhone,
126+ skipContacts = true ,
127+ )
128+ route?.let { navigator.replace(it) }
131129 }
132130}
133131
@@ -152,7 +150,7 @@ private fun PermissionsPhaseFlowHost(
152150 val contactPickerMode by featureFlags.observe(FeatureFlag .ContactPickerMode ).collectAsStateWithLifecycle()
153151
154152 val permissionsSteps = buildList {
155- if (phoneNumberSendEnabled && ! contactPickerMode) add(OnboardingStep .ContactPermission )
153+ if (! route.skipContacts && phoneNumberSendEnabled && ! contactPickerMode) add(OnboardingStep .ContactPermission )
156154 add(OnboardingStep .NotificationPermission )
157155 }
158156
@@ -164,7 +162,7 @@ private fun PermissionsPhaseFlowHost(
164162 val notificationsGranted = ! notificationConfig.requiresRuntimeRequest ||
165163 checker.isGranted(notificationConfig.permission)
166164 when {
167- phoneNumberSendEnabled && ! contactsGranted -> 0
165+ ! route.skipContacts && phoneNumberSendEnabled && ! contactsGranted -> 0
168166 ! notificationsGranted -> permissionsSteps.indexOfFirst {
169167 it is OnboardingStep .NotificationPermission
170168 }.coerceAtLeast(0 )
@@ -218,35 +216,10 @@ private fun AccountPhaseFlowHost(
218216 resultStateRegistry = resultStateRegistry,
219217 onExit = { reason, _ ->
220218 when (reason) {
221- is FlowExitReason .Completed -> when (reason.result) {
222- is OnboardingResult .ProceedToVerification -> {
223- val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
224- if (hasLinkedPhone) {
225- outerNavigator.replace(
226- AppRoute .OnboardingFlow (phase = AppRoute .OnboardingFlow .Phase .Permissions )
227- )
228- } else {
229- outerNavigator.replace(
230- AppRoute .Verification (
231- origin = AppRoute .Onboarding .AccessKey ,
232- includePhone = true ,
233- includeEmail = false ,
234- target = AppRoute .OnboardingFlow (
235- phase = AppRoute .OnboardingFlow .Phase .Permissions ,
236- ),
237- fullScreen = true ,
238- )
239- )
240- }
241- }
242-
243- OnboardingResult .LoggedIn -> {
244- outerNavigator.replace(
245- AppRoute .OnboardingFlow (phase = AppRoute .OnboardingFlow .Phase .Permissions )
246- )
247- }
248-
249- OnboardingResult .Completed -> Unit
219+ is FlowExitReason .Completed -> {
220+ val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
221+ val route = resolvePostAccountRoute(reason.result, hasLinkedPhone)
222+ route?.let { outerNavigator.replace(it) }
250223 }
251224
252225 FlowExitReason .BackedOutOfRoot -> Unit
@@ -257,6 +230,34 @@ private fun AccountPhaseFlowHost(
257230 )
258231}
259232
233+ internal fun resolvePostAccountRoute (
234+ result : OnboardingResult ,
235+ hasLinkedPhone : Boolean ,
236+ skipContacts : Boolean = false,
237+ ): AppRoute ? {
238+ val permissionsRoute = AppRoute .OnboardingFlow (
239+ phase = AppRoute .OnboardingFlow .Phase .Permissions ,
240+ skipContacts = skipContacts,
241+ )
242+ return when (result) {
243+ is OnboardingResult .ProceedToVerification -> {
244+ if (hasLinkedPhone) {
245+ permissionsRoute
246+ } else {
247+ AppRoute .Verification (
248+ origin = AppRoute .Onboarding .AccessKey ,
249+ includePhone = true ,
250+ includeEmail = false ,
251+ target = permissionsRoute,
252+ fullScreen = true ,
253+ )
254+ }
255+ }
256+ OnboardingResult .LoggedIn -> permissionsRoute
257+ OnboardingResult .Completed -> null
258+ }
259+ }
260+
260261private fun onboardingEntryProvider (
261262 route : AppRoute .OnboardingFlow ,
262263): (NavKey ) -> NavEntry <NavKey > = entryProvider {
@@ -426,7 +427,7 @@ private fun PurchaseStepContent() {
426427 LaunchedEffect (viewModel) {
427428 viewModel.eventFlow
428429 .filterIsInstance<PurchaseAccountViewModel .Event .OnAccountCreated >()
429- .onEach { flowNavigator.exitWithResult(OnboardingResult .LoggedIn ) }
430+ .onEach { flowNavigator.exitWithResult(OnboardingResult .ProceedToVerification ) }
430431 .launchIn(this )
431432 }
432433
0 commit comments