Skip to content

Commit 3abc328

Browse files
committed
feat(onramp): make Coinbase email verification optional
Email entry is always required for the Coinbase add-money flow, but the requireCoinbaseEmailVerification flag now only controls verification. When it is off, the entered email skips the server round-trip and is persisted locally on the profile as unverified, then used for the Coinbase order; when on, the existing server verification flow runs. The synthetic phone@flipcash.com fallback is removed. To support this, profile phone/email are modeled as VerifiableContactMethod (value + verified) with verifiedPhoneNumber/verifiedEmailAddress kept as derived accessors; ProfileController preserves locally-entered unverified contacts across refreshes; and the user profile screen surfaces unverified contacts with a verified/unverified status badge. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 9c4db30 commit 3abc328

25 files changed

Lines changed: 489 additions & 124 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="960"
5+
android:viewportHeight="960">
6+
<path
7+
android:pathData="M160,320h640v-80L160,240v80ZM80,240q0,-33 23.5,-56.5T160,160h640q33,0 56.5,23.5T880,240v240L160,480v240h164v80L160,800q-33,0 -56.5,-23.5T80,720v-480ZM598,880 L428,710l56,-56 114,112 226,-226 56,58L598,880ZM160,240v480,-180 113,-413Z"
8+
android:fillColor="#e8eaed"/>
9+
</vector>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@
360360
<string name="action_addPhoneNumber">Add Phone Number</string>
361361
<string name="action_addEmailAddress">Add Email Address</string>
362362
<string name="subtitle_linkedForPayments">Linked for payments</string>
363+
<string name="label_verified">Verified</string>
364+
<string name="label_unverified">Unverified</string>
363365
<string name="prompt_title_unlinkPhone">Unlink Phone Number?</string>
364366
<string name="prompt_description_unlinkPhone">Your phone number will be removed from your profile.</string>
365367
<string name="prompt_title_unlinkEmail">Unlink Email Address?</string>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ dependencies {
1717
implementation(project(":apps:flipcash:shared:analytics"))
1818
implementation(project(":apps:flipcash:shared:featureflags"))
1919
implementation(project(":apps:flipcash:shared:phone"))
20+
implementation(project(":apps:flipcash:shared:userflags"))
21+
2022
implementation(project(":libs:messaging"))
2123
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,14 @@ fun EmailVerificationContent(
7575
}
7676
}.launchIn(this)
7777
}
78+
79+
LaunchedEffect(viewModel) {
80+
viewModel.eventFlow
81+
.filterIsInstance<EmailVerificationViewModel.Event.OnEntrySaved>()
82+
.onEach {
83+
keyboard.hideIfVisible {
84+
flowNavigator.exitWithResult(VerificationResult.Success)
85+
}
86+
}.launchIn(this)
87+
}
7888
}

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
66
import com.flipcash.app.core.verification.email.EmailCodeChannel
77
import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin
88
import com.flipcash.app.core.extensions.onResult
9+
import com.flipcash.app.userflags.UserFlagsCoordinator
910
import com.getcode.opencode.utils.base64
1011
import kotlinx.serialization.encodeToString
1112
import kotlinx.serialization.json.Json
@@ -45,6 +46,7 @@ class EmailVerificationViewModel @Inject constructor(
4546
private val resources: ResourceHelper,
4647
private val dispatchers: DispatcherProvider,
4748
private val emailCodeChannel: EmailCodeChannel,
49+
private val userFlags: UserFlagsCoordinator,
4850
) : BaseViewModel<EmailVerificationViewModel.State, EmailVerificationViewModel.Event>(
4951
initialState = State(),
5052
updateStateForEvent = updateStateForEvent,
@@ -66,6 +68,8 @@ class EmailVerificationViewModel @Inject constructor(
6668
data class OnOriginSet(val origin: EmailDeeplinkOrigin?) : Event
6769
data class OnDataProvided(val email: String?, val code: String?) : Event
6870
data object OnSendCodeClicked : Event
71+
/** Emitted in skip-verification mode once the entered email is persisted locally. */
72+
data object OnEntrySaved : Event
6973
data object OnResendCodeClicked: Event
7074
data class OnSendingCodeChanged(
7175
val loading: Boolean = false,
@@ -111,11 +115,27 @@ class EmailVerificationViewModel @Inject constructor(
111115

112116
eventFlow
113117
.filterIsInstance<Event.OnSendCodeClicked>()
114-
.map {
118+
.onEach {
115119
val emailAddress = stateFlow.value.email.text.toString()
116-
ContactMethod.Email(emailAddress, computeClientData())
117-
}.onEach { handleSendVerificationCode(it) }
118-
.launchIn(viewModelScope)
120+
val requiresVerification = userFlags.resolvedFlags.value
121+
.requireCoinbaseEmailVerification.effectiveValue
122+
123+
if (!requiresVerification) {
124+
dispatchEvent(Event.OnSendingCodeChanged(loading = true))
125+
viewModelScope.launch {
126+
delay(1.seconds)
127+
dispatchEvent(Event.OnSendingCodeChanged(success = true))
128+
delay(1.seconds)
129+
// Skip server verification: record the email locally as unverified
130+
// and complete. The profile write is persisted by ProfileCoordinator.
131+
verificationController.setLocalUnverified(ContactMethod.Email(emailAddress))
132+
dispatchEvent(Event.OnEntrySaved)
133+
dispatchEvent(Event.OnSendingCodeChanged())
134+
}
135+
} else {
136+
handleSendVerificationCode(ContactMethod.Email(emailAddress, computeClientData()))
137+
}
138+
}.launchIn(viewModelScope)
119139

120140
eventFlow
121141
.filterIsInstance<Event.OnResendCodeClicked>()
@@ -270,6 +290,7 @@ class EmailVerificationViewModel @Inject constructor(
270290
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
271291
when (event) {
272292
is Event.OnOriginSet -> { state -> state }
293+
Event.OnEntrySaved -> { state -> state }
273294
is Event.OnDataProvided -> { state ->
274295
state.copy(
275296
email = TextFieldState(event.email ?: state.email.text.toString()),

apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt

Lines changed: 121 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,25 @@ import androidx.compose.foundation.background
44
import androidx.compose.foundation.border
55
import androidx.compose.foundation.clickable
66
import androidx.compose.foundation.layout.Arrangement
7+
import androidx.compose.foundation.layout.Box
78
import androidx.compose.foundation.layout.Column
9+
import androidx.compose.foundation.layout.IntrinsicSize
810
import androidx.compose.foundation.layout.PaddingValues
911
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.aspectRatio
13+
import androidx.compose.foundation.layout.fillMaxHeight
1014
import androidx.compose.foundation.layout.fillMaxSize
1115
import androidx.compose.foundation.layout.fillMaxWidth
16+
import androidx.compose.foundation.layout.height
1217
import androidx.compose.foundation.layout.padding
1318
import androidx.compose.foundation.layout.requiredSize
1419
import androidx.compose.foundation.layout.size
1520
import androidx.compose.foundation.lazy.LazyColumn
1621
import androidx.compose.foundation.lazy.items
22+
import androidx.compose.foundation.shape.CircleShape
1723
import androidx.compose.material.icons.Icons
1824
import androidx.compose.material.icons.filled.Add
25+
import androidx.compose.material.icons.filled.Check
1926
import androidx.compose.material.icons.filled.ContentCopy
2027
import androidx.compose.material3.HorizontalDivider
2128
import androidx.compose.material3.Icon
@@ -28,6 +35,8 @@ import androidx.compose.ui.graphics.Color
2835
import androidx.compose.ui.graphics.Shape
2936
import androidx.compose.ui.graphics.RectangleShape
3037
import androidx.compose.foundation.shape.RoundedCornerShape
38+
import androidx.compose.ui.graphics.vector.ImageVector
39+
import androidx.compose.ui.res.painterResource
3140
import androidx.compose.ui.res.stringResource
3241
import androidx.compose.ui.text.font.FontWeight.Companion.W600
3342
import androidx.compose.ui.text.style.TextOverflow
@@ -154,52 +163,67 @@ internal fun UserProfileScreenContent(
154163
// Phone section
155164
item(contentType = "section_header") { SectionHeader(stringResource(R.string.title_sectionPhone)) }
156165
item(contentType = "contact_method") {
157-
if (state.phoneNumber != null) {
158-
SwipeActionRow(
166+
val phone = state.phone
167+
when {
168+
phone == null -> CardRow {
169+
AddContactMethodRow(
170+
label = stringResource(R.string.action_addPhoneNumber),
171+
onClick = { dispatch(UserProfileViewModel.Event.ConnectPhoneClicked) },
172+
)
173+
}
174+
// Only verified contacts can be unlinked from the server.
175+
phone.verified -> SwipeActionRow(
159176
onDelete = { dispatch(UserProfileViewModel.Event.UnlinkPhoneClicked) },
160-
stateKey = state.phoneNumber,
177+
stateKey = phone.value,
161178
resetOnDismiss = true,
162179
) {
163180
ContactMethodRow(
164-
value = state.phoneNumber,
165-
subtitle = if (state.phoneLinkedForPayment) {
166-
stringResource(R.string.subtitle_linkedForPayments)
167-
} else null,
181+
value = phone.value,
182+
verified = true,
183+
linkedForPayment = state.phoneLinkedForPayment,
168184
onRowClick = { dispatch(UserProfileViewModel.Event.ReplacePhoneClicked) },
169185
)
170186
}
171-
} else {
172-
CardRow {
173-
AddContactMethodRow(
174-
label = stringResource(R.string.action_addPhoneNumber),
175-
onClick = { dispatch(UserProfileViewModel.Event.ConnectPhoneClicked) },
176-
)
177-
}
187+
else -> ContactMethodRow(
188+
value = phone.value,
189+
verified = false,
190+
linkedForPayment = state.phoneLinkedForPayment,
191+
onRowClick = { dispatch(UserProfileViewModel.Event.ReplacePhoneClicked) },
192+
)
178193
}
179194
}
180195

181196
// Email section
182197
item(contentType = "section_header") { SectionHeader(stringResource(R.string.title_sectionEmail)) }
183198
item(contentType = "contact_method") {
184-
if (state.emailAddress != null) {
185-
SwipeActionRow(
199+
val email = state.email
200+
when {
201+
email == null -> CardRow {
202+
AddContactMethodRow(
203+
label = stringResource(R.string.action_addEmailAddress),
204+
onClick = { dispatch(UserProfileViewModel.Event.ConnectEmailClicked) },
205+
)
206+
}
207+
208+
email.verified -> SwipeActionRow(
186209
onDelete = { dispatch(UserProfileViewModel.Event.UnlinkEmailClicked) },
187-
stateKey = state.emailAddress,
210+
stateKey = email.value,
188211
resetOnDismiss = true,
189212
) {
190213
ContactMethodRow(
191-
value = state.emailAddress,
192-
subtitle = null,
214+
value = email.value,
215+
verified = true,
216+
linkedForPayment = false,
193217
onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) },
194218
)
195219
}
196-
} else {
197-
CardRow {
198-
AddContactMethodRow(
199-
label = stringResource(R.string.action_addEmailAddress),
200-
onClick = { dispatch(UserProfileViewModel.Event.ConnectEmailClicked) },
201-
)
202-
}
220+
221+
else -> ContactMethodRow(
222+
value = email.value,
223+
verified = false,
224+
linkedForPayment = false,
225+
onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) },
226+
)
203227
}
204228
}
205229

@@ -278,7 +302,8 @@ private fun ProfileValueRow(value: String) {
278302
@Composable
279303
private fun ContactMethodRow(
280304
value: String,
281-
subtitle: String?,
305+
verified: Boolean,
306+
linkedForPayment: Boolean,
282307
onRowClick: () -> Unit,
283308
) {
284309
Row(
@@ -291,26 +316,82 @@ private fun ContactMethodRow(
291316
),
292317
verticalAlignment = Alignment.CenterVertically,
293318
) {
294-
Column(
319+
Text(
295320
modifier = Modifier.weight(1f),
296-
verticalArrangement = Arrangement.Center,
321+
text = value,
322+
style = CodeTheme.typography.textMedium,
323+
color = CodeTheme.colors.textMain,
324+
)
325+
// Match the linked-payment icon's height to the status badge (the badge's
326+
// natural height drives the row via IntrinsicSize.Min).
327+
Row(
328+
modifier = Modifier.height(IntrinsicSize.Min),
329+
verticalAlignment = Alignment.CenterVertically,
330+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
297331
) {
298-
Text(
299-
text = value,
300-
style = CodeTheme.typography.textMedium,
301-
color = CodeTheme.colors.textMain,
302-
)
303-
if (subtitle != null) {
304-
Text(
305-
text = subtitle,
306-
style = CodeTheme.typography.textSmall,
307-
color = CodeTheme.colors.textSecondary,
308-
)
332+
if (linkedForPayment) {
333+
Box(
334+
modifier = Modifier
335+
.fillMaxHeight()
336+
.aspectRatio(1f)
337+
.clip(CircleShape)
338+
.background(CodeTheme.colors.success.copy(alpha = 0.15f)),
339+
contentAlignment = Alignment.Center,
340+
) {
341+
Icon(
342+
painter = painterResource(R.drawable.ic_linked_payment_method),
343+
contentDescription = stringResource(R.string.subtitle_linkedForPayments),
344+
tint = CodeTheme.colors.success,
345+
modifier = Modifier.size(16.dp),
346+
)
347+
}
309348
}
349+
StatusBadge(
350+
label = if (verified) {
351+
stringResource(R.string.label_verified)
352+
} else {
353+
stringResource(R.string.label_unverified)
354+
},
355+
color = if (verified) CodeTheme.colors.success else CodeTheme.colors.warning,
356+
icon = if (verified) Icons.Default.Check else null,
357+
)
310358
}
311359
}
312360
}
313361

362+
@Composable
363+
private fun StatusBadge(
364+
label: String,
365+
color: Color,
366+
icon: ImageVector? = null,
367+
) {
368+
Row(
369+
modifier = Modifier
370+
.clip(CircleShape)
371+
.background(color.copy(alpha = 0.15f))
372+
.padding(
373+
horizontal = CodeTheme.dimens.grid.x2,
374+
vertical = CodeTheme.dimens.grid.x1,
375+
),
376+
verticalAlignment = Alignment.CenterVertically,
377+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
378+
) {
379+
if (icon != null) {
380+
Icon(
381+
imageVector = icon,
382+
contentDescription = null,
383+
tint = color,
384+
modifier = Modifier.size(14.dp),
385+
)
386+
}
387+
Text(
388+
text = label,
389+
style = CodeTheme.typography.caption,
390+
color = color,
391+
)
392+
}
393+
}
394+
314395
@Composable
315396
private fun SocialAccountRow(
316397
account: SocialAccount.TwitterX,

0 commit comments

Comments
 (0)