Skip to content

Commit cfe2964

Browse files
authored
feat(onboarding): unify login into a single OnboardingFlow with linear FlowHost API (#778)
* feat(onboarding): unify login into a single OnboardingFlow with linear FlowHost API Replace the fragmented login routing (LoginRouter, standalone screens, and scattered verification logic in MainRoot) with a single OnboardingFlowScreen backed by two FlowHost phases: Account and Permissions. FlowHost API changes: - Add linear flow overload with `steps`, `resumeAt`, and `completedResult` parameters for ordered step-by-step flows - Add `FlowNavigator.proceed()` to advance through the step list, exit with completedResult at the end, or delegate to `onProceed` for custom behavior - Rename the existing overload as the non-linear variant for flows that manage their own navigation via navigateTo/exitWithResult - Extract shared logic into FlowHostImpl; support re-seeding when initialStack changes before user navigation (async flag settling) Onboarding routing: - All AuthState.Registered cases now route to AppRoute.OnboardingFlow with a ResumePoint (Login, AccessKey, AccessKeyThenPurchase, or PostAccessKey) — MainRoot no longer routes directly to Verification - PostAccessKeyRedirect checks UserProfile.verifiedPhoneNumber to skip verification when phone is already linked - Seed restore (LoggedIn) skips verification and goes straight to permissions — existing users encounter phone verification in-app via the send flow - Permissions phase uses the linear FlowHost with resumeAt to skip already-granted permissions Login module restructuring: - Delete LoginRouter, AccessKeyScreen, SeedInputScreen (standalone wrappers) — all step content is now composed inline by OnboardingFlowScreen via the entryProvider - Move ViewModels to internal package, screen content to internal/screens - Add OnboardingStep sealed interface and OnboardingResult for flow step/result modeling Signed-off-by: Brandon McAnsh <git@bmcreations.dev> * feat(onboarding): skip contact permission step when ContactPickerMode is enabled * feat(auth): fetch user profile on login to populate verified phone number Ensures UserProfile (including verifiedPhoneNumber) is available before onboarding routing decisions that depend on phone-linked state. Signed-off-by: Brandon McAnsh <git@bmcreations.dev> * 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> * feat(contacts): suspend contact sync, Android 17 multi-pick picker, and loading button states Make ContactCoordinator.sync() and addPickedContacts() suspend returning Result<Unit> so callers can await completion. Add ContactAccessHandle using ContactsPickerSessionContract on API 37+ with phone-only filtering, falling back to full permission on older APIs. Thread isLoading/isSuccess through ContactPermissionBottomBar for visual feedback during sync. * fix(onboarding): preserve seenAccessKey flag across restart so onboarding resumes at access key onAccountPurchased was removing seenAccessKeyKey, and login always set LoggedInWithUser when flags.isRegistered was true, skipping the access key screen on restart. Now onAccountPurchased preserves the flag value, hasSeenAccessKey falls back to selectedAccountIdKey, login gates LoggedInWithUser on seenAccessKey, and RealSessionController respects incomplete onboarding state. * feat(onboarding): move phone verification before access key and remove for existing users When PhoneNumberSend is enabled, new account creation now launches verification with target=OnboardingFlow(AccessKey) so the user verifies their phone before seeing the access key. Existing users (PostAccessKey) skip verification entirely and go straight to permissions. Verification gating logic moved from composable into LoginViewModel. * chore: add onboarding, session, and phone area labels for PR labeler Signed-off-by: Brandon McAnsh <git@bmcreations.dev> --------- Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 2be9942 commit cfe2964

55 files changed

Lines changed: 1440 additions & 523 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/labeler.yml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,27 @@
2525
"area: auth":
2626
- changed-files:
2727
- any-glob-to-any-file:
28-
- "apps/flipcash/features/login/**"
2928
- "apps/flipcash/shared/authentication/**"
30-
- "apps/flipcash/shared/accesskey/**"
29+
30+
"area: session":
31+
- changed-files:
32+
- any-glob-to-any-file:
3133
- "apps/flipcash/shared/session/**"
32-
- "apps/flipcash/features/contact-verification/**"
34+
35+
"area: phone":
36+
- changed-files:
37+
- any-glob-to-any-file:
3338
- "apps/flipcash/shared/phone/**"
3439

40+
"area: onboarding":
41+
- changed-files:
42+
- any-glob-to-any-file:
43+
- "apps/flipcash/features/login/**"
44+
- "apps/flipcash/shared/accesskey/**"
45+
- "apps/flipcash/features/contact-verification/**"
46+
- "apps/flipcash/shared/permissions/**"
47+
- "apps/flipcash/core/**/onboarding/**"
48+
3549
"area: tokens":
3650
- changed-files:
3751
- any-glob-to-any-file:

apps/flipcash/app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ dependencies {
185185
implementation(project(":apps:flipcash:features:transactions"))
186186
implementation(project(":apps:flipcash:features:bill-customization"))
187187
implementation(project(":apps:flipcash:features:currency-creator"))
188+
implementation(project(":apps:flipcash:features:direct-send"))
188189
implementation(project(":apps:flipcash:features:discovery"))
189190
implementation(project(":apps:flipcash:features:userflags"))
190191

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,8 @@ internal fun App(
266266
action.entropy,
267267
onSwitchAccount = {
268268
codeNavigator.replaceAll(
269-
AppRoute.Onboarding.Login(
270-
action.entropy,
269+
AppRoute.OnboardingFlow(
270+
seed = action.entropy,
271271
fromDeeplink = true
272272
)
273273
)
@@ -287,13 +287,13 @@ internal fun App(
287287
LaunchedEffect(userState.authState) {
288288
if (userState.authState == AuthState.LoggedOut) {
289289
val current = codeNavigator.currentRouteKey
290-
if (current !is AppRoute.Loading && current !is AppRoute.Onboarding) {
290+
if (current !is AppRoute.Loading && current !is AppRoute.OnboardingFlow) {
291291
codeNavigator.pendingSheetDismiss = null
292292
val switchEntropy =
293293
viewModel.consumePendingSwitchEntropy()
294294
codeNavigator.replaceAll(
295-
AppRoute.Onboarding.Login(
296-
switchEntropy
295+
AppRoute.OnboardingFlow(
296+
seed = switchEntropy
297297
)
298298
)
299299
}

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppRestrictedScreen.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ fun AppRestrictedScreen(restrictionType: RestrictionType) {
1919
coroutineScope.launch {
2020
homeViewModel.logout()
2121
.onSuccess {
22-
navigator.replaceAll(
23-
AppRoute.Onboarding.Login()
24-
)
22+
navigator.replaceAll(AppRoute.OnboardingFlow())
2523
}
2624
}
2725
}

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,9 @@ import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEn
3434
import com.flipcash.app.lab.LabsScreen
3535
import com.flipcash.app.lab.NavBarSettingsScreen
3636
import com.flipcash.app.lab.StandaloneLabsScreen
37-
import com.flipcash.app.login.accesskey.AccessKeyScreen
38-
import com.flipcash.app.login.accesskey.PhotoAccessKeyScreen
39-
import com.flipcash.app.login.router.LoginRouter
40-
import com.flipcash.app.login.seed.SeedInputScreen
37+
import com.flipcash.app.login.OnboardingFlowScreen
4138
import com.flipcash.app.menu.MenuScreen
4239
import com.flipcash.app.myaccount.MyAccountScreen
43-
import com.flipcash.app.permissions.ContactPermissionScreen
44-
import com.flipcash.app.permissions.NotificationPermissionRationaleScreen
45-
import com.flipcash.app.permissions.NotificationPermissionScreen
46-
import com.flipcash.app.purchase.PurchaseAccountScreen
4740
import com.flipcash.app.scanner.ScannerScreen
4841
import com.flipcash.app.shareapp.ShareAppScreen
4942
import com.flipcash.app.tokens.SwapFlowScreen
@@ -75,16 +68,10 @@ fun appEntryProvider(
7568
// Loading / splash
7669
annotatedEntry<AppRoute.Loading> { MainRoot(deepLink) }
7770

78-
// Onboarding
79-
annotatedEntry<AppRoute.Onboarding.Login> { key -> LoginRouter(key.seed, key.fromDeeplink) }
80-
annotatedEntry<AppRoute.Onboarding.SeedInput> { SeedInputScreen() }
81-
annotatedEntry<AppRoute.Onboarding.AccessKey> { AccessKeyScreen() }
82-
annotatedEntry<AppRoute.Onboarding.AccessKeySavedLocation> { PhotoAccessKeyScreen() }
83-
annotatedEntry<AppRoute.Onboarding.Purchase> { key -> PurchaseAccountScreen(key.fromLogin) }
84-
annotatedEntry<AppRoute.Onboarding.ContactPermission> { key -> ContactPermissionScreen(key.postCreate) }
85-
annotatedEntry<AppRoute.Onboarding.NotificationPermission> { key -> NotificationPermissionScreen(key.postCreate) }
86-
annotatedEntry<AppRoute.Onboarding.NotificationPermissionRationale> { key -> NotificationPermissionRationaleScreen(key.permanentlyDenied) }
87-
annotatedEntry<AppRoute.Onboarding.CameraPermission> { }
71+
// Onboarding flow
72+
annotatedEntry<AppRoute.OnboardingFlow> { key ->
73+
OnboardingFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
74+
}
8875

8976
// Main
9077
annotatedEntry<AppRoute.Main.Sheet> { key ->

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,25 +179,13 @@ private fun buildNavGraphForLaunch(
179179
): LaunchNavGraph? {
180180
return when (state) {
181181
is AuthState.Registered -> {
182-
if (state.seenAccessKey) {
183-
val routes = if (userFlags?.requiresIapForRegistration == true) {
184-
listOf(
185-
AppRoute.Onboarding.Login(),
186-
AppRoute.Onboarding.AccessKey,
187-
AppRoute.Onboarding.Purchase()
188-
)
189-
} else {
190-
listOf(AppRoute.Main.Scanner)
191-
}
192-
LaunchNavGraph(routes)
193-
} else {
194-
LaunchNavGraph(
195-
listOf(
196-
AppRoute.Onboarding.Login(),
197-
AppRoute.Onboarding.AccessKey
198-
)
199-
)
182+
val resumePoint = when {
183+
!state.seenAccessKey -> AppRoute.OnboardingFlow.ResumePoint.AccessKey
184+
userFlags?.requiresIapForRegistration == true ->
185+
AppRoute.OnboardingFlow.ResumePoint.AccessKeyThenPurchase
186+
else -> AppRoute.OnboardingFlow.ResumePoint.PostAccessKey
200187
}
188+
LaunchNavGraph(listOf(AppRoute.OnboardingFlow(resumeAt = resumePoint)))
201189
}
202190

203191
AuthState.LoggedInWithUser -> {
@@ -223,10 +211,10 @@ private fun buildNavGraphForLaunch(
223211
if (link != null) {
224212
when (val action = router.dispatch(link)) {
225213
is DeeplinkAction.Navigate -> LaunchNavGraph(action.routes)
226-
else -> LaunchNavGraph(listOf(AppRoute.Onboarding.Login()))
214+
else -> LaunchNavGraph(listOf(AppRoute.OnboardingFlow()))
227215
}
228216
} else {
229-
LaunchNavGraph(listOf(AppRoute.Onboarding.Login()))
217+
LaunchNavGraph(listOf(AppRoute.OnboardingFlow()))
230218
}
231219
}
232220

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import com.flipcash.app.core.verification.VerificationResult
1414
import com.flipcash.app.core.verification.VerificationStep
1515
import com.flipcash.app.core.withdrawal.WithdrawalResult
1616
import com.flipcash.app.core.withdrawal.WithdrawalStep
17+
import com.flipcash.app.core.onboarding.OnboardingStep
1718
import com.getcode.navigation.NonDismissableRoute
1819
import com.getcode.navigation.NonDraggableRoute
20+
import com.getcode.navigation.flow.FlowRoute
1921
import com.getcode.navigation.flow.FlowRouteWithResult
2022
import com.getcode.opencode.exchange.VerifiedFiat
2123
import com.getcode.opencode.internal.solana.model.SwapId
@@ -62,6 +64,31 @@ sealed interface AppRoute : NavKey, Parcelable {
6264
}
6365

6466

67+
@Serializable
68+
@Parcelize
69+
data class OnboardingFlow(
70+
val phase: Phase = Phase.Account,
71+
val seed: String? = null,
72+
val fromDeeplink: Boolean = false,
73+
val resumeAt: ResumePoint = ResumePoint.Login,
74+
val skipContacts: Boolean = false,
75+
) : AppRoute, FlowRoute {
76+
enum class Phase { Account, Permissions }
77+
enum class ResumePoint { Login, AccessKey, AccessKeyThenPurchase, PostAccessKey }
78+
79+
override val initialStack: List<NavKey>
80+
get() = when (phase) {
81+
Phase.Account -> when (resumeAt) {
82+
ResumePoint.Login -> listOf(OnboardingStep.Start(seed, fromDeeplink))
83+
ResumePoint.AccessKey -> listOf(OnboardingStep.Start(), OnboardingStep.AccessKey)
84+
ResumePoint.AccessKeyThenPurchase ->
85+
listOf(OnboardingStep.Start(), OnboardingStep.AccessKey, OnboardingStep.Purchase)
86+
ResumePoint.PostAccessKey -> emptyList()
87+
}
88+
Phase.Permissions -> listOf(OnboardingStep.ContactPermission, OnboardingStep.NotificationPermission)
89+
}
90+
}
91+
6592
@Serializable
6693
@Parcelize
6794
sealed interface Main : AppRoute {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.flipcash.app.core.onboarding
2+
3+
import android.os.Parcelable
4+
import com.getcode.navigation.NonDismissableRoute
5+
import com.getcode.navigation.flow.FlowStep
6+
import kotlinx.parcelize.Parcelize
7+
import kotlinx.serialization.Serializable
8+
9+
/**
10+
* Steps inside the Onboarding flow. Owned by [com.flipcash.app.core.AppRoute.OnboardingFlow]
11+
* and rendered inside a [com.getcode.navigation.flow.FlowHost].
12+
*/
13+
@Serializable
14+
sealed interface OnboardingStep : FlowStep, Parcelable {
15+
@Parcelize
16+
@Serializable
17+
data class Start(val seed: String? = null, val fromDeeplink: Boolean = false) : OnboardingStep
18+
19+
@Parcelize
20+
@Serializable
21+
data object SeedInput : OnboardingStep
22+
23+
@Parcelize
24+
@Serializable
25+
data object AccessKey : OnboardingStep
26+
27+
@Parcelize
28+
@Serializable
29+
data object AccessKeySavedLocation : OnboardingStep
30+
31+
@Parcelize
32+
@Serializable
33+
data object Purchase : OnboardingStep
34+
35+
@Parcelize
36+
@Serializable
37+
data object ContactPermission : OnboardingStep
38+
39+
@Parcelize
40+
@Serializable
41+
data object NotificationPermission : OnboardingStep
42+
43+
@Parcelize
44+
@Serializable
45+
data class NotificationPermissionRationale(
46+
val permanentlyDenied: Boolean = false,
47+
) : OnboardingStep, NonDismissableRoute
48+
}
49+
50+
@Serializable
51+
sealed interface OnboardingResult : Parcelable {
52+
@Parcelize
53+
@Serializable
54+
data object ProceedToVerification : OnboardingResult
55+
56+
@Parcelize
57+
@Serializable
58+
data object LoggedIn : OnboardingResult
59+
60+
@Parcelize
61+
@Serializable
62+
data object Completed : OnboardingResult
63+
}

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,16 @@
275275
<string name="error_description_deeplinkOnRampInsufficientUsdc">Your %1$s wallet doesn\'t have enough USDC for this purchase. Please add more USDC and try again.</string>
276276
<string name="error_description_deeplinkOnRampUnknown">Please try again</string>
277277

278-
<string name="title_verifyPhoneNumber">Verify Phone Number</string>
278+
<string name="title_connectPhoneNumber">Connect Phone Number</string>
279279
<string name="subtitle_enterPhoneNumberToContinue">Please enter your phone number to continue</string>
280280
<string name="subtitle_enterPhoneVerificationCode">An SMS message was sent to your phone number with a verification code.\nPlease enter the verification code above</string>
281281
<string name="subtitle_enterEmailToContinue">Please enter your email to continue</string>
282282

283-
<string name="title_verifyEmailAddress">Verify Your Email</string>
283+
<string name="title_connectPhoneToSend">Connect Phone To Send</string>
284+
<string name="subtitle_connectPhoneToSend">Connect your phone number to send cash.</string>
285+
<string name="action_connectPhoneNumber">Connect Your Phone Number</string>
286+
287+
<string name="title_connectEmailAddress">Connect Your Email</string>
284288
<string name="subtitle_resend">Resend</string>
285289

286290
<string name="title_enterTheCode">Enter The Code</string>

apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ fun VerificationFlowScreen(
4747
value = NavResultOrCanceled.ReturnValue(result),
4848
)
4949
if (route.target != null && result is VerificationResult.Success) {
50-
outerNavigator.replace(route.target!!)
50+
outerNavigator.replaceAll(route.target!!)
5151
} else {
5252
outerNavigator.pop()
5353
}

0 commit comments

Comments
 (0)