Skip to content

Commit 5d585a4

Browse files
authored
feat(phone): dispatch LinkForPayment on verification and onboarding complete (#780)
* feat(phone): update protos and scaffold LinkForPayment RPC Update flipcash protobuf definitions and add service layer stubs for the new LinkForPayment RPC which links a verified phone number for payment. Signed-off-by: Brandon McAnsh <git@bmcreations.dev> * feat(phone): dispatch LinkForPayment on verification and onboarding complete Wire up the LinkForPayment RPC so it fires in two cases: - After phone verification from the send flow (via linkForPayment flag on Verification route) - After onboarding completes (via OnboardingViewModel) Also fix a bug where phone verification was shown during onboarding even when the phone-number-send feature flag was disabled — resolvePostAccountRoute now checks phoneNumberSendEnabled before routing to verification. Signed-off-by: Brandon McAnsh <git@bmcreations.dev> --------- Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent c3edd19 commit 5d585a4

16 files changed

Lines changed: 180 additions & 85 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
@@ -119,6 +119,7 @@ sealed interface AppRoute : NavKey, Parcelable {
119119
val emailVerificationCode: String? = null,
120120
val target: AppRoute? = null,
121121
val fullScreen: Boolean = false,
122+
val linkForPayment: Boolean = false,
122123
) : AppRoute, FlowRouteWithResult<VerificationResult> {
123124
override val initialStack: List<NavKey>
124125
get() = buildVerificationInitialStack(

apps/flipcash/features/contact-verification/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies {
1515
implementation(libs.bundles.kotlinx.serialization)
1616

1717
implementation(project(":apps:flipcash:shared:analytics"))
18+
implementation(project(":apps:flipcash:shared:featureflags"))
1819
implementation(project(":apps:flipcash:shared:phone"))
1920
implementation(project(":libs:messaging"))
2021
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ private fun verificationEntryProvider(
6666
PhoneVerificationContent(isInModal = !route.fullScreen)
6767
}
6868
annotatedEntry<VerificationStep.PhoneCode> {
69-
PhoneCodeContent(includeEmail = route.includeEmail, isInModal = !route.fullScreen)
69+
PhoneCodeContent(
70+
includeEmail = route.includeEmail,
71+
isInModal = !route.fullScreen,
72+
linkForPayment = route.linkForPayment,
73+
)
7074
}
7175
annotatedEntry<VerificationStep.PhoneCountryCode> {
7276
PhoneCountryCodeContent(isInModal = !route.fullScreen)

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import androidx.compose.foundation.text.input.TextFieldState
44
import androidx.compose.runtime.snapshotFlow
55
import androidx.lifecycle.viewModelScope
66
import com.flipcash.app.core.extensions.onResult
7+
import com.flipcash.app.featureflags.FeatureFlag
8+
import com.flipcash.app.featureflags.FeatureFlagController
79
import com.flipcash.app.phone.CountryLocale
810
import com.flipcash.app.phone.PhoneUtils
911
import com.flipcash.features.contact.verification.R
1012
import com.flipcash.libs.coroutines.DispatcherProvider
1113
import com.flipcash.services.controllers.ContactVerificationController
1214
import com.flipcash.services.controllers.ProfileController
1315
import com.flipcash.services.models.ContactMethod
16+
import com.flipcash.services.user.UserManager
1417
import com.flipcash.services.models.PhoneVerificationError
1518
import com.getcode.manager.BottomBarManager
1619
import com.getcode.util.resources.ResourceHelper
@@ -19,6 +22,7 @@ import com.getcode.view.LoadingSuccessState
1922
import dagger.hilt.android.lifecycle.HiltViewModel
2023
import kotlinx.coroutines.delay
2124
import kotlinx.coroutines.flow.distinctUntilChanged
25+
import kotlinx.coroutines.flow.filter
2226
import kotlinx.coroutines.flow.filterIsInstance
2327
import kotlinx.coroutines.flow.flatMapLatest
2428
import kotlinx.coroutines.flow.launchIn
@@ -37,6 +41,8 @@ internal class PhoneVerificationViewModel @Inject constructor(
3741
private val phoneUtils: PhoneUtils,
3842
private val verificationController: ContactVerificationController,
3943
private val profileController: ProfileController,
44+
private val userManager: UserManager,
45+
private val featureFlags: FeatureFlagController,
4046
private val resources: ResourceHelper,
4147
private val dispatchers: DispatcherProvider,
4248
) : BaseViewModel<PhoneVerificationViewModel.State, PhoneVerificationViewModel.Event>(
@@ -88,6 +94,7 @@ internal class PhoneVerificationViewModel @Inject constructor(
8894

8995
data object OnVerifyCodeClicked : Event
9096
data object OnCodeVerified : Event
97+
data object LinkForPayment : Event
9198

9299
data object OnMaxAttemptsReached : Event
93100
}
@@ -196,6 +203,21 @@ internal class PhoneVerificationViewModel @Inject constructor(
196203
)
197204
}
198205
).launchIn(viewModelScope)
206+
207+
eventFlow
208+
.filterIsInstance<Event.LinkForPayment>()
209+
.filter {
210+
featureFlags.observe(FeatureFlag.PhoneNumberSend).value ||
211+
userManager.state.value.flags?.enablePhoneNumberSend == true
212+
}
213+
.map {
214+
val number = stateFlow.value.numberTextFieldState.text.toString()
215+
val locale = stateFlow.value.selectedLocale
216+
val cleanedNumber = phoneUtils.cleanNumber(number, locale)
217+
ContactMethod.Phone(cleanedNumber)
218+
}
219+
.map { verificationController.linkForPayment(it) }
220+
.launchIn(viewModelScope)
199221
}
200222

201223
private suspend fun handleSendVerificationCode(method: ContactMethod) {
@@ -296,6 +318,7 @@ internal class PhoneVerificationViewModel @Inject constructor(
296318
}
297319
Event.OnVerifyCodeClicked -> { state -> state }
298320
Event.OnCodeVerified -> { state -> state }
321+
Event.LinkForPayment -> { state -> state }
299322
is Event.OnPhoneNumberFormatted -> { state -> state.copy(formattedPhone = event.formatted) }
300323
Event.OnSendCodeClicked -> { state -> state.copy(attempts = state.attempts + 1) }
301324
Event.OnMaxAttemptsReached -> { state -> state }

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.onEach
2525
fun PhoneCodeContent(
2626
includeEmail: Boolean,
2727
isInModal: Boolean = true,
28+
linkForPayment: Boolean = false,
2829
) {
2930
val flowNavigator = rememberFlowNavigator<VerificationStep, VerificationResult>()
3031
val viewModel = flowSharedViewModel<PhoneVerificationViewModel>()
@@ -55,10 +56,13 @@ fun PhoneCodeContent(
5556
.launchIn(this)
5657
}
5758

58-
LaunchedEffect(viewModel, includeEmail) {
59+
LaunchedEffect(viewModel, includeEmail, linkForPayment) {
5960
viewModel.eventFlow
6061
.filterIsInstance<PhoneVerificationViewModel.Event.OnCodeVerified>()
6162
.onEach {
63+
if (linkForPayment) {
64+
viewModel.dispatchEvent(PhoneVerificationViewModel.Event.LinkForPayment)
65+
}
6266
if (includeEmail) {
6367
flowNavigator.navigateTo(VerificationStep.EmailEntry)
6468
} else {

apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.flipcash.app.contact.verification.internal.phone
22

3+
import com.flipcash.app.featureflags.FeatureFlagController
34
import com.flipcash.app.phone.PhoneUtils
45
import com.flipcash.features.contact.verification.R
56
import com.flipcash.services.controllers.ContactVerificationController
67
import com.flipcash.services.controllers.ProfileController
78
import com.flipcash.services.models.PhoneVerificationError
9+
import com.flipcash.services.user.UserManager
810
import com.getcode.manager.BottomBarManager
911
import com.getcode.util.resources.ResourceHelper
1012
import com.flipcash.app.core.MainCoroutineRule
@@ -34,6 +36,8 @@ class PhoneVerificationViewModelErrorTest {
3436
// Mockito for Result-returning methods (MockK double-boxes Result inline class)
3537
private val verificationController: ContactVerificationController = mock()
3638
private val profileController = mockk<ProfileController>(relaxed = true)
39+
private val userManager = mockk<UserManager>(relaxed = true)
40+
private val featureFlags = mockk<FeatureFlagController>(relaxed = true)
3741
private val resources = mockk<ResourceHelper>(relaxed = true)
3842

3943
private lateinit var dispatchers: TestDispatchers
@@ -62,6 +66,8 @@ class PhoneVerificationViewModelErrorTest {
6266
phoneUtils = phoneUtils,
6367
verificationController = verificationController,
6468
profileController = profileController,
69+
userManager = userManager,
70+
featureFlags = featureFlags,
6571
resources = resources,
6672
dispatchers = dispatchers,
6773
)

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

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.flipcash.app.featureflags.LocalFeatureFlags
3333
import com.flipcash.app.core.onboarding.OnboardingResult
3434
import com.flipcash.app.core.onboarding.OnboardingStep
3535
import com.flipcash.app.login.internal.LoginAccessKeyViewModel
36+
import com.flipcash.app.login.internal.OnboardingViewModel
3637
import com.flipcash.app.login.internal.screens.PhotoAccessKeyScreen
3738
import com.flipcash.app.login.internal.screens.AccessKeyScreen
3839
import com.flipcash.app.login.internal.screens.LoginRouterScreenContent
@@ -73,13 +74,13 @@ import kotlinx.coroutines.flow.onEach
7374
* ```
7475
* 1. New account (ResumePoint.Login → ProceedToVerification)
7576
*
76-
* Start → Verification³ → AccessKey ──┬──→ Contacts¹ → Notifications → Scanner
77+
* Start → Verification² → AccessKey ──┬──────────────→ Contacts¹ → Notifications → Scanner
7778
* └→ Purchase ─┘
7879
*
7980
* 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput)
8081
*
81-
* Start → SeedInput ──┬──────────────────────→ Contacts¹ → Notifications → Scanner
82-
* └→ Purchase → Verification² ─┘
82+
* Start → SeedInput ──┬──────────────→ Contacts¹ → Notifications → Scanner
83+
* └→ Purchase ─┘
8384
*
8485
* 3. App resume (ResumePoint.PostAccessKey)
8586
*
@@ -95,9 +96,9 @@ import kotlinx.coroutines.flow.onEach
9596
* **and** [FeatureFlag.ContactPickerMode] is off. When ContactPickerMode is on,
9697
* contacts are accessed via the system picker at call site (no READ_CONTACTS needed).
9798
* Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost].
98-
* ² 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.
99+
* ² Phone verification is shown only when [FeatureFlag.PhoneNumberSend] is enabled
100+
* and no phone is linked. Skipped entirely when the flag is off.
101+
* Uses `target` to replace the nav stack with AccessKey on success.
101102
*/
102103
@Composable
103104
fun OnboardingFlowScreen(
@@ -134,6 +135,7 @@ private fun PermissionsPhaseFlowHost(
134135
resultStateRegistry: NavResultStateRegistry,
135136
) {
136137
val outerNavigator = LocalCodeNavigator.current
138+
val onboardingViewModel = hiltViewModel<OnboardingViewModel>()
137139
val checker = LocalPermissionChecker.current
138140
val contactConfig = PermissionConfigs.contacts()
139141
val notificationConfig = PermissionConfigs.notifications()
@@ -178,6 +180,7 @@ private fun PermissionsPhaseFlowHost(
178180
when (reason) {
179181
is FlowExitReason.Completed -> {
180182
analytics.action(Action.CompletedOnboarding)
183+
onboardingViewModel.linkPhoneForPayment()
181184
outerNavigator.navigate(
182185
route = AppRoute.Main.Scanner,
183186
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),
@@ -186,6 +189,7 @@ private fun PermissionsPhaseFlowHost(
186189

187190
FlowExitReason.BackedOutOfRoot -> {
188191
// All permissions already granted
192+
onboardingViewModel.linkPhoneForPayment()
189193
outerNavigator.navigate(
190194
route = AppRoute.Main.Scanner,
191195
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll),
@@ -205,19 +209,16 @@ private fun AccountPhaseFlowHost(
205209
resultStateRegistry: NavResultStateRegistry,
206210
) {
207211
val outerNavigator = LocalCodeNavigator.current
208-
val userManager = LocalUserManager.current!!
209-
val userState by userManager.state.collectAsStateWithLifecycle()
210212

211213
val initialStack = route.rememberInitialStack<OnboardingStep>()
212214

213-
FlowHost<OnboardingStep, OnboardingResult>(
215+
FlowHost(
214216
initialStack = initialStack,
215217
resultStateRegistry = resultStateRegistry,
216218
onExit = { reason, _ ->
217219
when (reason) {
218220
is FlowExitReason.Completed -> {
219-
val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
220-
val route = resolvePostAccountRoute(reason.result, hasLinkedPhone)
221+
val route = resolvePostAccountRoute(reason.result)
221222
route?.let { outerNavigator.replace(it) }
222223
}
223224

@@ -231,27 +232,14 @@ private fun AccountPhaseFlowHost(
231232

232233
internal fun resolvePostAccountRoute(
233234
result: OnboardingResult,
234-
hasLinkedPhone: Boolean,
235235
skipContacts: Boolean = false,
236236
): AppRoute? {
237237
val permissionsRoute = AppRoute.OnboardingFlow(
238238
phase = AppRoute.OnboardingFlow.Phase.Permissions,
239239
skipContacts = skipContacts,
240240
)
241241
return when (result) {
242-
is OnboardingResult.ProceedToVerification -> {
243-
if (hasLinkedPhone) {
244-
permissionsRoute
245-
} else {
246-
AppRoute.Verification(
247-
origin = AppRoute.Onboarding.AccessKey,
248-
includePhone = true,
249-
includeEmail = false,
250-
target = permissionsRoute,
251-
fullScreen = true,
252-
)
253-
}
254-
}
242+
is OnboardingResult.ProceedToVerification -> permissionsRoute
255243
OnboardingResult.LoggedIn -> permissionsRoute
256244
OnboardingResult.Completed -> null
257245
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.flipcash.app.login.internal
2+
3+
import androidx.lifecycle.ViewModel
4+
import androidx.lifecycle.viewModelScope
5+
import com.flipcash.app.featureflags.FeatureFlag
6+
import com.flipcash.app.featureflags.FeatureFlagController
7+
import com.flipcash.services.controllers.ContactVerificationController
8+
import com.flipcash.services.models.ContactMethod
9+
import com.flipcash.services.user.UserManager
10+
import dagger.hilt.android.lifecycle.HiltViewModel
11+
import kotlinx.coroutines.launch
12+
import javax.inject.Inject
13+
14+
@HiltViewModel
15+
internal class OnboardingViewModel @Inject constructor(
16+
private val userManager: UserManager,
17+
private val featureFlags: FeatureFlagController,
18+
private val contactVerificationController: ContactVerificationController,
19+
) : ViewModel() {
20+
21+
fun linkPhoneForPayment() {
22+
val phone = userManager.profile?.verifiedPhoneNumber ?: return
23+
val enabled = featureFlags.observe(FeatureFlag.PhoneNumberSend).value ||
24+
userManager.state.value.flags?.enablePhoneNumberSend == true
25+
if (!enabled) return
26+
viewModelScope.launch {
27+
contactVerificationController.linkForPayment(ContactMethod.Phone(phone))
28+
}
29+
}
30+
}

0 commit comments

Comments
 (0)