Skip to content

Commit c903b6a

Browse files
committed
feat(onboarding): skip contact permission for existing users and extract routing logic
- Add skipContacts flag to OnboardingFlow route, passed from PostAccessKey path - Purchase now exits with ProceedToVerification so IAP paths check phone-linked state and route through verification when needed - Extract resolvePostAccountRoute pure function from composable routing logic - Add OnboardingRoutingTest covering all flow chart paths - Replace prose KDoc with ASCII flow charts documenting each onboarding path Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent adf4293 commit c903b6a

3 files changed

Lines changed: 172 additions & 66 deletions

File tree

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ sealed interface AppRoute : NavKey, Parcelable {
7171
val seed: String? = null,
7272
val fromDeeplink: Boolean = false,
7373
val resumeAt: ResumePoint = ResumePoint.Login,
74+
val skipContacts: Boolean = false,
7475
) : AppRoute, FlowRoute {
7576
enum class Phase { Account, Permissions }
7677
enum class ResumePoint { Login, AccessKey, AccessKeyThenPurchase, PostAccessKey }

apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt

Lines changed: 67 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -67,30 +67,35 @@ import kotlinx.coroutines.flow.launchIn
6767
import 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
96101
fun 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+
260261
private 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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.flipcash.app.login
2+
3+
import com.flipcash.app.core.AppRoute
4+
import com.flipcash.app.core.onboarding.OnboardingResult
5+
import org.junit.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertIs
8+
import kotlin.test.assertNull
9+
10+
class OnboardingRoutingTest {
11+
12+
// -- Path 1: New account (AccessKey exits with ProceedToVerification) --
13+
14+
@Test
15+
fun `path 1 - new account routes through verification when phone not linked`() {
16+
val route = resolvePostAccountRoute(
17+
result = OnboardingResult.ProceedToVerification,
18+
hasLinkedPhone = false,
19+
)
20+
val verification = assertIs<AppRoute.Verification>(route)
21+
val target = assertIs<AppRoute.OnboardingFlow>(verification.target)
22+
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase)
23+
assertEquals(false, target.skipContacts)
24+
}
25+
26+
@Test
27+
fun `path 1 - new account skips verification when phone already linked`() {
28+
val route = resolvePostAccountRoute(
29+
result = OnboardingResult.ProceedToVerification,
30+
hasLinkedPhone = true,
31+
)
32+
val flow = assertIs<AppRoute.OnboardingFlow>(route)
33+
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase)
34+
assertEquals(false, flow.skipContacts)
35+
}
36+
37+
// -- Path 2: Seed restore (SeedInput exits with LoggedIn, Purchase exits with ProceedToVerification) --
38+
39+
@Test
40+
fun `path 2 - seed restore without IAP skips verification`() {
41+
val route = resolvePostAccountRoute(
42+
result = OnboardingResult.LoggedIn,
43+
hasLinkedPhone = false,
44+
)
45+
val flow = assertIs<AppRoute.OnboardingFlow>(route)
46+
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase)
47+
assertEquals(false, flow.skipContacts)
48+
}
49+
50+
@Test
51+
fun `path 2 - seed restore with IAP routes through verification when phone not linked`() {
52+
val route = resolvePostAccountRoute(
53+
result = OnboardingResult.ProceedToVerification,
54+
hasLinkedPhone = false,
55+
)
56+
val verification = assertIs<AppRoute.Verification>(route)
57+
val target = assertIs<AppRoute.OnboardingFlow>(verification.target)
58+
assertEquals(false, target.skipContacts)
59+
}
60+
61+
@Test
62+
fun `path 2 - seed restore with IAP skips verification when phone linked`() {
63+
val route = resolvePostAccountRoute(
64+
result = OnboardingResult.ProceedToVerification,
65+
hasLinkedPhone = true,
66+
)
67+
val flow = assertIs<AppRoute.OnboardingFlow>(route)
68+
assertEquals(false, flow.skipContacts)
69+
}
70+
71+
// -- Path 3: App resume (PostAccessKey, skipContacts = true) --
72+
73+
@Test
74+
fun `path 3 - app resume skips verification and contacts when phone linked`() {
75+
val route = resolvePostAccountRoute(
76+
result = OnboardingResult.ProceedToVerification,
77+
hasLinkedPhone = true,
78+
skipContacts = true,
79+
)
80+
val flow = assertIs<AppRoute.OnboardingFlow>(route)
81+
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase)
82+
assertEquals(true, flow.skipContacts)
83+
}
84+
85+
@Test
86+
fun `path 3 - app resume routes through verification and skips contacts when phone not linked`() {
87+
val route = resolvePostAccountRoute(
88+
result = OnboardingResult.ProceedToVerification,
89+
hasLinkedPhone = false,
90+
skipContacts = true,
91+
)
92+
val verification = assertIs<AppRoute.Verification>(route)
93+
val target = assertIs<AppRoute.OnboardingFlow>(verification.target)
94+
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase)
95+
assertEquals(true, target.skipContacts)
96+
}
97+
98+
// -- Completed (no-op) --
99+
100+
@Test
101+
fun `completed result returns null`() {
102+
assertNull(resolvePostAccountRoute(OnboardingResult.Completed, hasLinkedPhone = false))
103+
}
104+
}

0 commit comments

Comments
 (0)