Skip to content

Commit b3cddfc

Browse files
committed
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.
1 parent 440e92c commit b3cddfc

9 files changed

Lines changed: 78 additions & 30 deletions

File tree

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
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ fun EmailMagicLinkContent(
4747
horizontalAlignment = Alignment.CenterHorizontally,
4848
) {
4949
AppBarWithTitle(
50-
title = stringResource(R.string.title_verifyEmailAddress),
50+
title = stringResource(R.string.title_connectEmailAddress),
5151
isInModal = true,
5252
titleAlignment = Alignment.CenterHorizontally,
5353
backButton = true,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ fun EmailVerificationContent(
4343
horizontalAlignment = Alignment.CenterHorizontally,
4444
) {
4545
AppBarWithTitle(
46-
title = stringResource(R.string.title_verifyEmailAddress),
46+
title = stringResource(R.string.title_connectEmailAddress),
4747
isInModal = true,
4848
titleAlignment = Alignment.CenterHorizontally,
4949
backButton = true,

apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneEntryScreen.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,14 @@ import androidx.compose.foundation.layout.imePadding
1111
import androidx.compose.foundation.layout.navigationBars
1212
import androidx.compose.foundation.layout.padding
1313
import androidx.compose.foundation.layout.windowInsetsPadding
14-
import androidx.compose.material.Text
14+
import androidx.compose.material3.Text
1515
import androidx.compose.runtime.Composable
16-
import androidx.compose.runtime.LaunchedEffect
17-
import androidx.compose.runtime.SideEffect
1816
import androidx.compose.runtime.getValue
1917
import androidx.compose.runtime.remember
2018
import androidx.compose.ui.Alignment
2119
import androidx.compose.ui.Alignment.Companion.Center
2220
import androidx.compose.ui.Modifier
2321
import androidx.compose.ui.focus.FocusRequester
24-
import androidx.compose.ui.layout.onPlaced
2522
import androidx.compose.ui.platform.LocalFocusManager
2623
import androidx.compose.ui.res.stringResource
2724
import androidx.compose.ui.text.style.TextAlign

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ fun PhoneVerificationContent(isInModal: Boolean = true) {
3636
horizontalAlignment = Alignment.CenterHorizontally,
3737
) {
3838
AppBarWithTitle(
39-
title = stringResource(R.string.title_verifyPhoneNumber),
39+
title = stringResource(R.string.title_connectPhoneNumber),
4040
isInModal = isInModal,
4141
titleAlignment = Alignment.CenterHorizontally,
4242
backButton = true,

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

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import com.flipcash.app.login.internal.screens.LoginRouterScreenContent
3939
import com.flipcash.app.login.internal.screens.SeedInputContent
4040
import com.flipcash.app.login.router.LoginViewModel
4141
import com.flipcash.app.login.internal.SeedInputViewModel
42+
import com.flipcash.app.permissions.asContactAccessHandle
4243
import com.flipcash.app.permissions.internal.contacts.ContactScreenContent
4344
import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent
4445
import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent
@@ -72,8 +73,8 @@ import kotlinx.coroutines.flow.onEach
7273
* ```
7374
* 1. New account (ResumePoint.Login → ProceedToVerification)
7475
*
75-
* Start → AccessKey ──┬────────────→ Verification² → Contacts¹ → Notifications → Scanner
76-
* └→ Purchase ─┘
76+
* Start → Verification³ → AccessKey ──┬──→ Contacts¹ → Notifications → Scanner
77+
* └→ Purchase ─┘
7778
*
7879
* 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput)
7980
*
@@ -82,9 +83,8 @@ import kotlinx.coroutines.flow.onEach
8283
*
8384
* 3. App resume (ResumePoint.PostAccessKey)
8485
*
85-
* ┬─ phone linked ──→ Notifications → Scanner
86-
* └─ not linked ────→ Verification → Notifications → Scanner
87-
* (contacts always skipped — existing users encounter contacts in-app)
86+
* → Notifications → Scanner
87+
* (contacts and verification skipped — existing users encounter these in-app)
8888
*
8989
* 4. Mid-flow resume (ResumePoint.AccessKey / AccessKeyThenPurchase)
9090
*
@@ -96,6 +96,8 @@ import kotlinx.coroutines.flow.onEach
9696
* contacts are accessed via the system picker at call site (no READ_CONTACTS needed).
9797
* Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost].
9898
* ² Verification is skipped if a phone number is already linked.
99+
* ³ Phone verification is shown only when [FeatureFlag.PhoneNumberSend] is enabled
100+
* and no phone is linked. Uses `target` to replace nav stack with AccessKey on success.
99101
*/
100102
@Composable
101103
fun OnboardingFlowScreen(
@@ -115,17 +117,14 @@ fun OnboardingFlowScreen(
115117
@Composable
116118
private fun CompleteExistingUserOnboarding() {
117119
val navigator = LocalCodeNavigator.current
118-
val userManager = LocalUserManager.current!!
119-
val userState by userManager.state.collectAsStateWithLifecycle()
120-
val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
121120

122121
LaunchedEffect(Unit) {
123-
val route = resolvePostAccountRoute(
124-
result = OnboardingResult.ProceedToVerification,
125-
hasLinkedPhone = hasLinkedPhone,
126-
skipContacts = true,
122+
navigator.replace(
123+
AppRoute.OnboardingFlow(
124+
phase = AppRoute.OnboardingFlow.Phase.Permissions,
125+
skipContacts = true,
126+
)
127127
)
128-
route?.let { navigator.replace(it) }
129128
}
130129
}
131130

@@ -306,7 +305,23 @@ private fun LoginStepContent(seed: String?) {
306305
LaunchedEffect(vm) {
307306
vm.eventFlow
308307
.filterIsInstance<LoginViewModel.Event.OnAccountCreated>()
309-
.onEach { flowNavigator.navigateTo(OnboardingStep.AccessKey) }
308+
.onEach {
309+
if (state.needsPhoneVerification) {
310+
flowNavigator.navigate(
311+
AppRoute.Verification(
312+
origin = AppRoute.OnboardingFlow(),
313+
includePhone = true,
314+
includeEmail = false,
315+
target = AppRoute.OnboardingFlow(
316+
resumeAt = AppRoute.OnboardingFlow.ResumePoint.AccessKey,
317+
),
318+
fullScreen = true,
319+
)
320+
)
321+
} else {
322+
flowNavigator.navigateTo(OnboardingStep.AccessKey)
323+
}
324+
}
310325
.launchIn(this)
311326
}
312327

@@ -402,8 +417,6 @@ private fun AccessKeyStepContent() {
402417
AppBarWithTitle(
403418
title = stringResource(R.string.title_accessKey),
404419
titleAlignment = Alignment.CenterHorizontally,
405-
backButton = true,
406-
onBackIconClicked = { flowNavigator.back() },
407420
)
408421
AccessKeyScreen(
409422
viewModel = viewModel,
@@ -415,6 +428,8 @@ private fun AccessKeyStepContent() {
415428
flowNavigator.exitWithResult(OnboardingResult.ProceedToVerification)
416429
}
417430
}
431+
432+
BackHandler { /* swallow back during onboarding permissions */ }
418433
}
419434
}
420435

@@ -432,12 +447,12 @@ private fun PurchaseStepContent() {
432447
}
433448

434449
Column {
435-
AppBarWithTitle(
436-
backButton = true,
437-
onBackIconClicked = { flowNavigator.back() },
438-
)
450+
AppBarWithTitle()
439451
PurchaseAccountScreenContent(state, viewModel::dispatchEvent)
440452
}
453+
454+
455+
BackHandler { /* swallow back during onboarding permissions */ }
441456
}
442457

443458
@Composable
@@ -458,7 +473,7 @@ private fun ContactPermissionStepContent() {
458473
}
459474

460475
ContactScreenContent(
461-
permissionState = permissionState,
476+
accessHandle = permissionState.asContactAccessHandle(),
462477
onSkip = {
463478
analytics.action(Button.SkipContacts)
464479
flowNavigator.proceed()

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ import androidx.lifecycle.viewModelScope
44
import com.flipcash.app.analytics.Button
55
import com.flipcash.app.analytics.FlipcashAnalyticsService
66
import com.flipcash.app.auth.AuthManager
7+
import com.flipcash.app.featureflags.FeatureFlag
8+
import com.flipcash.app.featureflags.FeatureFlagController
79
import com.flipcash.features.login.R
810
import com.flipcash.services.controllers.AccountController
11+
import com.flipcash.services.user.UserManager
912
import com.getcode.manager.BottomBarManager
1013
import com.getcode.util.resources.ResourceHelper
1114
import com.getcode.utils.encodeBase64
1215
import com.flipcash.libs.coroutines.DispatcherProvider
1316
import com.getcode.view.BaseViewModel
1417
import com.getcode.view.LoadingSuccessState
1518
import dagger.hilt.android.lifecycle.HiltViewModel
19+
import kotlinx.coroutines.flow.combine
1620
import kotlinx.coroutines.flow.filter
1721
import kotlinx.coroutines.flow.filterIsInstance
1822
import kotlinx.coroutines.flow.filterNot
@@ -29,6 +33,8 @@ class LoginViewModel @Inject constructor(
2933
private val accounts: AccountController,
3034
private val resources: ResourceHelper,
3135
private val analytics: FlipcashAnalyticsService,
36+
userManager: UserManager,
37+
featureFlags: FeatureFlagController,
3238
dispatchers: DispatcherProvider,
3339
) : BaseViewModel<LoginViewModel.State, LoginViewModel.Event>(
3440
initialState = State(),
@@ -40,6 +46,7 @@ class LoginViewModel @Inject constructor(
4046
val loggingIn: LoadingSuccessState = LoadingSuccessState(),
4147
val logoTapCount: Int = 0,
4248
val betaOptionsVisible: Boolean = false,
49+
val needsPhoneVerification: Boolean = false,
4350
)
4451

4552
sealed interface Event {
@@ -53,9 +60,21 @@ class LoginViewModel @Inject constructor(
5360
data object LogInFailed : Event
5461
data object OnAccountCreated : Event
5562
data object CreateFailed : Event
63+
data class PhoneVerificationUpdated(val needed: Boolean) : Event
5664
}
5765

5866
init {
67+
combine(
68+
userManager.state,
69+
featureFlags.observe(FeatureFlag.PhoneNumberSend),
70+
) { userState, phoneNumberSendFlag ->
71+
val enabled = phoneNumberSendFlag || userState.flags?.enablePhoneNumberSend == true
72+
val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
73+
enabled && !hasLinkedPhone
74+
}.onEach { needed ->
75+
dispatchEvent(Event.PhoneVerificationUpdated(needed))
76+
}.launchIn(viewModelScope)
77+
5978
eventFlow
6079
.filterIsInstance<Event.OnLogoTapped>()
6180
.map { stateFlow.value.logoTapCount }
@@ -188,6 +207,10 @@ class LoginViewModel @Inject constructor(
188207
)
189208
)
190209
}
210+
211+
is Event.PhoneVerificationUpdated -> { state ->
212+
state.copy(needsPhoneVerification = event.needed)
213+
}
191214
}
192215
}
193216
}

apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package com.flipcash.app.login.router
33
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
44
import com.flipcash.app.analytics.FlipcashAnalyticsService
55
import com.flipcash.app.auth.AuthManager
6+
import com.flipcash.app.featureflags.FeatureFlagController
67
import com.flipcash.app.core.MainCoroutineRule
78
import com.flipcash.app.core.dispatchers.TestDispatchers
89
import com.flipcash.features.login.R
910
import com.flipcash.services.controllers.AccountController
11+
import com.flipcash.services.user.UserManager
1012
import com.getcode.manager.BottomBarManager
1113
import com.getcode.util.resources.ResourceHelper
1214
import io.mockk.every
@@ -41,6 +43,8 @@ class LoginViewModelErrorTest {
4143
// MockK for everything else
4244
private val resources: ResourceHelper = mockk(relaxed = true)
4345
private val analytics: FlipcashAnalyticsService = mockk(relaxed = true)
46+
private val userManager: UserManager = mockk(relaxed = true)
47+
private val featureFlags: FeatureFlagController = mockk(relaxed = true)
4448

4549
private lateinit var dispatchers: TestDispatchers
4650

@@ -68,6 +72,8 @@ class LoginViewModelErrorTest {
6872
accounts = accounts,
6973
resources = resources,
7074
analytics = analytics,
75+
userManager = userManager,
76+
featureFlags = featureFlags,
7177
dispatchers = dispatchers,
7278
)
7379
}

services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ class ContactVerificationController @Inject constructor(
2727
val owner = userManager.accountCluster?.authority?.keyPair
2828
?: return Result.failure(Throwable("No account cluster in UserManager"))
2929

30-
return repository.unlink(method, owner)
30+
return repository.unlink(method, owner).onSuccess {
31+
val profile = userManager.profile ?: return@onSuccess
32+
val updated = when (method) {
33+
is ContactMethod.Phone -> profile.copy(verifiedPhoneNumber = null)
34+
is ContactMethod.Email -> profile.copy(verifiedEmailAddress = null)
35+
}
36+
userManager.set(updated)
37+
}
3138
}
3239
}

0 commit comments

Comments
 (0)