From 5b05128837ed133dd2e5b4b50300457764f39488 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:43:40 -0400 Subject: [PATCH 01/17] feat(payments): add PaymentAction.Plain and Google Pay deposit-sheet icon Adds a Plain payment action variant rendering a plain Google Pay icon on the purchase-method sheet, used by the deposit ("Add Money") flow. Signed-off-by: Brandon McAnsh --- .../src/main/res/drawable/ic_google_pay.xml | 27 +++++++++++++++++++ .../flipcash/app/payments/PurchaseMethod.kt | 2 +- .../flipcash/app/payments/internal/Buttons.kt | 3 ++- .../InternalPurchaseMethodController.kt | 14 +++++----- 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_google_pay.xml diff --git a/apps/flipcash/core/src/main/res/drawable/ic_google_pay.xml b/apps/flipcash/core/src/main/res/drawable/ic_google_pay.xml new file mode 100644 index 000000000..d807b1835 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_google_pay.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt index 6f4515bef..038ae6987 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethod.kt @@ -4,7 +4,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint -enum class PaymentAction { Buy, Pay } +enum class PaymentAction { Buy, Pay, Plain } enum class PurchasePurpose { Buy, Deposit } sealed interface PurchaseMethod { diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt index a1ecfac6e..0cc358a72 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt @@ -46,6 +46,7 @@ internal fun purchaseOptions( iconRes = when (metadata.paymentAction) { PaymentAction.Buy -> R.drawable.ic_buy_with_google_pay PaymentAction.Pay -> R.drawable.ic_pay_with_google_pay + PaymentAction.Plain -> R.drawable.ic_google_pay }, width = 150.sp, height = 20.sp, @@ -81,7 +82,7 @@ internal fun purchaseOptions( if (state.canUseOtherWallets) { add( BottomBarAction( - text = resources.getString(R.string.action_depositUsdc), + text = resources.getString(R.string.title_onrampProviderOtherWallet), onClick = { onClick(PurchaseMethod.OtherWallet) } ) ) diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index edfd705a7..33a83e51e 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -1,10 +1,11 @@ package com.flipcash.app.payments.internal -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.payments.PaymentAction import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata @@ -30,10 +31,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -91,7 +91,6 @@ class InternalPurchaseMethodController @Inject constructor( userFlags.resolvedFlags .mapNotNull { it.preferredOnRampProvider.effectiveValue } - .filterIsInstance() .onEach { provider -> _state.update { it.copy(preferredProvider = provider) } }.launchIn(scope) @@ -119,7 +118,7 @@ class InternalPurchaseMethodController @Inject constructor( selected = true scope.launch { val selection = PurchaseMethodSelection(method, metadata) - delay(300) + delay(300.milliseconds) _selections.emit(selection) } }, @@ -138,6 +137,7 @@ class InternalPurchaseMethodController @Inject constructor( present(PurchaseMethodMetadata( mint = Mint.usdf, purpose = PurchasePurpose.Deposit, + paymentAction = PaymentAction.Plain, showReserves = false, canUseOtherWallets = true) ) @@ -167,10 +167,12 @@ class InternalPurchaseMethodController @Inject constructor( swapRoute } } + PurchaseMethod.PhantomWallet -> AppRoute.Token.Swap( purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Phantom), popToRoot = popToRoot, ) + PurchaseMethod.OtherWallet -> AppRoute.Transfers.Deposit(showOtherOptions = true) is PurchaseMethod.CashReserves -> null null -> null From 33066521650797a61919ca23b33ee38a7fcebc93 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:45:41 -0400 Subject: [PATCH 02/17] refactor(amount-entry): model action label as AmountEntryLabel sealed type Replaces AmountEntryStyle.actionLabel: String with a sealed AmountEntryLabel (Plain text, or Annotated for a composable label with inline content such as a wallet icon). Threads it through AmountEntryAction/Screen and updates all callers (cash, withdrawal, chat, swap). The swap buy-via-Phantom label uses the Annotated variant to render the Phantom icon. Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 2 +- .../app/cash/internal/CashScreenViewModel.kt | 3 +- .../app/messenger/internal/ChatViewModel.kt | 3 +- .../app/withdrawal/WithdrawalViewModel.kt | 3 +- .../shared/amountentry/AmountEntryAction.kt | 2 +- .../shared/amountentry/AmountEntryLabel.kt | 39 ++++++++++++++++++ .../shared/amountentry/AmountEntryScreen.kt | 9 ++-- .../shared/amountentry/AmountEntryStyle.kt | 2 +- .../amountentry/AmountEntryConfigTest.kt | 20 ++++----- .../amountentry/AmountEntryDelegateTest.kt | 38 ++++++++--------- .../amountentry/AmountEntryScreenTest.kt | 22 +++++----- .../flipcash/app/tokens/ui/SwapViewModel.kt | 41 ++++++++++++++++--- 12 files changed, 129 insertions(+), 55 deletions(-) create mode 100644 apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryLabel.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 6ef05b575..65778caa1 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -70,7 +70,7 @@ Deposit Deposit Funds Deposit Funds - + Add Money Withdraw Withdraw Funds diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index 0e87b4709..fe2a7bb3e 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -9,6 +9,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.cash.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -75,7 +76,7 @@ internal class CashScreenViewModel @Inject constructor( exchange = exchange, scope = viewModelScope, style = AmountEntryStyle( - actionLabel = resources.getString(R.string.action_next), + actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)), infoHint = { resources.getString(R.string.subtitle_giveCashHint, it) }, overMaxHint = { resources.getString(R.string.subtitle_giveCashHintLimitExceeded, it) }, ), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index d69f409d8..64c92f887 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -32,6 +32,7 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.TypingState import com.flipcash.services.user.UserManager import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.flipcash.shared.chat.ActiveTypist import com.flipcash.shared.chat.ChatCoordinator @@ -237,7 +238,7 @@ internal class ChatViewModel @Inject constructor( exchange = exchange, scope = viewModelScope, style = AmountEntryStyle( - actionLabel = resources.getString(R.string.action_swipeToSend), + actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_swipeToSend)), actionStyle = ConfirmationStyle.Slide, infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt index 5254a57f4..de37c5817 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt @@ -15,6 +15,7 @@ import com.flipcash.features.withdrawal.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -94,7 +95,7 @@ internal class WithdrawalViewModel @Inject constructor( exchange = exchange, scope = viewModelScope, style = AmountEntryStyle( - actionLabel = resources.getString(R.string.action_next), + actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)), infoHint = { resources.getString(R.string.subtitle_withdrawHint, it) }, overMaxHint = { resources.getString(R.string.subtitle_withdrawHintLimitExceeded, it) }, belowMinHint = { resources.getString(R.string.subtitle_withdrawHintMinimumNotMet, it) }, diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryAction.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryAction.kt index 57a22380a..8947f53a4 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryAction.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryAction.kt @@ -4,7 +4,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle import com.getcode.view.LoadingSuccessState data class AmountEntryAction( - val label: String, + val label: AmountEntryLabel, val style: ConfirmationStyle = ConfirmationStyle.Button, val loadingState: LoadingSuccessState = LoadingSuccessState(), ) diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryLabel.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryLabel.kt new file mode 100644 index 000000000..f828433b2 --- /dev/null +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryLabel.kt @@ -0,0 +1,39 @@ +package com.flipcash.shared.amountentry + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import com.flipcash.app.core.onramp.ui.AnnotatedButtonLabel + +/** + * Label for the amount-entry confirm action. + * + * [text] is the plain-text form used by the slide-to-confirm style and as a fallback, + * while [annotated] is the composition-built form used by the button style — it may + * include inline content such as a wallet icon. + */ +sealed interface AmountEntryLabel { + val text: String + + @Composable + fun annotated(enabled: Boolean): AnnotatedButtonLabel + + /** Simple text label with no inline content. */ + data class Plain(override val text: String) : AmountEntryLabel { + @Composable + override fun annotated(enabled: Boolean): AnnotatedButtonLabel = AnnotatedString(text) to emptyMap() + } + + /** + * Label with inline content (e.g. text + wallet icon). The [builder] is invoked in + * composition since [AnnotatedButtonLabel] needs stringResource/painterResource/theme. + * It receives the button's `enabled` state so inline icons can tint accordingly. + * [text] is a plain fallback used by non-annotated surfaces (e.g. slide-to-confirm). + */ + data class Annotated( + override val text: String, + val builder: @Composable (enabled: Boolean) -> AnnotatedButtonLabel, + ) : AmountEntryLabel { + @Composable + override fun annotated(enabled: Boolean): AnnotatedButtonLabel = builder(enabled) + } +} diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryScreen.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryScreen.kt index 70dfa92b2..85fdee4f5 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryScreen.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryScreen.kt @@ -40,13 +40,16 @@ fun AmountEntryScreen( ) { when (config.action.style) { ConfirmationStyle.Button -> { + val enabled = config.canConfirm && config.action.loadingState.isIdle + val (text, inlineContent) = config.action.label.annotated(enabled) CodeButton( - enabled = config.canConfirm && config.action.loadingState.isIdle, + enabled = enabled, modifier = Modifier.fillMaxWidth(), buttonState = ButtonState.Filled, isLoading = config.action.loadingState.loading, isSuccess = config.action.loadingState.success, - text = config.action.label, + text = text, + inlineContent = inlineContent, ) { onConfirm() } @@ -59,7 +62,7 @@ fun AmountEntryScreen( enabled = config.canConfirm && config.action.loadingState.isIdle, isLoading = config.action.loadingState.loading, isSuccess = config.action.loadingState.success, - label = config.action.label, + label = config.action.label.text, ) } } diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt index eae0c2661..ccde493bd 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt @@ -3,7 +3,7 @@ package com.flipcash.shared.amountentry import com.flipcash.app.core.ui.ConfirmationStyle data class AmountEntryStyle( - val actionLabel: String, + val actionLabel: AmountEntryLabel, val actionStyle: ConfirmationStyle = ConfirmationStyle.Button, val canChangeCurrency: Boolean = true, val infoHint: (maxFormatted: String) -> String = { "" }, diff --git a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryConfigTest.kt b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryConfigTest.kt index 8f0bff454..7df6902b4 100644 --- a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryConfigTest.kt +++ b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryConfigTest.kt @@ -12,7 +12,7 @@ class AmountEntryConfigTest { @Test fun `default config has no hint and cannot confirm`() { - val config = AmountEntryConfig(action = AmountEntryAction(label = "Go")) + val config = AmountEntryConfig(action = AmountEntryAction(label = AmountEntryLabel.Plain("Go"))) assertIs(config.hint) assertFalse(config.canConfirm) assertTrue(config.canChangeCurrency) @@ -22,7 +22,7 @@ class AmountEntryConfigTest { fun `config with info hint`() { val config = AmountEntryConfig( hint = AmountEntryHint.Info("Send up to \$100"), - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ) assertIs(config.hint) assertEquals("Send up to \$100", (config.hint as AmountEntryHint.Info).text) @@ -32,7 +32,7 @@ class AmountEntryConfigTest { fun `config with error hint`() { val config = AmountEntryConfig( hint = AmountEntryHint.Error("Limit exceeded"), - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ) assertIs(config.hint) assertEquals("Limit exceeded", (config.hint as AmountEntryHint.Error).text) @@ -40,7 +40,7 @@ class AmountEntryConfigTest { @Test fun `action defaults to Button style`() { - val action = AmountEntryAction(label = "Continue") + val action = AmountEntryAction(label = AmountEntryLabel.Plain("Continue")) assertEquals(ConfirmationStyle.Button, action.style) assertTrue(action.loadingState.isIdle) } @@ -48,7 +48,7 @@ class AmountEntryConfigTest { @Test fun `action with Slide style`() { val action = AmountEntryAction( - label = "Swipe to send", + label = AmountEntryLabel.Plain("Swipe to send"), style = ConfirmationStyle.Slide, ) assertEquals(ConfirmationStyle.Slide, action.style) @@ -57,7 +57,7 @@ class AmountEntryConfigTest { @Test fun `action loading state`() { val action = AmountEntryAction( - label = "Send", + label = AmountEntryLabel.Plain("Send"), loadingState = LoadingSuccessState(loading = true), ) assertTrue(action.loadingState.loading) @@ -68,7 +68,7 @@ class AmountEntryConfigTest { @Test fun `action success state`() { val action = AmountEntryAction( - label = "Send", + label = AmountEntryLabel.Plain("Send"), loadingState = LoadingSuccessState(success = true), ) assertTrue(action.loadingState.success) @@ -77,8 +77,8 @@ class AmountEntryConfigTest { @Test fun `style default values`() { - val style = AmountEntryStyle(actionLabel = "Buy") - assertEquals("Buy", style.actionLabel) + val style = AmountEntryStyle(actionLabel = AmountEntryLabel.Plain("Buy")) + assertEquals("Buy", style.actionLabel.text) assertEquals(ConfirmationStyle.Button, style.actionStyle) assertTrue(style.canChangeCurrency) assertEquals("", style.infoHint("anything")) @@ -89,7 +89,7 @@ class AmountEntryConfigTest { @Test fun `style with custom hints`() { val style = AmountEntryStyle( - actionLabel = "Send", + actionLabel = AmountEntryLabel.Plain("Send"), actionStyle = ConfirmationStyle.Slide, canChangeCurrency = false, infoHint = { "Up to $it available" }, diff --git a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt index 39c656d81..807d5846a 100644 --- a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt +++ b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt @@ -43,7 +43,7 @@ class AmountEntryDelegateTest { * while NOT blocking `runTest` completion (unlike `this` / TestScope). */ private fun TestScope.createDelegate( - style: AmountEntryStyle = AmountEntryStyle(actionLabel = "Next"), + style: AmountEntryStyle = AmountEntryStyle(actionLabel = AmountEntryLabel.Plain("Next")), loadingState: MutableStateFlow = MutableStateFlow(LoadingSuccessState()), maxAmount: MutableStateFlow = MutableStateFlow(null), minimumAmount: MutableStateFlow = MutableStateFlow(null), @@ -173,13 +173,13 @@ class AmountEntryDelegateTest { fun `config initial state uses style label and defaults`() = runTest { val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Send", + actionLabel = AmountEntryLabel.Plain("Send"), actionStyle = ConfirmationStyle.Slide, ), ) val config = delegate.config.value - assertEquals("Send", config.action.label) + assertEquals("Send", config.action.label.text) assertEquals(ConfirmationStyle.Slide, config.action.style) assertFalse(config.canConfirm) assertTrue(config.canChangeCurrency) @@ -208,7 +208,7 @@ class AmountEntryDelegateTest { @Test fun `config canChangeCurrency reflects style`() = runTest { val delegate = createDelegate( - style = AmountEntryStyle(actionLabel = "Buy", canChangeCurrency = false), + style = AmountEntryStyle(actionLabel = AmountEntryLabel.Plain("Buy"), canChangeCurrency = false), ) assertFalse(delegate.config.value.canChangeCurrency) @@ -223,7 +223,7 @@ class AmountEntryDelegateTest { val max = MutableStateFlow(Fiat(100.0, CurrencyCode.USD)) val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, ), @@ -242,7 +242,7 @@ class AmountEntryDelegateTest { val max = MutableStateFlow(Fiat(10.0, CurrencyCode.USD)) val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, ), @@ -261,7 +261,7 @@ class AmountEntryDelegateTest { fun `config shows no hint when max is null`() = runTest { val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, ), @@ -277,7 +277,7 @@ class AmountEntryDelegateTest { val max = MutableStateFlow(Fiat(100.0, CurrencyCode.USD)) val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, ), @@ -299,7 +299,7 @@ class AmountEntryDelegateTest { val min = MutableStateFlow(Fiat(5.0, CurrencyCode.USD)) val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, belowMinHint = { "Min is $it" }, @@ -321,7 +321,7 @@ class AmountEntryDelegateTest { val min = MutableStateFlow(Fiat(5.0, CurrencyCode.USD)) val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, // belowMinHint is null — no below-min check @@ -342,7 +342,7 @@ class AmountEntryDelegateTest { val min = MutableStateFlow(Fiat(5.0, CurrencyCode.USD)) val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, belowMinHint = { "Min is $it" }, @@ -364,7 +364,7 @@ class AmountEntryDelegateTest { val min = MutableStateFlow(Fiat(5.0, CurrencyCode.USD)) val delegate = createDelegate( style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, belowMinHint = { "Min is $it" }, @@ -408,7 +408,7 @@ class AmountEntryDelegateTest { @Test fun `config updates when style flow changes`() = runTest { val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) - val styleFlow = MutableStateFlow(AmountEntryStyle(actionLabel = "Buy")) + val styleFlow = MutableStateFlow(AmountEntryStyle(actionLabel = AmountEntryLabel.Plain("Buy"))) val delegate = AmountEntryDelegate( exchange = exchange, scope = scope, @@ -416,11 +416,11 @@ class AmountEntryDelegateTest { ) delegate.config.launchIn(scope) - assertEquals("Buy", delegate.config.value.action.label) + assertEquals("Buy", delegate.config.value.action.label.text) - styleFlow.value = AmountEntryStyle(actionLabel = "Sell") + styleFlow.value = AmountEntryStyle(actionLabel = AmountEntryLabel.Plain("Sell")) - assertEquals("Sell", delegate.config.value.action.label) + assertEquals("Sell", delegate.config.value.action.label.text) } @Test @@ -431,7 +431,7 @@ class AmountEntryDelegateTest { exchange = exchange, scope = scope, style = AmountEntryStyle( - actionLabel = "Next", + actionLabel = AmountEntryLabel.Plain("Next"), infoHint = { "Up to $it" }, overMaxHint = { "Over $it" }, ), @@ -461,7 +461,7 @@ class AmountEntryDelegateTest { exchange = exchange, scope = scope, maxLength = 3, - style = AmountEntryStyle(actionLabel = "Next"), + style = AmountEntryStyle(actionLabel = AmountEntryLabel.Plain("Next")), ) delegate.onCurrencyChanged(usd) delegate.onNumber(1) @@ -485,7 +485,7 @@ class AmountEntryDelegateTest { val delegate = AmountEntryDelegate( exchange = exch, scope = scope, - style = AmountEntryStyle(actionLabel = "Next"), + style = AmountEntryStyle(actionLabel = AmountEntryLabel.Plain("Next")), ) delegate.onCurrencyChanged(usd) delegate.onNumber(5) diff --git a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt index 51d208467..fd4283d03 100644 --- a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt +++ b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryScreenTest.kt @@ -30,7 +30,7 @@ class AmountEntryScreenTest { private class FakeController( state: AmountEntryDelegate.State = AmountEntryDelegate.State(), config: AmountEntryConfig = AmountEntryConfig( - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ), ) : AmountEntryController { override val state = MutableStateFlow(state) @@ -76,7 +76,7 @@ class AmountEntryScreenTest { fun `displays action button with label from config`() { setScreen(FakeController( config = AmountEntryConfig( - action = AmountEntryAction(label = "Send"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Send")), ), )) @@ -87,7 +87,7 @@ class AmountEntryScreenTest { fun `button label updates when config changes`() { val controller = FakeController( config = AmountEntryConfig( - action = AmountEntryAction(label = "Buy"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Buy")), ), ) setScreen(controller) @@ -95,7 +95,7 @@ class AmountEntryScreenTest { composeTestRule.onNodeWithText("Buy").assertIsDisplayed() controller.updateConfig { - it.copy(action = it.action.copy(label = "Sell")) + it.copy(action = it.action.copy(label = AmountEntryLabel.Plain("Sell"))) } composeTestRule.waitForIdle() @@ -111,7 +111,7 @@ class AmountEntryScreenTest { setScreen(FakeController( config = AmountEntryConfig( canConfirm = false, - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ), )) @@ -123,7 +123,7 @@ class AmountEntryScreenTest { setScreen(FakeController( config = AmountEntryConfig( canConfirm = true, - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ), )) @@ -137,7 +137,7 @@ class AmountEntryScreenTest { config = AmountEntryConfig( canConfirm = true, action = AmountEntryAction( - label = "Next", + label = AmountEntryLabel.Plain("Next"), loadingState = LoadingSuccessState(loading = true), ), ), @@ -167,7 +167,7 @@ class AmountEntryScreenTest { controller = FakeController( config = AmountEntryConfig( canConfirm = true, - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ), ), onConfirm = { confirmed = true }, @@ -186,7 +186,7 @@ class AmountEntryScreenTest { setScreen(FakeController( config = AmountEntryConfig( action = AmountEntryAction( - label = "Swipe to send", + label = AmountEntryLabel.Plain("Swipe to send"), style = ConfirmationStyle.Slide, ), ), @@ -204,7 +204,7 @@ class AmountEntryScreenTest { setScreen(FakeController( config = AmountEntryConfig( hint = AmountEntryHint.Info("Send up to \$100"), - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ), )) @@ -216,7 +216,7 @@ class AmountEntryScreenTest { setScreen(FakeController( config = AmountEntryConfig( hint = AmountEntryHint.Error("Limit exceeded"), - action = AmountEntryAction(label = "Next"), + action = AmountEntryAction(label = AmountEntryLabel.Plain("Next")), ), )) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 63dd89fa1..99909922f 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -7,6 +7,7 @@ import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.to +import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.onramp.CoinbaseOnRampController @@ -20,7 +21,6 @@ import com.flipcash.app.onramp.PurchaseGate import com.flipcash.app.onramp.isAlert import com.flipcash.app.onramp.isNetworkCause import com.flipcash.app.onramp.messaging -import com.getcode.manager.BottomBarAction import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata @@ -29,7 +29,11 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.UserManager +import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel +import com.flipcash.shared.amountentry.AmountEntryStyle import com.flipcash.shared.tokens.R +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange @@ -53,8 +57,6 @@ import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.transactions.SwapState import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 -import com.flipcash.shared.amountentry.AmountEntryDelegate -import com.flipcash.shared.amountentry.AmountEntryStyle import com.getcode.util.resources.ResourceHelper import com.getcode.utils.TraceType import com.getcode.utils.trace @@ -66,9 +68,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -107,8 +109,30 @@ class SwapViewModel @Inject constructor( ) { private val styleFlow: StateFlow = stateFlow.map { vmState -> val isBuy = vmState.purpose is SwapPurpose.Buy + val isAddingMoney = vmState.isAddingMoney + val isAddingMoneyViaPhantom = isAddingMoney && vmState.addingMoneyFrom == FundingSource.Phantom AmountEntryStyle( - actionLabel = if (isBuy) resources.getString(R.string.action_buy) else resources.getString(R.string.action_next), + actionLabel = when { + isAddingMoneyViaPhantom -> { + val confirmIn = resources.getString(R.string.label_confirmIn) + val phantom = resources.getString(R.string.label_phantom) + AmountEntryLabel.Annotated(text = "$confirmIn $phantom") { enabled -> + buildPhantomButtonLabel(prefix = confirmIn, isEnabled = enabled) + } + } + + isAddingMoney -> { + AmountEntryLabel.Plain(resources.getString(R.string.action_addMoney)) + } + + isBuy -> { + AmountEntryLabel.Plain(resources.getString(R.string.action_buy)) + } + + else -> { + AmountEntryLabel.Plain(resources.getString(R.string.action_next)) + } + }, canChangeCurrency = (vmState.purpose as? SwapPurpose.Buy)?.fundingSource != FundingSource.Phantom, infoHint = { resources.getString(R.string.subtitle_buySellCashHint, it) }, overMaxHint = { @@ -118,7 +142,7 @@ class SwapViewModel @Inject constructor( }, belowMinHint = { resources.getString(R.string.subtitle_buyHintBelowMinimum, it) }, ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AmountEntryStyle(actionLabel = "")) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AmountEntryStyle(actionLabel = AmountEntryLabel.Plain(""))) private val maxAmountFlow: StateFlow = combine( stateFlow.map { it.purpose }, @@ -196,6 +220,11 @@ class SwapViewModel @Inject constructor( val netTransferAmount: Fiat get() = confirmedNetTransferAmount ?: Fiat.Zero + + val isAddingMoney: Boolean + get() = purpose is SwapPurpose.Buy && purpose.fundingSource != FundingSource.Flexible + val addingMoneyFrom: FundingSource? + get() = (purpose as? SwapPurpose.Buy)?.fundingSource } sealed interface Event { From bbf6d6c5b46e1a6a8b609e416e4436949afc93f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:49:08 -0400 Subject: [PATCH 03/17] feat(deposit): deposit-first "Add Money" UX groundwork Renames the DepositFirstUX flag to AddMoneyUX and reframes getting funds into the app around "Add Money" (deposit into USDF reserves). Adds per-context "No Balance Yet" messaging, an "Adding Money" processing title, Phantom add-money copy, and routes Phantom deposits straight to the connect step. Threads the flag through balance/menu/chat/session/token-info and adds the swap-side add-money label + (still-empty) mustAddMoney branch. Signed-off-by: Brandon McAnsh --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 16 ++- .../core/src/main/res/values/strings.xml | 24 ++++- .../balance/internal/BalanceScreenContent.kt | 12 +-- .../app/balance/internal/BalanceViewModel.kt | 2 +- .../internal/CurrencyCreatorViewModel.kt | 5 +- .../app/menu/internal/MenuScreenViewModel.kt | 6 +- .../app/messenger/internal/ChatViewModel.kt | 16 ++- .../app/tokens/PhantomWalletScreens.kt | 18 +++- .../flipcash/app/tokens/SwapEntryScreen.kt | 2 +- .../app/tokens/internal/TokenSelectScreen.kt | 2 +- .../internal/TokenTxProcessingScreen.kt | 2 +- .../flipcash/app/featureflags/FeatureFlag.kt | 8 +- .../flipcash/app/session/SessionController.kt | 4 +- .../internal/delegates/DepositDelegate.kt | 10 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 101 ++++++++++-------- .../app/tokens/ui/TokenInfoViewModel.kt | 61 ++++++++--- .../app/tokens/ui/SwapViewModelErrorTest.kt | 3 + 17 files changed, 187 insertions(+), 105 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 19a608e00..f6c86dc2d 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -17,6 +17,7 @@ import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.onboarding.OnboardingStep +import com.flipcash.app.core.tokens.FundingSource import com.getcode.navigation.flow.FlowRoute import com.getcode.navigation.flow.FlowRouteWithResult import com.getcode.opencode.model.financial.Fiat @@ -181,7 +182,20 @@ sealed interface AppRoute : NavKey, Parcelable { val popToRoot: Boolean = false, ) : Token, FlowRouteWithResult { override val initialStack: List - get() = listOf(SwapStep.Entry(purpose, initialAmount = shortfall)) + get() = when (purpose) { + is SwapPurpose.Buy -> { + if (purpose.fundingSource == FundingSource.Phantom) { + // adding money (deposit) via phantom + listOf(SwapStep.PhantomConnect) + } else { + listOf(SwapStep.Entry(purpose, initialAmount = shortfall)) + } + } + is SwapPurpose.Sell -> listOf(SwapStep.Entry(purpose, initialAmount = shortfall)) + } + + + } @Serializable diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 65778caa1..c4332138f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -69,9 +69,8 @@ Deposit Deposit Deposit Funds - Deposit Funds Add Money - + Add More Money Withdraw Withdraw Funds @@ -187,6 +186,7 @@ Invalid address Withdraw + Withdraw Money Yes, Withdraw Are You Sure? @@ -241,6 +241,7 @@ Add Cash Select Method + Amount to Add Amount to Deposit Add Cash with Debit Card @@ -254,7 +255,14 @@ Tap above to Add Cash to your wallet You don\'t have any cash yet.\nTap below to add cash to your wallet No Balance Yet - Deposit funds to get started + Insufficient Balance + Add more money to create a currency + Add more money, or enter a smaller amount + Add money to give cash + Add money to buy currencies + Add money to send cash + Add money to get started + Add money to create a currency Buy your first currency to get started Dismiss Success @@ -386,7 +394,7 @@ Debit Card with Google Pay Debit Card Credit Card - Manual Deposit + Manual USDC Deposit Backpack Wallet Phantom Wallet Solflare Wallet @@ -497,6 +505,8 @@ USDF Buy More + Add Money + Amount to Buy Amount to Sell Enter up to %1$s @@ -509,7 +519,7 @@ Solana USDC with Sell %1$s Purchasing %1$s - Depositing %1$s + Adding Money Selling %1$s Review the above before confirming.\nOnce made, your transaction is irreversible. @@ -730,6 +740,10 @@ Buy With Phantom Purchase using Solana USDC in Phantom. Simply connect your wallet and confirm the transaction + + Add Money With Phantom + Add money using Solana USDC in Phantom. Your USDC will be automatically converted to USDF 1:1 + Confirmation Connected Confirm the transaction in Phantom to continue diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt index f34bf2313..fed38939d 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt @@ -46,7 +46,7 @@ internal fun BalanceScreen( val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle() val tokenState by tokenViewModel.stateFlow.collectAsStateWithLifecycle() BalanceScreenContent( - depositFirstUx = balanceState.depositFirstUx, + addMoneyUx = balanceState.depositFirstUx, tokenState = tokenState, dispatchEvent = viewModel::dispatchEvent ) @@ -54,7 +54,7 @@ internal fun BalanceScreen( @Composable private fun BalanceScreenContent( - depositFirstUx: Boolean = false, + addMoneyUx: Boolean = false, tokenState: SelectTokenViewModel.State, dispatchEvent: (BalanceViewModel.Event) -> Unit ) { @@ -100,8 +100,8 @@ private fun BalanceScreenContent( Text( modifier = Modifier.fillMaxWidth(0.6f), - text = if (depositFirstUx) { - stringResource(R.string.description_noBalanceYet) + text = if (addMoneyUx) { + stringResource(R.string.description_noBalanceYetForBalance) } else { stringResource(R.string.description_noBalanceYetDiscover) }, @@ -118,8 +118,8 @@ private fun BalanceScreenContent( .padding(top = CodeTheme.dimens.grid.x2) .align(Alignment.CenterHorizontally), contentPadding = PaddingValues(), - text = if (depositFirstUx) { - stringResource(R.string.action_depositFunds) + text = if (addMoneyUx) { + stringResource(R.string.action_addMoney) } else { stringResource(R.string.action_discoverCurrencies) }, diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt index 7c12c622b..33655744a 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt @@ -49,7 +49,7 @@ internal class BalanceViewModel @Inject constructor( } init { - featureFlags.observe(FeatureFlag.DepositFirstUX) + featureFlags.observe(FeatureFlag.AddMoneyUX) .onEach { dispatchEvent(Event.DepositFirstUxEnabled(it)) } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt index 067fb1c62..4f4cd86ba 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt @@ -29,9 +29,6 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.currencycreator.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ModerationController -import com.getcode.solana.keys.PublicKey -import com.getcode.utils.TraceType -import com.getcode.utils.trace import com.flipcash.services.models.ImageModerationError import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.TextModerationError @@ -63,6 +60,8 @@ import com.getcode.opencode.model.ui.TokenBillCustomizations import com.getcode.solana.keys.Mint import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.TraceType +import com.getcode.utils.trace import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 001b4e533..046722362 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -18,18 +18,14 @@ import com.flipcash.features.menu.BuildConfig import com.flipcash.features.menu.R import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager -import com.getcode.manager.BottomBarManager import com.getcode.opencode.managers.MnemonicManager import com.flipcash.libs.coroutines.DispatcherProvider -import com.getcode.manager.BottomBarAction import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -168,7 +164,7 @@ internal class MenuScreenViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { - val depositFirstUx = featureFlags.get(FeatureFlag.DepositFirstUX) + val depositFirstUx = featureFlags.get(FeatureFlag.AddMoneyUX) if (!depositFirstUx) { return@mapNotNull AppRoute.Transfers.Deposit() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 64c92f887..2cadd6f95 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1,7 +1,6 @@ package com.flipcash.app.messenger.internal import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope @@ -15,7 +14,6 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.contacts.DeviceContact -import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController @@ -46,7 +44,6 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token -import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace @@ -71,7 +68,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.min @@ -524,14 +520,14 @@ internal class ChatViewModel @Inject constructor( .mapNotNull { stateFlow.value.chattingWith } .onEach { contact -> if (!tokenCoordinator.hasGiveableBalance()) { - val depositFirst = featureFlags.get(FeatureFlag.DepositFirstUX) - val message = if (depositFirst) { - resources.getString(R.string.description_noBalanceYet) + val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX) + val message = if (addMoney) { + resources.getString(R.string.description_noBalanceYetToSend) } else { resources.getString(R.string.description_noBalanceYetDiscover) } - val cta = if (depositFirst) { - resources.getString(R.string.action_depositFunds) + val cta = if (addMoney) { + resources.getString(R.string.action_addMoney) } else { resources.getString(R.string.action_discover) } @@ -542,7 +538,7 @@ internal class ChatViewModel @Inject constructor( BottomBarAction( text = cta ) { - if (depositFirst) { + if (addMoney) { dispatchEvent(Event.PresentDepositOptions) } else { dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery)) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt index dc9fddeb3..596beb55c 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt @@ -57,7 +57,11 @@ internal fun PhantomConnectConfirmationScreen() { CodeScaffold( topBar = { AppBarWithTitle( - title = stringResource(R.string.title_purchase), + title = if (state.isAddingMoney) { + stringResource(R.string.title_addMoney) + } else { + stringResource(R.string.title_purchase) + }, backButton = true, onBackIconClicked = { flowNavigator.back() }, titleAlignment = Alignment.CenterHorizontally, @@ -106,12 +110,20 @@ internal fun PhantomConnectConfirmationScreen() { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stringResource(R.string.title_buyWithPhantom), + text = if (state.isAddingMoney) { + stringResource(R.string.title_addMoneyWithPhantom) + } else { + stringResource(R.string.title_buyWithPhantom) + }, style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, ) Text( - text = stringResource(R.string.description_buyWithPhantom), + text = if (state.isAddingMoney) { + stringResource(R.string.description_addMoneyWithPhantom) + } else { + stringResource(R.string.description_buyWithPhantom) + }, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 4972bbdcf..8c2506366 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -50,7 +50,7 @@ internal fun SwapEntryScreen( AppBarWithTitle( title = when (purpose) { is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible -> - stringResource(R.string.title_amountToDeposit) + stringResource(R.string.title_amountToAdd) is SwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy) is SwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell) }, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt index 851b8f91c..6aff413f5 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt @@ -75,7 +75,7 @@ private fun SelectTokenScreenContent( Text( modifier = Modifier.fillMaxWidth(0.6f), - text = stringResource(R.string.description_noBalanceYet), + text = stringResource(R.string.description_noBalanceYetForBalance), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt index ceda7c220..08f269116 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenTxProcessingScreen.kt @@ -56,7 +56,7 @@ private fun TokenTxProcessingScreen( AppBarWithTitle( title = when (val purpose = state.purpose) { is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible -> - stringResource(R.string.title_depositingToken, state.tokenName) + stringResource(R.string.title_addingMoney) is SwapPurpose.BalanceIncrease -> stringResource( R.string.title_purchasingToken, diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 385b2b065..cf9640da4 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -240,9 +240,9 @@ sealed interface FeatureFlag { } @FeatureFlagMarker - data object DepositFirstUX: FeatureFlag { + data object AddMoneyUX: FeatureFlag { override val key: String = "deposit_first_ux_enabled" - override val default: Boolean = false + override val default: Boolean = true override val launched: Boolean = false override val visible: Boolean = true override val persistLogOut: Boolean = false @@ -292,7 +292,7 @@ val FeatureFlag<*>.title: String FeatureFlag.Messenger -> "Messenger" FeatureFlag.NavBar -> "Navigation Bar" FeatureFlag.GiveUsdf -> "Give/Send USDF" - FeatureFlag.DepositFirstUX -> "Deposit First UX" + FeatureFlag.AddMoneyUX -> "Add Money UX" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" } @@ -320,7 +320,7 @@ val FeatureFlag<*>.message: String FeatureFlag.Messenger -> "When enabled, tapping a contact will open the chat messenger instead of navigating directly to send" FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" FeatureFlag.GiveUsdf -> "When enabled, you'll gain the ability to send USDF directly and give it as cash" - FeatureFlag.DepositFirstUX -> "When enabled, the user experience for new and empty accounts will be centered around depositing funds" + FeatureFlag.AddMoneyUX -> "When enabled, the user experience for getting money into the app will be focused around 'Adding Money'" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 32ab36eec..dc7980984 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -5,10 +5,8 @@ import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.bill.BillState import com.flipcash.app.session.BillDeterminationResult.ActedUpon import com.getcode.opencode.model.financial.Token -import com.getcode.solana.keys.Mint import com.flipcash.app.core.AppRoute import com.getcode.ui.core.RestrictionType -import com.getcode.util.permissions.PermissionResult import com.kik.kikx.models.ScannableKikCode import kotlinx.coroutines.flow.StateFlow @@ -58,7 +56,7 @@ data class SessionState( val notificationUnreadCount: Int = 0, val tokens: List = emptyList(), val isPhoneNumberSendEnabled: Boolean = false, - val depositFirstUx: Boolean = false, + val addMoneyUx: Boolean = false, ) val LocalSessionController = staticCompositionLocalOf { null } \ No newline at end of file diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt index 8e0903ab9..724e212fe 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt @@ -45,21 +45,21 @@ class DepositDelegate @Inject constructor( private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) init { - featureFlagController.observe(FeatureFlag.DepositFirstUX) - .onEach { enabled -> stateHolder.update { it.copy(depositFirstUx = enabled) } } + featureFlagController.observe(FeatureFlag.AddMoneyUX) + .onEach { enabled -> stateHolder.update { it.copy(addMoneyUx = enabled) } } .launchIn(scope) } override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) { - val depositFirstUx = stateHolder.current.depositFirstUx + val depositFirstUx = stateHolder.current.addMoneyUx val message = if (depositFirstUx) { - resources.getString(R.string.description_noBalanceYet) + resources.getString(R.string.description_noBalanceYetToGive) } else { resources.getString(R.string.description_noBalanceYetDiscover) } val cta = if (depositFirstUx) { - resources.getString(R.string.action_depositFunds) + resources.getString(R.string.action_addMoney) } else { resources.getString(R.string.action_discoverCurrencies) } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 99909922f..27ad789a5 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -10,6 +10,8 @@ import com.flipcash.app.core.extensions.to import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.onramp.CoinbaseOnRampController import com.flipcash.app.onramp.CoinbaseOnRampState import com.flipcash.app.onramp.DeeplinkError @@ -101,6 +103,7 @@ class SwapViewModel @Inject constructor( private val coinbaseOnRampController: CoinbaseOnRampController, private val phantomWalletController: PhantomWalletController, private val userFlags: UserFlagsCoordinator, + private val featureFlags: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -508,6 +511,8 @@ class SwapViewModel @Inject constructor( it to purpose } .onEach { (delegateState, purpose) -> + val mustAddMoney = featureFlags.get(FeatureFlag.AddMoneyUX) + val isAddingMoney = stateFlow.value.isAddingMoney when (purpose) { is SwapPurpose.Buy -> { val rate = exchange.preferredRate @@ -519,54 +524,62 @@ class SwapViewModel @Inject constructor( ).convertingTo(conversionRate) val reservesBalance = stateFlow.value.reservesBalance - if (enteredInUsdf <= reservesBalance.rounded()) { - // Sufficient reserves — buy directly - val amountFiat = verifiedFiatCalculator.compute( - amount = Fiat(delegateState.enteredAmount, rate.currency), - token = Token.usdf, - balance = reservesBalance.convertingToUsdIfNeeded(rate), - rate = rate - ).getOrElse { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_staleRates), - message = resources.getString(R.string.error_description_staleRates), + when { + enteredInUsdf <= reservesBalance.rounded() -> { + // Sufficient reserves — buy directly + val amountFiat = verifiedFiatCalculator.compute( + amount = Fiat(delegateState.enteredAmount, rate.currency), + token = Token.usdf, + balance = reservesBalance.convertingToUsdIfNeeded(rate), + rate = rate + ).getOrElse { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + return@onEach + } + val netAmount = amountFiat.localFiat.nativeAmount + + dispatchEvent(Event.UpdateBuyState(loading = true)) + dispatchEvent( + Event.OnAmountAccepted( + amountFiat, + netTransferAmount = netAmount, + enteredAmount = enteredAmount, + feeAmount = feeAmount, + ) ) - return@onEach + dispatchEvent(Event.ProceedWithPurchase(amountFiat)) } - val netAmount = amountFiat.localFiat.nativeAmount - - dispatchEvent(Event.UpdateBuyState(loading = true)) - dispatchEvent( - Event.OnAmountAccepted( - amountFiat, - netTransferAmount = netAmount, - enteredAmount = enteredAmount, - feeAmount = feeAmount, - ) - ) - dispatchEvent(Event.ProceedWithPurchase(amountFiat)) - } else { - // Insufficient reserves — check available purchase methods - val mint = purpose.mint - val metadata = PurchaseMethodMetadata( - mint = mint, - purchaseAmount = Fiat(delegateState.enteredAmount, rate.currency), - canUseOtherWallets = true, // allow external USDC deposit as a "purchase" option - ) - val pinnedMethod = when (purpose.fundingSource) { - FundingSource.Coinbase -> PurchaseMethod.CoinbaseOnRamp - FundingSource.Phantom -> PurchaseMethod.PhantomWallet - FundingSource.Flexible -> null + + mustAddMoney -> { + // not enough USDF, must "add money first" } - if (pinnedMethod != null) { - purchaseMethodController.select(pinnedMethod, metadata) - } else { - val methods = purchaseMethodController.state.value.availableMethods - if (methods.size == 1) { - // Single method — skip sheet, handle directly - purchaseMethodController.select(methods.first(), metadata) + + else -> { + // Insufficient reserves — check available purchase methods + val mint = purpose.mint + val metadata = PurchaseMethodMetadata( + mint = mint, + purchaseAmount = Fiat(delegateState.enteredAmount, rate.currency), + canUseOtherWallets = true, // allow external USDC deposit as a "purchase" option + ) + val pinnedMethod = when (purpose.fundingSource) { + FundingSource.Coinbase -> PurchaseMethod.CoinbaseOnRamp + FundingSource.Phantom -> PurchaseMethod.PhantomWallet + FundingSource.Flexible -> null + } + if (pinnedMethod != null) { + purchaseMethodController.select(pinnedMethod, metadata) } else { - purchaseMethodController.present(metadata) + val methods = purchaseMethodController.state.value.availableMethods + if (methods.size == 1) { + // Single method — skip sheet, handle directly + purchaseMethodController.select(methods.first(), metadata) + } else { + purchaseMethodController.present(metadata) + } } } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 5130fdf62..67161b9c3 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -1,7 +1,6 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.data.isLoaded @@ -17,6 +16,7 @@ import com.flipcash.app.tokens.data.MarketCapPoint import com.flipcash.app.tokens.data.Period import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.tokens.R +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.AccountController import com.getcode.opencode.exchange.Exchange @@ -69,10 +69,14 @@ class TokenInfoViewModel @Inject constructor( val historicalMarketCapData: Map>> = emptyMap(), val selectedPeriod: Period = Period.All, val canGiveUsdf: Boolean = false, + val reservesBalance: LocalFiat = LocalFiat.Zero, ) { val canSell: Boolean get() = balance.underlyingTokenAmount.valueNonZero() + val hasReserves: Boolean + get() = reservesBalance.underlyingTokenAmount.valueNonZero() + val isCashReserve: Boolean get() = token.dataOrNull?.address == Mint.usdf } @@ -92,6 +96,7 @@ class TokenInfoViewModel @Inject constructor( data class OnMarketCapPeriodSelected(val period: Period) : Event data class OnBalanceUpdated(val balance: LocalFiat) : Event + data class OnReservesBalanceUpdated(val balance: LocalFiat): Event data class OnAppreciatedEnabled(val enabled: Boolean) : Event data class OnTransactionHistoryEnabled(val enabled: Boolean): Event data class OnAppreciationUpdated(val amount: LocalFiat?) : Event @@ -176,6 +181,19 @@ class TokenInfoViewModel @Inject constructor( } .launchIn(viewModelScope) + combine( + tokenCoordinator.observeReservesBalance(), + exchange.observePreferredRate(), + ) { balance, rate -> + LocalFiat( + usdf = balance, + nativeAmount = balance.convertingTo(rate), + mint = Mint.usdf, + ) + }.onEach { + dispatchEvent(Event.OnReservesBalanceUpdated(it)) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .distinctUntilChanged() @@ -269,27 +287,45 @@ class TokenInfoViewModel @Inject constructor( eventFlow .filterIsInstance() - .mapNotNull { - val mint = stateFlow.value.mint ?: return@mapNotNull null - SwapPurpose.Buy(mint) to it.shortFall - } - .onEach { (purpose, shortfall) -> - dispatchEvent(Event.OpenScreen(AppRoute.Token.Swap( - purpose = purpose, - shortfall = shortfall, - ))) + .onEach { event -> + val mint = stateFlow.value.mint ?: return@onEach + // Buying requires cash reserves to fund the swap; if there are none, + // send the user to deposit options first instead of the swap screen. + // This check is only done if AddMoneyUx FF is enabled. + val addMoney = features.get(FeatureFlag.AddMoneyUX) + if (!stateFlow.value.hasReserves && addMoney) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noBalanceYet), + message = resources.getString(R.string.description_noBalanceYetToBuy), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoney) + ) { + dispatchEvent(Event.PresentDepositOptions) + }, + ), + showCancel = true, + ) + } else { + dispatchEvent(Event.OpenScreen(AppRoute.Token.Swap( + purpose = SwapPurpose.Buy(mint), + shortfall = event.shortFall, + ))) + } } .launchIn(viewModelScope) eventFlow .filterIsInstance() .mapNotNull { - val depositFirstUx = features.get(FeatureFlag.DepositFirstUX) + val depositFirstUx = features.get(FeatureFlag.AddMoneyUX) if (!depositFirstUx) { return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) } - purchaseMethodController.presentDepositOptions(popToRoot = true) } + // popToRoot = false so finishing the deposit returns to this token + // info screen rather than dismissing the whole sheet. + purchaseMethodController.presentDepositOptions(popToRoot = false) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) @@ -309,6 +345,7 @@ class TokenInfoViewModel @Inject constructor( is Event.OnTokenChanged -> { state -> state.copy(token = event.token) } is Event.OnMarketCapChanged -> { state -> state.copy(marketCap = event.mcap) } is Event.OnBalanceUpdated -> { state -> state.copy(balance = event.balance) } + is Event.OnReservesBalanceUpdated -> { state -> state.copy(reservesBalance = event.balance) } is Event.OnAppreciationUpdated -> { state -> state.copy(appreciation = event.amount) } is Event.ExpandDescription -> { state -> state.copy(descriptionExpanded = event.expand) } is Event.PresentDepositOptions -> { state -> state } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index 0a56c2233..60c79fc67 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.tokens.ui import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.onramp.CoinbaseOnRampController import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator @@ -65,6 +66,7 @@ class SwapViewModelErrorTest { private val coinbaseOnRampController = mockk(relaxed = true) private val phantomWalletController = mockk(relaxed = true) private val userFlagsCoordinator = mockk(relaxed = true) + private val featureFlags = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) @@ -106,6 +108,7 @@ class SwapViewModelErrorTest { phantomWalletController = phantomWalletController, dispatchers = dispatchers, userFlags = userFlagsCoordinator, + featureFlags = featureFlags, ) } From e6974f3704d28a329118ab68611a2858a08dc71a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:50:19 -0400 Subject: [PATCH 04/17] fix(swap): guard reserves branches against add-money purposes Adds !isAddingMoney guards so pinned Coinbase/Phantom "add money" purposes (mint = USDF) always take the pinned-method path instead of "buy directly" or the add-money re-route, which only apply to token buys funded from USDF reserves. Signed-off-by: Brandon McAnsh --- .../kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 27ad789a5..3eff431e4 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -525,8 +525,8 @@ class SwapViewModel @Inject constructor( val reservesBalance = stateFlow.value.reservesBalance when { - enteredInUsdf <= reservesBalance.rounded() -> { - // Sufficient reserves — buy directly + !isAddingMoney && enteredInUsdf <= reservesBalance.rounded() -> { + // Sufficient USDF reserves — buy the token directly from reserves val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(delegateState.enteredAmount, rate.currency), token = Token.usdf, @@ -553,8 +553,8 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.ProceedWithPurchase(amountFiat)) } - mustAddMoney -> { - // not enough USDF, must "add money first" + mustAddMoney && !isAddingMoney -> { + // not enough USDF for a token buy — must "add money first" } else -> { From 1ee9919678af3d9fbbc3bb5fd2c1ce6be0dddeb4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:52:27 -0400 Subject: [PATCH 05/17] feat(swap): add deposit-options navigation events Adds Event.PresentDepositOptions (presents the add-money/deposit sheet via purchaseMethodController.presentDepositOptions, or the USDC deposit flow when AddMoneyUX is off) and Event.OpenScreen, which SwapEntryScreen observes to navigate. Nothing dispatches PresentDepositOptions from the buy path yet. Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/tokens/SwapEntryScreen.kt | 7 +++++++ .../flipcash/app/tokens/ui/SwapViewModel.kt | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 8c2506366..e361afcfa 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -135,6 +135,13 @@ internal fun SwapEntryScreen( }.launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(it.screen) } + .launchIn(this) + } + LaunchedEffect(Unit) { coinbaseOnRampController.pendingCompletion.collect { completion -> when (completion) { diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 3eff431e4..0ce4c0f38 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -5,6 +5,7 @@ import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.to import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel @@ -298,6 +299,8 @@ class SwapViewModel @Inject constructor( data object OnInitialAmountEntered : Event data class OnVerificationNeeded(val phone: Boolean, val email: Boolean) : Event data object Exit : Event + data object PresentDepositOptions : Event + data class OpenScreen(val screen: AppRoute) : Event } private val enteredAmount: Fiat @@ -902,6 +905,19 @@ class SwapViewModel @Inject constructor( } } }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { + if (!featureFlags.get(FeatureFlag.AddMoneyUX)) { + return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) + } + // present the add-money/deposit sheet; navigate to whatever the user picks. + // popToRoot = false so the user returns to the in-progress buy screen. + purchaseMethodController.presentDepositOptions(popToRoot = false) + } + .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } + .launchIn(viewModelScope) } private suspend fun resolveToken(): Token? { @@ -1250,6 +1266,8 @@ class SwapViewModel @Inject constructor( is Event.OnVerificationNeeded -> { state -> state } Event.Exit -> { state -> state } + Event.PresentDepositOptions -> { state -> state } + is Event.OpenScreen -> { state -> state } } } } From 110ec035f76f1ae26049e9cc3ef29215c1cd395c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:52:54 -0400 Subject: [PATCH 06/17] feat(swap): route over-reserve token buys to add-money When a token buy exceeds available USDF reserves and deposit-first UX is on, the mustAddMoney branch now dispatches PresentDepositOptions to send the user to the add-money/deposit flow instead of doing nothing. Signed-off-by: Brandon McAnsh --- .../main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 0ce4c0f38..8ca3f7d41 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -557,7 +557,9 @@ class SwapViewModel @Inject constructor( } mustAddMoney && !isAddingMoney -> { - // not enough USDF for a token buy — must "add money first" + // not enough USDF for a token buy — require depositing first. + // (a confirmation dialog is shown ahead of this by the screen.) + dispatchEvent(Event.PresentDepositOptions) } else -> { From 54b8806ec1688470cde29b22b1fa8ece82e0d924 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:57:39 -0400 Subject: [PATCH 07/17] feat(currency-creator): add reserves-check and deposit-options events to VM Injects FeatureFlagController and adds OnIntroContinue (checks AddMoneyUX + USDF reserves; shows the "No Balance Yet" dialog routing to add-money, else emits AdvanceFromInfo), PresentDepositOptions, and OpenScreen events. Adds the featureflags module dependency. Screen wiring follows in a separate commit. Signed-off-by: Brandon McAnsh --- .../currency-creator/build.gradle.kts | 1 + .../internal/CurrencyCreatorViewModel.kt | 76 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/currency-creator/build.gradle.kts b/apps/flipcash/features/currency-creator/build.gradle.kts index d3831c491..9d369e056 100644 --- a/apps/flipcash/features/currency-creator/build.gradle.kts +++ b/apps/flipcash/features/currency-creator/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(project(":apps:flipcash:features:bill-customization")) implementation(project(":apps:flipcash:shared:bills")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:currency-creator")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) implementation(project(":apps:flipcash:shared:payments")) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt index 4f4cd86ba..6f79c6fe9 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt @@ -26,6 +26,9 @@ import com.flipcash.app.payments.PurchaseMethodMetadata import com.flipcash.app.tokens.BalancePoller import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.app.core.AppRoute +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.features.currencycreator.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ModerationController @@ -33,6 +36,7 @@ import com.flipcash.services.models.ImageModerationError import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.TextModerationError import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.CurrencyController import com.getcode.opencode.controllers.TransactionController @@ -79,6 +83,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds internal data class ModerationAttestations( @@ -102,6 +107,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( private val resources: ResourceHelper, val contentReader: ContentReader, val purchaseMethodController: PurchaseMethodController, + private val featureFlags: FeatureFlagController, private val currencyCreatorCoordinator: CurrencyCreatorCoordinator, ) : BaseViewModel( initialState = State(), @@ -199,6 +205,11 @@ internal class CurrencyCreatorViewModel @Inject constructor( data class PurchaseSubmitted(val swapId: SwapId, val mint: Mint) : Event data class PurchaseCompleted(val token: Token): Event + + data object OnIntroContinue : Event + data object AdvanceFromInfo : Event + data object PresentDepositOptions : Event + data class OpenScreen(val screen: AppRoute) : Event } data class LaunchedContext( @@ -283,7 +294,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( onSuccess = { attestation -> viewModelScope.launch { dispatchEvent(Event.UpdateProcessingState(success = true)) - delay(500) + delay(500.milliseconds) dispatchEvent(Event.OnNameApproved(attestation)) dispatchEvent(Event.UpdateProcessingState()) } @@ -428,6 +439,11 @@ internal class CurrencyCreatorViewModel @Inject constructor( .launchIn(viewModelScope) purchaseMethodController.selections + // Only the final "pay for the created token" selection should launch the + // token. The shared controller also emits for the add-money/deposit sheet + // (PaymentAction.Plain); reacting to those would try to launch a token with + // an empty, un-moderated name and fail name validation. + .filter { (_, metadata) -> metadata.paymentAction == PaymentAction.Pay } .onEach { (method, _) -> dispatchEvent(Event.LaunchToken(method)) } @@ -618,6 +634,60 @@ internal class CurrencyCreatorViewModel @Inject constructor( dispatchEvent(Event.UpdateProcessingState()) } ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX) + if (addMoney) { + if (!purchaseMethodController.state.value.hasReserves) { + // Creating a currency is funded from USDF reserves; require a deposit first. + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noBalanceYet), + message = resources.getString(R.string.description_noBalanceYetToCreate), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoney) + ) { + dispatchEvent(Event.PresentDepositOptions) + }, + ), + showCancel = true, + ) + return@onEach + } else if (purchaseMethodController.state.value.reservesBalance.nativeAmount <= stateFlow.value.totalCost) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_insufficientBalance), + message = resources.getString(R.string.description_insufficientBalanceToCreate), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoreMoney) + ) { + dispatchEvent(Event.PresentDepositOptions) + }, + ), + showCancel = true, + ) + return@onEach + } + } + + dispatchEvent(Event.AdvanceFromInfo) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { + if (!featureFlags.get(FeatureFlag.AddMoneyUX)) { + return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) + } + // popToRoot = false so finishing the deposit returns to the currency + // creator (which pushed this flow) rather than tearing down the whole + // sheet and losing the user's place in the flow. + purchaseMethodController.presentDepositOptions(popToRoot = false) + } + .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } + .launchIn(viewModelScope) } /** @@ -737,6 +807,10 @@ internal class CurrencyCreatorViewModel @Inject constructor( } is Event.PurchaseWithReserves -> { state -> state } + Event.OnIntroContinue -> { state -> state } + Event.AdvanceFromInfo -> { state -> state } + Event.PresentDepositOptions -> { state -> state } + is Event.OpenScreen -> { state -> state } is Event.PurchaseWithPhantom -> { state -> state } is Event.PurchaseWithGooglePay -> { state -> state } is Event.PurchaseSubmitted -> { state -> state } From e91aa85b01e71b6353d56b70c812960fa8c21003 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 11:58:47 -0400 Subject: [PATCH 08/17] feat(currency-creator): gate token creation intro on USDF reserves Routes the intro "Get Started" button through the ViewModel: it dispatches OnIntroContinue, which shows the "No Balance Yet" add-money dialog when reserves are missing (deposit-first UX) or advances to name selection otherwise. The screen observes AdvanceFromInfo/OpenScreen to drive navigation. Signed-off-by: Brandon McAnsh --- .../internal/screens/InfoScreen.kt | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt index 827dd28be..b5b42f936 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,6 +26,9 @@ import com.flipcash.app.currencycreator.internal.components.Stepper import com.flipcash.core.R import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton @@ -34,13 +38,35 @@ import com.getcode.ui.theme.CodeScaffold internal fun InfoScreen() { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - InfoScreenContent(state) + val flowNavigator = rememberFlowNavigator() + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.navigateTo(CurrencyCreatorStep.NameSelection()) } + .launchIn(this) + } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + // navigate() pushes onto the OUTER app navigator; using the inner flow + // navigator (LocalCodeNavigator) would try to render this app route inside + // the currency-creator flow, which only knows CurrencyCreatorStep keys → crash. + .onEach { flowNavigator.navigate(it.screen) } + .launchIn(this) + } + + InfoScreenContent( + state = state, + onGetStarted = { viewModel.dispatchEvent(CurrencyCreatorViewModel.Event.OnIntroContinue) }, + ) } @Composable -internal fun InfoScreenContent(state: CurrencyCreatorViewModel.State) { - val flowNavigator = rememberFlowNavigator() - +internal fun InfoScreenContent( + state: CurrencyCreatorViewModel.State, + onGetStarted: () -> Unit = {}, +) { CodeScaffold( modifier = Modifier .padding(horizontal = CodeTheme.dimens.inset), @@ -55,7 +81,7 @@ internal fun InfoScreenContent(state: CurrencyCreatorViewModel.State) { ), text = stringResource(R.string.action_getStarted), buttonState = ButtonState.Filled, - onClick = { flowNavigator.navigateTo(CurrencyCreatorStep.NameSelection()) }, + onClick = onGetStarted, ) } ) { padding -> From 0baf8973484483d03805dd12838e442e98563ce7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 14:56:37 -0400 Subject: [PATCH 09/17] feat(swap): route phantom deposits through amount entry In the deposit-first "Add Money" via Phantom flow, navigate to the amount entry screen after connecting the wallet instead of the transaction confirmation gate. Confirming the entered amount now triggers the Phantom transaction request directly. Signed-off-by: Brandon McAnsh --- .../app/tokens/PhantomWalletScreens.kt | 23 +++++++- .../flipcash/app/tokens/SwapEntryScreen.kt | 10 ++++ .../com/flipcash/app/tokens/SwapFlowScreen.kt | 17 +++++- .../flipcash/app/tokens/ui/SwapViewModel.kt | 58 ++++++++++++++----- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt index 596beb55c..49d54682f 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/PhantomWalletScreens.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.onramp.ui.buildPhantomButtonLabel +import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep import com.flipcash.app.tokens.ui.SwapViewModel @@ -35,11 +36,21 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @Composable -internal fun PhantomConnectConfirmationScreen() { +internal fun PhantomConnectConfirmationScreen( + depositFirstPurpose: SwapPurpose? = null, +) { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() + // Deposit-first "Add Money" via Phantom starts the flow here with no preceding + // amount-entry step, so seed the purpose the VM would otherwise receive from it. + LaunchedEffect(viewModel, depositFirstPurpose) { + if (depositFirstPurpose != null) { + viewModel.dispatchEvent(SwapViewModel.Event.OnPurposeChanged(depositFirstPurpose)) + } + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() @@ -50,7 +61,15 @@ internal fun PhantomConnectConfirmationScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { flowNavigator.navigateTo(SwapStep.PhantomConfirmTransaction) } + .onEach { + if (depositFirstPurpose != null) { + // Add Money UX: enter the amount after connecting; confirming it + // triggers the Phantom transaction request (no confirmation gate). + flowNavigator.navigateTo(SwapStep.Entry(depositFirstPurpose)) + } else { + flowNavigator.navigateTo(SwapStep.PhantomConfirmTransaction) + } + } .launchIn(this) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index e361afcfa..5d9fecf8d 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -127,6 +127,16 @@ internal fun SwapEntryScreen( }.launchIn(this) } + // Deposit-first "Add Money" via Phantom enters the amount here after connecting; + // confirming it signs the transaction and advances straight to processing. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + flowNavigator.navigateTo(SwapStep.Processing) + }.launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt index 7bf26159f..210f14423 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt @@ -6,6 +6,8 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import com.flipcash.app.core.AppRoute import com.flipcash.app.core.toast.LocalToastController +import com.flipcash.app.core.tokens.FundingSource +import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep import com.getcode.opencode.model.financial.Fiat @@ -56,17 +58,26 @@ fun SwapFlowScreen( SwapResult.Canceled -> outerNavigator.pop() } }, - entryProvider = swapEntryProvider(), + entryProvider = swapEntryProvider(route), ) } -private fun swapEntryProvider(): (NavKey) -> NavEntry = entryProvider { +private fun swapEntryProvider( + route: AppRoute.Token.Swap, +): (NavKey) -> NavEntry = entryProvider { + // When the flow begins directly at the connect screen (deposit-first "Add Money" + // via Phantom), there is no preceding amount-entry step. Carry the purpose through + // so that, once connected, we can route into amount entry instead of the + // transaction-confirmation gate. + val depositFirstPurpose = (route.purpose as? SwapPurpose.Buy) + ?.takeIf { it.fundingSource == FundingSource.Phantom } + flowAnnotatedEntry { step -> SwapEntryScreen(step.purpose, step.initialAmount) } annotatedEntry { SellReceiptScreen() } annotatedEntry { - PhantomConnectConfirmationScreen() + PhantomConnectConfirmationScreen(depositFirstPurpose = depositFirstPurpose) } annotatedEntry { PhantomTransactionConfirmationScreen() diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 8ca3f7d41..d2b244877 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -83,6 +83,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.util.concurrent.CancellationException import javax.inject.Inject +import kotlin.collections.listOf data class AmountEntryState( val limits: Limits? = null, @@ -152,9 +153,16 @@ class SwapViewModel @Inject constructor( stateFlow.map { it.purpose }, stateFlow.map { it.amountEntryState.maxToAdd }, stateFlow.map { it.tokenBalance }, - ) { purpose, maxToAdd, tokenBalance -> + stateFlow.map { it.reservesBalance }, + ) { purpose, maxToAdd, tokenBalance, reservesBalance -> when (purpose) { - is SwapPurpose.Buy -> maxToAdd?.let { Fiat(it.first, it.second) } + is SwapPurpose.Buy -> { + val mustAddMoney = featureFlags.get(FeatureFlag.AddMoneyUX) + if (mustAddMoney && !stateFlow.value.isAddingMoney) { + return@combine reservesBalance + } + maxToAdd?.let { Fiat(it.first, it.second) } + } is SwapPurpose.Sell -> tokenBalance null -> null } @@ -330,6 +338,11 @@ class SwapViewModel @Inject constructor( private val transactionLimit: Fiat get() = when (stateFlow.value.purpose) { is SwapPurpose.Buy -> { + val mustAddMoney = featureFlags.observe(FeatureFlag.AddMoneyUX).value + if (mustAddMoney && !stateFlow.value.isAddingMoney) { + return stateFlow.value.reservesBalance + } + val sendLimit = enteredAmount.currencyCode.let { stateFlow.value.amountEntryState.limits?.sendLimitFor(it) } ?: SendLimit.Zero @@ -377,14 +390,32 @@ class SwapViewModel @Inject constructor( } } - val checkFundingAmount: () -> Boolean = { + val checkFundingAmount: suspend () -> Boolean = { val limit = transactionLimit val isOverLimit = enteredAmount.valueGreaterThan(limit) + val mustAddMoney = featureFlags.get(FeatureFlag.AddMoneyUX) + val isAddingMoney = stateFlow.value.isAddingMoney + if (isOverLimit) { - BottomBarManager.showAlert( - resources.getString(R.string.error_title_insufficientFunds), - resources.getString(R.string.error_description_insufficientFunds) - ) + if (mustAddMoney && !isAddingMoney) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_insufficientBalance), + message = resources.getString(R.string.description_insufficientBalanceToUse), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoreMoney) + ) { + dispatchEvent(Event.PresentDepositOptions) + } + ), + showCancel = true, + ) + } else { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_insufficientFunds), + resources.getString(R.string.error_description_insufficientFunds) + ) + } } isOverLimit } @@ -528,6 +559,13 @@ class SwapViewModel @Inject constructor( val reservesBalance = stateFlow.value.reservesBalance when { + purpose.fundingSource == FundingSource.Phantom -> { + // Deposit-first "Add Money" via Phantom: the wallet is + // already connected and the amount was just entered, so + // confirming it triggers the Phantom transaction request. + dispatchEvent(Event.ConfirmPhantomTransaction) + } + !isAddingMoney && enteredInUsdf <= reservesBalance.rounded() -> { // Sufficient USDF reserves — buy the token directly from reserves val amountFiat = verifiedFiatCalculator.compute( @@ -556,12 +594,6 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.ProceedWithPurchase(amountFiat)) } - mustAddMoney && !isAddingMoney -> { - // not enough USDF for a token buy — require depositing first. - // (a confirmation dialog is shown ahead of this by the screen.) - dispatchEvent(Event.PresentDepositOptions) - } - else -> { // Insufficient reserves — check available purchase methods val mint = purpose.mint From afb2250d2d9334e3101e58c4019a0cf030df6317 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 14:56:37 -0400 Subject: [PATCH 10/17] feat(menu): flag-gate add-money and withdraw labels Show "Add Money"/"Withdraw Money" tile labels when the AddMoneyUX flag is enabled, falling back to "Deposit"/"Withdraw" otherwise. Tighten the Phantom purchase-option button icon padding. Signed-off-by: Brandon McAnsh --- .../flipcash/app/menu/internal/MenuScreenContent.kt | 13 +++++++++++-- .../app/menu/internal/MenuScreenViewModel.kt | 2 ++ .../com/flipcash/app/payments/internal/Buttons.kt | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index d078f138c..0ef36b23d 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.ui.TileButton +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.menu.MenuList import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.updates.LocalAppUpdater @@ -96,7 +97,11 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { ) { TileButton( modifier = Modifier.weight(1f), - text = stringResource(R.string.action_deposit), + text = if (state.addMoneyUxEnabled) { + stringResource(R.string.action_addMoney) + } else { + stringResource(R.string.action_deposit) + }, icon = painterResource(R.drawable.ic_menu_deposit) ) { viewModel.dispatchEvent(Event.PresentDepositOptions) @@ -104,7 +109,11 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { TileButton( modifier = Modifier.weight(1f), - text = stringResource(R.string.action_withdraw), + text = if (state.addMoneyUxEnabled) { + stringResource(R.string.action_withdrawMoney) + } else { + stringResource(R.string.action_withdraw) + }, icon = painterResource(R.drawable.ic_menu_withdraw) ) { navigator.push(AppRoute.Transfers.Withdrawal()) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 046722362..faf3ca8a5 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -67,6 +67,7 @@ internal class MenuScreenViewModel @Inject constructor( val unlockedBetaFeaturesManually: Boolean = false, val appVersionInfo: VersionInfo = VersionInfo(), val releaseTrack: String = "", + val addMoneyUxEnabled: Boolean = false, ) sealed interface Event { @@ -258,6 +259,7 @@ internal class MenuScreenViewModel @Inject constructor( flags = event.flags ), flags = event.flags, + addMoneyUxEnabled = event.flags.find { it.flag == FeatureFlag.AddMoneyUX }?.enabled ?: false, ) } } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt index 0cc358a72..a0b43829a 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/Buttons.kt @@ -74,6 +74,7 @@ internal fun purchaseOptions( buildButtonAction( prefix = null, suffix = resources.getString(R.string.label_phantom), + iconPadding = { PaddingValues() }, iconRes = R.drawable.ic_phantom_wallet, onClick = { onClick(PurchaseMethod.PhantomWallet) } ) From ae38b90ccd129f7cd21e4e97c562c69c68cebf8b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 14:56:37 -0400 Subject: [PATCH 11/17] fix(tokens): exclude USDF from giveable balance when GiveUsdf is off hasGiveableBalance() now filters out USDF unless the GiveUsdf flag is enabled, so a USDF-only balance no longer counts as giveable. Re-evaluate the session's giveable state on GiveUsdf toggles, not just balance changes. Signed-off-by: Brandon McAnsh --- .../app/session/internal/RealSessionController.kt | 8 ++++++-- .../kotlin/com/flipcash/app/tokens/TokenCoordinator.kt | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 699573614..4dfafd8a2 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -212,8 +212,12 @@ class RealSessionController @Inject constructor( .onEach { enabled -> stateHolder.update { it.copy(vibrateOnScan = enabled) } } .launchIn(scope) - tokenCoordinator.tokenBalances - .map { tokenCoordinator.hasGiveableBalance() } + // Re-evaluate on balance changes and on GiveUsdf toggles — hasGiveableBalance() + // filters out USDF when that flag is off. + combine( + tokenCoordinator.tokenBalances, + featureFlagController.observe(FeatureFlag.GiveUsdf), + ) { _, _ -> tokenCoordinator.hasGiveableBalance() } .distinctUntilChanged() .onEach { hasBalance -> stateHolder.update { it.copy(hasGiveableBalance = hasBalance) } } .launchIn(scope) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 5bfb49f25..49359b8a5 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -193,10 +193,15 @@ class TokenCoordinator @Inject constructor( // region Public API — Balances - fun hasGiveableBalance(): Boolean = - _state.value.balances + suspend fun hasGiveableBalance(): Boolean { + // USDF is only giveable when the GiveUsdf flag is on; otherwise a USDF-only + // balance must not count as giveable. + val canGiveUsdf = featureFlags.get(FeatureFlag.GiveUsdf) + return _state.value.balances + .filterKeys { canGiveUsdf || it != Mint.usdf } .values .any { it.hasDisplayableValue } + } fun balanceForToken(token: Token): Fiat = _state.value.balances[token.address] ?: Fiat.Zero From 9c4db3092296b3b1746b27acc07075f201b10e24 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 14:57:21 -0400 Subject: [PATCH 12/17] feat(onramp): buy USDC via Coinbase for add-money deposits Default Coinbase add-money purchases to USDC delivered to the owner's ATA, where UsdcDepositSweep converts it to USDF via the DepositSubmitted path. Parameterize purchaseCurrency on the order request and drop the region-based buy-options cache strategy. Signed-off-by: Brandon McAnsh --- .../app/onramp/CoinbaseOnRampController.kt | 92 +++------- .../onramp/CoinbaseOnRampControllerTest.kt | 157 ------------------ .../onramp/data/OnRampPurchaseRequest.kt | 4 +- .../onramp/data/OnRampPurchaseRequestTest.kt | 2 + 4 files changed, 28 insertions(+), 227 deletions(-) diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 2b3c1b3af..481342bf7 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -46,10 +46,6 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonIgnoreUnknownKeys -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import retrofit2.HttpException import javax.inject.Inject @@ -134,70 +130,25 @@ class CoinbaseOnRampController @Inject constructor( return Result.success(Unit) } - private val buyOptionsCache: MutableMap = mutableMapOf() - - /** - * Resolves the on-ramp token based on the user's phone number region. - * Calls the Coinbase buy-options API to check if USDF is tradable in the - * user's detected region. Falls back to USDC when USDF is unavailable. - * - * Results are cached per region so the API is only called once per - * country+subdivision combination. - */ - suspend fun resolveOnRampToken(): Token { - val phone = userManager.profile?.verifiedPhoneNumber ?: return Token.usdf - val region = regionFromPhone(phone) ?: return Token.usdf - - val usdfAvailable = buyOptionsCache.getOrPut(region.cacheKey) { - checkBuyOptions(country = region.country, subdivision = region.subdivision) - .map { response -> isUsdfTradable(response) } - .getOrDefault(true) // default to USDF on API failure - } - - return if (usdfAvailable) Token.usdf else Token.usdc - } - - private fun isUsdfTradable(response: JsonObject): Boolean { - return response["purchase_currencies"] - ?.jsonArray - ?.any { it.jsonObject["symbol"]?.jsonPrimitive?.content == "USDF" } - ?: false - } - - suspend fun checkBuyOptions( - country: String? = null, - subdivision: String? = null, - ): Result { - return requestJwtAndExecute( - scheme = "https", - host = "api.developer.coinbase.com/", - path = "onramp/v1/buy/options", - method = "GET", - call = { jwt -> - runCatching { - api.getBuyOptions( - url = "https://api.developer.coinbase.com/onramp/v1/buy/options", - jwt = "Bearer $jwt", - country = country, - subdivision = subdivision, - ) - } - } - ) - } - suspend fun placeOrderAndStartPayment( token: Token, verifiedFiat: VerifiedFiat, ): Result { - return placeOrderInclusiveOfFees(verifiedFiat.localFiat.underlyingTokenAmount, token) + // Add money into USDF reserves buys USDC by default and lets UsdcDepositSweep + // convert USDC→USDF — USDC is broadly available on Coinbase whereas USDF is + // region-restricted. Direct launchpad-token buys keep using USDF (there is no + // program to swap USDC into an arbitrary token yet). + val resolvedToken = if (token.address == Mint.usdf) Token.usdc else token + + return placeOrderInclusiveOfFees(verifiedFiat.localFiat.underlyingTokenAmount, resolvedToken) .mapCatching { (orderId, paymentLink) -> val order = OnrampOrder(orderId, paymentLink.url) - if (token.address == Mint.usdf) { + if (resolvedToken.address == Mint.usdf || resolvedToken.address == Mint.usdc) { // USDF goes to the deposit address — server auto-detects it. - // No stateful swap needed. - startPayment(order, token, verifiedFiat, null) + // USDC goes to the owner's ATA — UsdcDepositSweep converts it. + // Neither requires a stateful swap. + startPayment(order, resolvedToken, verifiedFiat, null) } else { val owner = userManager.accountCluster ?: throw IllegalStateException("No account cluster") @@ -210,7 +161,7 @@ class CoinbaseOnRampController @Inject constructor( fund = { Result.success(Unit) } ).getOrThrow() - startPayment(order, token, verifiedFiat, swapId) + startPayment(order, resolvedToken, verifiedFiat, swapId) } } } @@ -254,7 +205,8 @@ class CoinbaseOnRampController @Inject constructor( paymentMethod = OnRampPaymentMethod.GUEST_CHECKOUT_GOOGLE_PAY, email = email, phoneNumber = phone, - destinationAddress = destination + destinationAddress = destination, + purchaseCurrency = token.symbol.uppercase(), ) return requestJwtAndPlaceOrder(order, onRampApiEndpoint) @@ -299,7 +251,8 @@ class CoinbaseOnRampController @Inject constructor( paymentMethod = OnRampPaymentMethod.GUEST_CHECKOUT_GOOGLE_PAY, email = email, phoneNumber = phone, - destinationAddress = destination + destinationAddress = destination, + purchaseCurrency = token.symbol.uppercase(), ) return requestJwtAndPlaceOrder(order, onRampApiEndpoint) @@ -352,11 +305,14 @@ class CoinbaseOnRampController @Inject constructor( } private fun destinationForToken(owner: AccountCluster, token: Token): String { - return if (token.address == Mint.usdf) { - owner.depositAddressFor(token).base58() - } else { - val swapAccounts = Token.usdf.timelockSwapAccounts(owner.authorityPublicKey) - swapAccounts.pda.publicKey.base58() + return when (token.address) { + Mint.usdf -> owner.depositAddressFor(token).base58() + // USDC lands in the owner's ATA where UsdcDepositSweep converts it to USDF. + Mint.usdc -> owner.authorityPublicKey.base58() + else -> { + val swapAccounts = Token.usdf.timelockSwapAccounts(owner.authorityPublicKey) + swapAccounts.pda.publicKey.base58() + } } } diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt index 53102f11a..ea74d95da 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt @@ -278,163 +278,6 @@ class CoinbaseOnRampControllerTest { // endregion - // region checkBuyOptions - - @Test - fun `checkBuyOptions passes country and subdivision to API`() = runTest { - val urlSlot = slot() - val countrySlot = slot() - val subdivisionSlot = slot() - - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.success("test-jwt") - coEvery { - api.getBuyOptions( - url = capture(urlSlot), - jwt = any(), - country = capture(countrySlot), - subdivision = capture(subdivisionSlot), - ) - } returns JsonObject(emptyMap()) - - val result = controller.checkBuyOptions(country = "US", subdivision = "NY") - - assertTrue(result.isSuccess) - assertEquals("https://api.developer.coinbase.com/onramp/v1/buy/options", urlSlot.captured) - assertEquals("US", countrySlot.captured) - assertEquals("NY", subdivisionSlot.captured) - } - - @Test - fun `checkBuyOptions passes null params when omitted`() = runTest { - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.success("test-jwt") - coEvery { - api.getBuyOptions( - url = any(), - jwt = any(), - country = isNull(), - subdivision = isNull(), - ) - } returns JsonObject(emptyMap()) - - val result = controller.checkBuyOptions() - - assertTrue(result.isSuccess) - coVerify { - api.getBuyOptions( - url = any(), - jwt = any(), - country = isNull(), - subdivision = isNull(), - ) - } - } - - @Test - fun `checkBuyOptions fails when JWT fails`() = runTest { - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.failure(RuntimeException("jwt error")) - - val result = controller.checkBuyOptions(country = "US") - - assertTrue(result.isFailure) - } - - // endregion - - // region resolveOnRampToken - - private fun buyOptionsResponseWithUsdf(): JsonObject = JsonObject( - mapOf( - "purchase_currencies" to JsonArray( - listOf( - JsonObject(mapOf("symbol" to JsonPrimitive("USDC"))), - JsonObject(mapOf("symbol" to JsonPrimitive("USDF"))), - ) - ) - ) - ) - - private fun buyOptionsResponseWithoutUsdf(): JsonObject = JsonObject( - mapOf( - "purchase_currencies" to JsonArray( - listOf( - JsonObject(mapOf("symbol" to JsonPrimitive("USDC"))), - JsonObject(mapOf("symbol" to JsonPrimitive("BTC"))), - ) - ) - ) - ) - - private fun stubBuyOptionsApi(response: JsonObject) { - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.success("test-jwt") - coEvery { - api.getBuyOptions(url = any(), jwt = any(), country = any(), subdivision = any()) - } returns response - coEvery { - api.getBuyOptions(url = any(), jwt = any(), country = any(), subdivision = isNull()) - } returns response - } - - @Test - fun `resolveOnRampToken returns USDF for non-NYC US phone when USDF tradable`() = runTest { - stubProfile(phone = "+14155551234") // San Francisco - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken returns USDF when phone is null`() = runTest { - stubProfile(phone = null) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken returns USDF for NYC phone when USDF is tradable`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken returns USDC for NYC phone when USDF not tradable`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - assertEquals(Token.usdc, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken returns USDC for Canadian phone when USDF not tradable`() = runTest { - stubProfile(phone = "+14165551234") // Toronto - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - assertEquals(Token.usdc, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken returns USDF for international phone when USDF tradable`() = runTest { - stubProfile(phone = "+442071234567") // UK - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken defaults to USDF on API failure`() = runTest { - stubProfile(phone = "+12125551234") - coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.failure(RuntimeException("fail")) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } - - @Test - fun `resolveOnRampToken caches buy-options result per region`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - - controller.resolveOnRampToken() - controller.resolveOnRampToken() - - // API should only be called once due to caching - coVerify(exactly = 1) { api.getBuyOptions(any(), any(), any(), any()) } - } - - // endregion } class CoinbaseOnRampApiErrorParseTest { diff --git a/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt b/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt index e1f913fdf..0554c953a 100644 --- a/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt +++ b/libs/network/coinbase/onramp/src/main/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequest.kt @@ -30,9 +30,9 @@ sealed interface OnRampPurchaseRequest { override val email: String, override val phoneNumber: String, override val destinationAddress: String, + override val purchaseCurrency: String, ): OnRampPurchaseRequest { override val paymentCurrency: String = "USD" - override val purchaseCurrency: String = "USDF" override val destinationNetwork: String = "solana" } @@ -54,9 +54,9 @@ sealed interface OnRampPurchaseRequest { override val email: String, override val phoneNumber: String, override val destinationAddress: String, + override val purchaseCurrency: String, ): OnRampPurchaseRequest { override val paymentCurrency: String = "USD" - override val purchaseCurrency: String = "USDF" override val destinationNetwork: String = "solana" } diff --git a/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt b/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt index 77db24020..6f40ed505 100644 --- a/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt +++ b/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt @@ -14,6 +14,7 @@ class OnRampPurchaseRequestTest { email = "test@example.com", phoneNumber = "+12125551234", destinationAddress = "some-sol-address", + purchaseCurrency = "USDF", ) private fun exclusiveOf() = OnRampPurchaseRequest.ExclusiveOfFees( @@ -23,6 +24,7 @@ class OnRampPurchaseRequestTest { email = "apple@example.com", phoneNumber = "+12125559876", destinationAddress = "another-sol-address", + purchaseCurrency = "USDF", ) // --- InclusiveOfFees --- From 61b47896a3612dca7b4f3188002d0a49ff977ac2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 7 Jul 2026 19:34:41 -0400 Subject: [PATCH 13/17] 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 --- .../res/drawable/ic_linked_payment_method.xml | 9 + .../core/src/main/res/values/strings.xml | 2 + .../contact-verification/build.gradle.kts | 2 + .../email/EmailVerificationScreen.kt | 10 ++ .../email/EmailVerificationViewModel.kt | 29 +++- .../EmailVerificationViewModelErrorTest.kt | 16 ++ .../internal/UserProfileScreenContent.kt | 161 +++++++++++++----- .../internal/UserProfileViewModel.kt | 19 ++- .../ContactMethodsViewModelStateTest.kt | 21 +-- .../internal/UserProfileScreenContentTest.kt | 37 ++-- .../flipcash/app/tokens/SwapEntryScreen.kt | 3 +- .../app/onramp/CoinbaseOnRampController.kt | 19 +-- .../onramp/CoinbaseOnRampControllerTest.kt | 76 ++++++++- .../converters/ChatTypeConverters.kt | 5 +- .../converters/ChatTypeConvertersTest.kt | 5 +- .../sources/mapper/chat/ChatEntityMapper.kt | 12 +- .../shared/profile/ProfileCoordinator.kt | 13 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 25 ++- .../ContactVerificationController.kt | 22 ++- .../services/controllers/ProfileController.kt | 11 +- .../internal/domain/UserProfileMapper.kt | 6 +- .../network/extensions/ProtobufToLocal.kt | 5 +- .../flipcash/services/models/UserProfile.kt | 12 +- .../models/VerifiableContactMethod.kt | 14 ++ .../ContactVerificationControllerTest.kt | 60 +++++++ .../controllers/ProfileControllerTest.kt | 35 +++- 26 files changed, 505 insertions(+), 124 deletions(-) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_linked_payment_method.xml create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ContactVerificationControllerTest.kt diff --git a/apps/flipcash/core/src/main/res/drawable/ic_linked_payment_method.xml b/apps/flipcash/core/src/main/res/drawable/ic_linked_payment_method.xml new file mode 100644 index 000000000..1227431e8 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_linked_payment_method.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c4332138f..5c0e5ebd3 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -360,6 +360,8 @@ Add Phone Number Add Email Address Linked for payments + Verified + Unverified Unlink Phone Number? Your phone number will be removed from your profile. Unlink Email Address? diff --git a/apps/flipcash/features/contact-verification/build.gradle.kts b/apps/flipcash/features/contact-verification/build.gradle.kts index 79ce2da5c..3f36620e3 100644 --- a/apps/flipcash/features/contact-verification/build.gradle.kts +++ b/apps/flipcash/features/contact-verification/build.gradle.kts @@ -17,5 +17,7 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:phone")) + implementation(project(":apps:flipcash:shared:userflags")) + implementation(project(":libs:messaging")) } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt index 75c1cd9e1..9404cb175 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt @@ -75,4 +75,14 @@ fun EmailVerificationContent( } }.launchIn(this) } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + keyboard.hideIfVisible { + flowNavigator.exitWithResult(VerificationResult.Success) + } + }.launchIn(this) + } } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt index aa4bdc1fe..c633d048b 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.flipcash.app.core.verification.email.EmailCodeChannel import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.userflags.UserFlagsCoordinator import com.getcode.opencode.utils.base64 import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -45,6 +46,7 @@ class EmailVerificationViewModel @Inject constructor( private val resources: ResourceHelper, private val dispatchers: DispatcherProvider, private val emailCodeChannel: EmailCodeChannel, + private val userFlags: UserFlagsCoordinator, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -66,6 +68,8 @@ class EmailVerificationViewModel @Inject constructor( data class OnOriginSet(val origin: EmailDeeplinkOrigin?) : Event data class OnDataProvided(val email: String?, val code: String?) : Event data object OnSendCodeClicked : Event + /** Emitted in skip-verification mode once the entered email is persisted locally. */ + data object OnEntrySaved : Event data object OnResendCodeClicked: Event data class OnSendingCodeChanged( val loading: Boolean = false, @@ -111,11 +115,27 @@ class EmailVerificationViewModel @Inject constructor( eventFlow .filterIsInstance() - .map { + .onEach { val emailAddress = stateFlow.value.email.text.toString() - ContactMethod.Email(emailAddress, computeClientData()) - }.onEach { handleSendVerificationCode(it) } - .launchIn(viewModelScope) + val requiresVerification = userFlags.resolvedFlags.value + .requireCoinbaseEmailVerification.effectiveValue + + if (!requiresVerification) { + dispatchEvent(Event.OnSendingCodeChanged(loading = true)) + viewModelScope.launch { + delay(1.seconds) + dispatchEvent(Event.OnSendingCodeChanged(success = true)) + delay(1.seconds) + // Skip server verification: record the email locally as unverified + // and complete. The profile write is persisted by ProfileCoordinator. + verificationController.setLocalUnverified(ContactMethod.Email(emailAddress)) + dispatchEvent(Event.OnEntrySaved) + dispatchEvent(Event.OnSendingCodeChanged()) + } + } else { + handleSendVerificationCode(ContactMethod.Email(emailAddress, computeClientData())) + } + }.launchIn(viewModelScope) eventFlow .filterIsInstance() @@ -270,6 +290,7 @@ class EmailVerificationViewModel @Inject constructor( val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnOriginSet -> { state -> state } + Event.OnEntrySaved -> { state -> state } is Event.OnDataProvided -> { state -> state.copy( email = TextFieldState(event.email ?: state.email.text.toString()), diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt index 8f52910a1..b3cf55c10 100644 --- a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModelErrorTest.kt @@ -8,8 +8,14 @@ import com.getcode.manager.BottomBarManager import com.getcode.util.resources.FakeResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.userflags.FieldOverride +import com.flipcash.app.userflags.ResolvedFlag +import com.flipcash.app.userflags.ResolvedUserFlags +import com.flipcash.app.userflags.UserFlagsCoordinator +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -32,12 +38,21 @@ class EmailVerificationViewModelErrorTest { private val verificationController: ContactVerificationController = mock() private val profileController = mockk(relaxed = true) private val resources = FakeResourceHelper() + private val userFlags = mockk(relaxed = true) private lateinit var dispatchers: TestDispatchers @Before fun setUp() { BottomBarManager.clear() + // Verification required → OnSendCodeClicked takes the server send path. + val resolvedFlags = mockk(relaxed = true) { + every { requireCoinbaseEmailVerification } returns ResolvedFlag( + serverValue = true, + override = FieldOverride.None, + ) + } + every { userFlags.resolvedFlags } returns MutableStateFlow(resolvedFlags) } @After @@ -52,6 +67,7 @@ class EmailVerificationViewModelErrorTest { resources = resources, dispatchers = dispatchers, emailCodeChannel = EmailCodeChannel(), + userFlags = userFlags, ) } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt index a67e793d5..89c33c03a 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt @@ -4,18 +4,25 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -28,6 +35,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.RectangleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight.Companion.W600 import androidx.compose.ui.text.style.TextOverflow @@ -154,52 +163,67 @@ internal fun UserProfileScreenContent( // Phone section item(contentType = "section_header") { SectionHeader(stringResource(R.string.title_sectionPhone)) } item(contentType = "contact_method") { - if (state.phoneNumber != null) { - SwipeActionRow( + val phone = state.phone + when { + phone == null -> CardRow { + AddContactMethodRow( + label = stringResource(R.string.action_addPhoneNumber), + onClick = { dispatch(UserProfileViewModel.Event.ConnectPhoneClicked) }, + ) + } + // Only verified contacts can be unlinked from the server. + phone.verified -> SwipeActionRow( onDelete = { dispatch(UserProfileViewModel.Event.UnlinkPhoneClicked) }, - stateKey = state.phoneNumber, + stateKey = phone.value, resetOnDismiss = true, ) { ContactMethodRow( - value = state.phoneNumber, - subtitle = if (state.phoneLinkedForPayment) { - stringResource(R.string.subtitle_linkedForPayments) - } else null, + value = phone.value, + verified = true, + linkedForPayment = state.phoneLinkedForPayment, onRowClick = { dispatch(UserProfileViewModel.Event.ReplacePhoneClicked) }, ) } - } else { - CardRow { - AddContactMethodRow( - label = stringResource(R.string.action_addPhoneNumber), - onClick = { dispatch(UserProfileViewModel.Event.ConnectPhoneClicked) }, - ) - } + else -> ContactMethodRow( + value = phone.value, + verified = false, + linkedForPayment = state.phoneLinkedForPayment, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplacePhoneClicked) }, + ) } } // Email section item(contentType = "section_header") { SectionHeader(stringResource(R.string.title_sectionEmail)) } item(contentType = "contact_method") { - if (state.emailAddress != null) { - SwipeActionRow( + val email = state.email + when { + email == null -> CardRow { + AddContactMethodRow( + label = stringResource(R.string.action_addEmailAddress), + onClick = { dispatch(UserProfileViewModel.Event.ConnectEmailClicked) }, + ) + } + + email.verified -> SwipeActionRow( onDelete = { dispatch(UserProfileViewModel.Event.UnlinkEmailClicked) }, - stateKey = state.emailAddress, + stateKey = email.value, resetOnDismiss = true, ) { ContactMethodRow( - value = state.emailAddress, - subtitle = null, + value = email.value, + verified = true, + linkedForPayment = false, onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) }, ) } - } else { - CardRow { - AddContactMethodRow( - label = stringResource(R.string.action_addEmailAddress), - onClick = { dispatch(UserProfileViewModel.Event.ConnectEmailClicked) }, - ) - } + + else -> ContactMethodRow( + value = email.value, + verified = false, + linkedForPayment = false, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) }, + ) } } @@ -278,7 +302,8 @@ private fun ProfileValueRow(value: String) { @Composable private fun ContactMethodRow( value: String, - subtitle: String?, + verified: Boolean, + linkedForPayment: Boolean, onRowClick: () -> Unit, ) { Row( @@ -291,26 +316,82 @@ private fun ContactMethodRow( ), verticalAlignment = Alignment.CenterVertically, ) { - Column( + Text( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, + text = value, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + // Match the linked-payment icon's height to the status badge (the badge's + // natural height drives the row via IntrinsicSize.Min). + Row( + modifier = Modifier.height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - Text( - text = value, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - if (subtitle != null) { - Text( - text = subtitle, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) + if (linkedForPayment) { + Box( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f) + .clip(CircleShape) + .background(CodeTheme.colors.success.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_linked_payment_method), + contentDescription = stringResource(R.string.subtitle_linkedForPayments), + tint = CodeTheme.colors.success, + modifier = Modifier.size(16.dp), + ) + } } + StatusBadge( + label = if (verified) { + stringResource(R.string.label_verified) + } else { + stringResource(R.string.label_unverified) + }, + color = if (verified) CodeTheme.colors.success else CodeTheme.colors.warning, + icon = if (verified) Icons.Default.Check else null, + ) } } } +@Composable +private fun StatusBadge( + label: String, + color: Color, + icon: ImageVector? = null, +) { + Row( + modifier = Modifier + .clip(CircleShape) + .background(color.copy(alpha = 0.15f)) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = label, + style = CodeTheme.typography.caption, + color = color, + ) + } +} + @Composable private fun SocialAccountRow( account: SocialAccount.TwitterX, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt index 4f8144d02..c557a7e22 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt @@ -10,13 +10,13 @@ import com.flipcash.services.controllers.ContactVerificationController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.ContactMethod import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.uuid import com.getcode.solana.keys.base58 import com.getcode.util.resources.ResourceHelper -import com.getcode.utils.base64 import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -44,8 +44,8 @@ internal class UserProfileViewModel @Inject constructor( ) { internal data class State( val displayName: String? = null, - val phoneNumber: String? = null, - val emailAddress: String? = null, + val phone: VerifiableContactMethod? = null, + val email: VerifiableContactMethod? = null, val phoneLinkedForPayment: Boolean = false, val socialAccounts: List = emptyList(), val publicKey: String? = null, @@ -56,8 +56,8 @@ internal class UserProfileViewModel @Inject constructor( internal sealed interface Event { data class OnProfileUpdated( val displayName: String?, - val phone: String?, - val email: String?, + val phone: VerifiableContactMethod?, + val email: VerifiableContactMethod?, val linkedForPayment: Boolean, val socialAccounts: List, ) : Event @@ -93,8 +93,9 @@ internal class UserProfileViewModel @Inject constructor( dispatchEvent( Event.OnProfileUpdated( displayName = profile?.displayName, - phone = profile?.verifiedPhoneNumber, - email = profile?.verifiedEmailAddress, + // Carry the contact (value + verified) so unverified entries still show. + phone = profile?.phoneNumber, + email = profile?.email, linkedForPayment = linkedForPayment, socialAccounts = profile?.socialAccounts.orEmpty(), ) @@ -260,8 +261,8 @@ internal class UserProfileViewModel @Inject constructor( is Event.OnProfileUpdated -> { state -> state.copy( displayName = event.displayName, - phoneNumber = event.phone, - emailAddress = event.email, + phone = event.phone, + email = event.email, phoneLinkedForPayment = event.linkedForPayment, socialAccounts = event.socialAccounts, ) diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt index 8c22ecb75..41e7232be 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.myaccount.internal import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.VerifiableContactMethod import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -15,8 +16,8 @@ class ContactMethodsViewModelStateTest { fun `default state has null profile fields`() { val state = UserProfileViewModel.State() assertNull(state.displayName) - assertNull(state.phoneNumber) - assertNull(state.emailAddress) + assertNull(state.phone) + assertNull(state.email) assertFalse(state.phoneLinkedForPayment) assertTrue(state.socialAccounts.isEmpty()) assertNull(state.publicKey) @@ -38,15 +39,15 @@ class ContactMethodsViewModelStateTest { val updated = reduce( UserProfileViewModel.Event.OnProfileUpdated( displayName = "Alice", - phone = "+15551234567", - email = "test@example.com", + phone = VerifiableContactMethod("+15551234567", verified = true), + email = VerifiableContactMethod("test@example.com", verified = false), linkedForPayment = true, socialAccounts = listOf(xAccount), ) )(UserProfileViewModel.State()) assertEquals("Alice", updated.displayName) - assertEquals("+15551234567", updated.phoneNumber) - assertEquals("test@example.com", updated.emailAddress) + assertEquals(VerifiableContactMethod("+15551234567", verified = true), updated.phone) + assertEquals(VerifiableContactMethod("test@example.com", verified = false), updated.email) assertTrue(updated.phoneLinkedForPayment) assertEquals(1, updated.socialAccounts.size) assertEquals(xAccount, updated.socialAccounts.first()) @@ -64,8 +65,8 @@ class ContactMethodsViewModelStateTest { ) )(UserProfileViewModel.State()) assertNull(updated.displayName) - assertNull(updated.phoneNumber) - assertNull(updated.emailAddress) + assertNull(updated.phone) + assertNull(updated.email) assertFalse(updated.phoneLinkedForPayment) assertTrue(updated.socialAccounts.isEmpty()) } @@ -111,8 +112,8 @@ class ContactMethodsViewModelStateTest { ) val state = UserProfileViewModel.State( displayName = "Alice", - phoneNumber = "+15551234567", - emailAddress = "test@example.com", + phone = VerifiableContactMethod("+15551234567", verified = true), + email = VerifiableContactMethod("test@example.com", verified = true), phoneLinkedForPayment = true, socialAccounts = listOf(xAccount), ) diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt index 4584873ea..e833334f9 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt @@ -2,9 +2,11 @@ package com.flipcash.app.myaccount.internal import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.VerifiableContactMethod import com.getcode.theme.DesignSystem import org.junit.Rule import org.junit.Test @@ -57,36 +59,49 @@ class UserProfileScreenContentTest { @Test fun `phone number shown when present`() { - setScreen(UserProfileViewModel.State(phoneNumber = "+1 555-1234")) + setScreen(UserProfileViewModel.State(phone = VerifiableContactMethod("+1 555-1234", verified = true))) composeTestRule.onNodeWithText("+1 555-1234").assertIsDisplayed() } + @Test + fun `verified badge shown for verified phone`() { + setScreen(UserProfileViewModel.State(phone = VerifiableContactMethod("+1 555-1234", verified = true))) + composeTestRule.onNodeWithText("Verified").assertIsDisplayed() + } + + @Test + fun `unverified badge and value shown for unverified phone`() { + setScreen(UserProfileViewModel.State(phone = VerifiableContactMethod("+1 555-1234", verified = false))) + composeTestRule.onNodeWithText("+1 555-1234").assertIsDisplayed() + composeTestRule.onNodeWithText("Unverified").assertIsDisplayed() + } + @Test fun `add phone shown when phone is null`() { - setScreen(UserProfileViewModel.State(phoneNumber = null)) + setScreen(UserProfileViewModel.State(phone = null)) composeTestRule.onNodeWithText("Add Phone Number").assertIsDisplayed() } @Test - fun `linked for payments subtitle shown when true`() { + fun `linked for payments indicator shown when true`() { setScreen( UserProfileViewModel.State( - phoneNumber = "+1 555-1234", + phone = VerifiableContactMethod("+1 555-1234", verified = true), phoneLinkedForPayment = true, ) ) - composeTestRule.onNodeWithText("Linked for payments").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Linked for payments").assertIsDisplayed() } @Test - fun `linked for payments subtitle not shown when false`() { + fun `linked for payments indicator not shown when false`() { setScreen( UserProfileViewModel.State( - phoneNumber = "+1 555-1234", + phone = VerifiableContactMethod("+1 555-1234", verified = true), phoneLinkedForPayment = false, ) ) - composeTestRule.onNodeWithText("Linked for payments").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription("Linked for payments").assertDoesNotExist() } // --------------------------------------------------------------- @@ -95,13 +110,13 @@ class UserProfileScreenContentTest { @Test fun `email shown when present`() { - setScreen(UserProfileViewModel.State(emailAddress = "alice@example.com")) + setScreen(UserProfileViewModel.State(email = VerifiableContactMethod("alice@example.com", verified = true))) composeTestRule.onNodeWithText("alice@example.com").assertIsDisplayed() } @Test fun `add email shown when email is null`() { - setScreen(UserProfileViewModel.State(emailAddress = null)) + setScreen(UserProfileViewModel.State(email = null)) composeTestRule.onNodeWithText("Add Email Address").assertIsDisplayed() } @@ -137,7 +152,7 @@ class UserProfileScreenContentTest { @Test fun `tapping add phone dispatches ConnectPhoneClicked`() { - setScreen(UserProfileViewModel.State(phoneNumber = null)) + setScreen(UserProfileViewModel.State(phone = null)) composeTestRule.onNodeWithText("Add Phone Number").performClick() assertTrue(lastEvent is UserProfileViewModel.Event.ConnectPhoneClicked) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 5d9fecf8d..1158b89e6 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -102,7 +102,8 @@ internal fun SwapEntryScreen( LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { (phone, email) -> + .onEach { event -> + val (phone, email) = event val mint = (viewModel.stateFlow.value.purpose as? SwapPurpose.Buy)?.mint ?: return@onEach navigator.navigateForResult( AppRoute.Verification( diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 481342bf7..97cafac56 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -185,7 +185,7 @@ class CoinbaseOnRampController @Inject constructor( val destination = destinationForToken(owner, token) val phone = userManager.profile?.verifiedPhoneNumber - val email = resolveEmail(phone) + val email = resolveEmail() if (email == null || phone == null) { return Result.failure( @@ -231,7 +231,7 @@ class CoinbaseOnRampController @Inject constructor( val destination = destinationForToken(owner, token) val phone = userManager.profile?.verifiedPhoneNumber - val email = resolveEmail(phone) + val email = resolveEmail() if (email == null || phone == null) { return Result.failure( @@ -294,14 +294,13 @@ class CoinbaseOnRampController @Inject constructor( ) } - private fun resolveEmail(phone: String?): String? { - val verified = userManager.profile?.verifiedEmailAddress - if (verified != null) return verified - - val requireEmail = userFlags.resolvedFlags.value.requireCoinbaseEmailVerification.effectiveValue - if (!requireEmail && phone != null) return "$phone@flipcash.com" - - return null + private fun resolveEmail(): String? { + val profile = userManager.profile + val requireVerification = userFlags.resolvedFlags.value + .requireCoinbaseEmailVerification.effectiveValue + // When verification is required, only a server-verified email is usable. + // Otherwise any email the user has entered (verified or not) is accepted. + return if (requireVerification) profile?.verifiedEmailAddress else profile?.email?.value } private fun destinationForToken(owner: AccountCluster, token: Token): String { diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt index ea74d95da..c0b32f22a 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt @@ -8,6 +8,7 @@ import com.flipcash.app.userflags.ResolvedFlag import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.user.UserManager import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts @@ -22,14 +23,10 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.slot import io.mockk.unmockkStatic import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive import org.junit.After import org.junit.Before import org.junit.Test @@ -118,16 +115,30 @@ class CoinbaseOnRampControllerTest { } } - private fun stubProfile(email: String? = "test@test.com", phone: String? = "+11234567890") { + private fun stubProfile( + email: String? = "test@test.com", + phone: String? = "+11234567890", + emailVerified: Boolean = true, + ) { val profile = UserProfile( displayName = "Test", socialAccounts = emptyList(), - verifiedEmailAddress = email, - verifiedPhoneNumber = phone, + phoneNumber = phone?.let { VerifiableContactMethod(it, verified = true) }, + email = email?.let { VerifiableContactMethod(it, verified = emailVerified) }, ) every { userManager.profile } returns profile } + private fun stubRequireEmailVerification(required: Boolean) { + val resolvedFlags = mockk(relaxed = true) { + every { requireCoinbaseEmailVerification } returns ResolvedFlag( + serverValue = required, + override = FieldOverride.None, + ) + } + every { userFlags.resolvedFlags } returns MutableStateFlow(resolvedFlags) + } + private fun stubValidUser() { stubAccountCluster() stubProfile() @@ -278,6 +289,57 @@ class CoinbaseOnRampControllerTest { // endregion + // region email resolution (requireCoinbaseEmailVerification flag) + + @Test + fun `uses unverified local email when verification not required`() = runTest { + stubAccountCluster() + stubRequireEmailVerification(false) + stubProfile(email = "entered@test.com", emailVerified = false) + // Fail cleanly at the JWT step so we only assert the email gate was passed. + coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.failure(RuntimeException("no jwt")) + + val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD)) + // Passed the email gate (fails later at JWT, not with VerificationRequired). + assertTrue(result.exceptionOrNull() !is OnRampAuthError.VerificationRequired) + } + + @Test + fun `returns VerificationRequired when verification not required and no email`() = runTest { + stubAccountCluster() + stubRequireEmailVerification(false) + stubProfile(email = null) + + val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD)) + assertIs(result.exceptionOrNull()) + assertTrue((result.exceptionOrNull() as OnRampAuthError.VerificationRequired).email) + } + + @Test + fun `requires verified email when verification required`() = runTest { + stubAccountCluster() + stubRequireEmailVerification(true) + // An entered-but-unverified email is NOT sufficient when verification is required. + stubProfile(email = "entered@test.com", emailVerified = false) + + val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD)) + assertIs(result.exceptionOrNull()) + assertTrue((result.exceptionOrNull() as OnRampAuthError.VerificationRequired).email) + } + + @Test + fun `uses verified email when present regardless of flag`() = runTest { + stubAccountCluster() + stubRequireEmailVerification(true) + stubProfile(email = "verified@test.com", emailVerified = true) + coEvery { jwtProvider.provideJwtForEndpoint(any(), any()) } returns Result.failure(RuntimeException("no jwt")) + + val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD)) + assertTrue(result.exceptionOrNull() !is OnRampAuthError.VerificationRequired) + } + + // endregion + } class CoinbaseOnRampApiErrorParseTest { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index c53fb7730..40f0181aa 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -2,6 +2,7 @@ package com.flipcash.app.persistence.converters import androidx.room.TypeConverter import com.flipcash.app.persistence.entities.MessageStatus +import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.models.chat.MediaItem import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -139,8 +140,8 @@ data class MessagePointerSerialized( data class UserProfileSerialized( val displayName: String?, val socialAccounts: List, - val verifiedPhoneNumber: String?, - val verifiedEmailAddress: String?, + val phoneNumber: VerifiableContactMethod?, + val email: VerifiableContactMethod?, ) @Serializable diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index 137d60b9c..e9e05088c 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.persistence.converters +import com.flipcash.services.models.VerifiableContactMethod import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -120,8 +121,8 @@ class ChatTypeConvertersTest { followerCount = 100, ) ), - verifiedPhoneNumber = "+1234567890", - verifiedEmailAddress = "alice@example.com", + phoneNumber = VerifiableContactMethod("+1234567890", verified = true), + email = VerifiableContactMethod("alice@example.com", verified = true), ) val serialized = converter.toUserProfile(original) val deserialized = converter.fromUserProfile(serialized) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index f87a62b9d..2ffae589f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -151,8 +151,8 @@ class ChatEntityMapper @Inject constructor() { userProfile = entity.userProfileJson?.toDomain() ?: UserProfile( displayName = null, socialAccounts = emptyList(), - verifiedPhoneNumber = null, - verifiedEmailAddress = null, + phoneNumber = null, + email = null, ), pointers = entity.pointersJson?.map { it.toDomain() } ?: emptyList(), ) @@ -273,15 +273,15 @@ private fun MessagePointerSerialized.toDomain(): MessagePointer = MessagePointer private fun UserProfile.toSerialized(): UserProfileSerialized = UserProfileSerialized( displayName = displayName, socialAccounts = socialAccounts.map { it.toSerialized() }, - verifiedPhoneNumber = verifiedPhoneNumber, - verifiedEmailAddress = verifiedEmailAddress, + phoneNumber = phoneNumber, + email = email, ) private fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( displayName = displayName, socialAccounts = socialAccounts.map { it.toDomain() }, - verifiedPhoneNumber = verifiedPhoneNumber, - verifiedEmailAddress = verifiedEmailAddress, + phoneNumber = phoneNumber, + email = email, ) private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) { diff --git a/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt b/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt index 42593c21d..4457fbfb3 100644 --- a/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt +++ b/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt @@ -10,6 +10,7 @@ import androidx.datastore.preferences.preferencesDataStoreFile import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.user.UserManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -77,22 +78,22 @@ class ProfileCoordinator @Inject constructor( private data class CachedProfile( val displayName: String? = null, val socialAccounts: List = emptyList(), - val verifiedPhoneNumber: String? = null, - val verifiedEmailAddress: String? = null, + val phoneNumber: VerifiableContactMethod? = null, + val email: VerifiableContactMethod? = null, ) { fun toDomain(): UserProfile = UserProfile( displayName = displayName, socialAccounts = socialAccounts.mapNotNull { it.toDomain() }, - verifiedPhoneNumber = verifiedPhoneNumber, - verifiedEmailAddress = verifiedEmailAddress, + phoneNumber = phoneNumber, + email = email, ) companion object { fun fromDomain(profile: UserProfile): CachedProfile = CachedProfile( displayName = profile.displayName, socialAccounts = profile.socialAccounts.map { CachedSocialAccount.fromDomain(it) }, - verifiedPhoneNumber = profile.verifiedPhoneNumber, - verifiedEmailAddress = profile.verifiedEmailAddress, + phoneNumber = profile.phoneNumber, + email = profile.email, ) } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index d2b244877..feec88bc1 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -305,7 +305,11 @@ class SwapViewModel @Inject constructor( data class OnInitialAmountProvided(val amount: Fiat) : Event data object OnInitialAmountEntered : Event - data class OnVerificationNeeded(val phone: Boolean, val email: Boolean) : Event + data class OnVerificationNeeded( + val phone: Boolean, + val email: Boolean, + val skipEmailVerification: Boolean = false, + ) : Event data object Exit : Event data object PresentDepositOptions : Event data class OpenScreen(val screen: AppRoute) : Event @@ -881,10 +885,23 @@ class SwapViewModel @Inject constructor( val profile = userManager.profile val needsPhone = profile?.verifiedPhoneNumber == null - val requireEmail = userFlags.resolvedFlags.value.requireCoinbaseEmailVerification.effectiveValue - val needsEmail = requireEmail && profile?.verifiedEmailAddress == null + val requireVerification = userFlags.resolvedFlags.value.requireCoinbaseEmailVerification.effectiveValue + // Email entry is always required; verification (server round-trip) + // only when the flag is on. Off → any entered email counts. + val hasEmail = if (requireVerification) { + profile?.verifiedEmailAddress != null + } else { + profile?.email?.value != null + } + val needsEmail = !hasEmail if (needsPhone || needsEmail) { - dispatchEvent(Event.OnVerificationNeeded(needsPhone, needsEmail)) + dispatchEvent( + Event.OnVerificationNeeded( + needsPhone, + needsEmail, + skipEmailVerification = !requireVerification, + ) + ) return@onEach } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt index f900d458b..ee5fcd143 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt @@ -1,6 +1,7 @@ package com.flipcash.services.controllers import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.repository.ContactVerificationRepository import com.flipcash.services.user.UserManager import javax.inject.Inject @@ -32,13 +33,30 @@ class ContactVerificationController @Inject constructor( return repository.unlink(method, owner).onSuccess { val profile = userManager.profile ?: return@onSuccess val updated = when (method) { - is ContactMethod.Phone -> profile.copy(verifiedPhoneNumber = null) - is ContactMethod.Email -> profile.copy(verifiedEmailAddress = null) + is ContactMethod.Phone -> profile.copy(phoneNumber = null) + is ContactMethod.Email -> profile.copy(email = null) } userManager.set(updated) } } + /** + * Records a contact on the local profile as *unverified*, without any server + * round-trip. Used when server verification is skipped but we still need the + * value available (e.g. a Coinbase on-ramp email the user entered). The change + * is persisted locally by [com.flipcash.shared.profile.ProfileCoordinator]. + */ + fun setLocalUnverified(method: ContactMethod) { + val profile = userManager.profile ?: return + val updated = when (method) { + is ContactMethod.Phone -> + profile.copy(phoneNumber = VerifiableContactMethod(method.phoneNumber, verified = false)) + is ContactMethod.Email -> + profile.copy(email = VerifiableContactMethod(method.emailAddress, verified = false)) + } + userManager.set(updated) + } + suspend fun linkForPayment(method: ContactMethod.Phone): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 32b9218be..0d48c9e54 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -22,8 +22,17 @@ class ProfileController @Inject constructor( val accountId = userManager.accountId ?: return Result.failure(Throwable("No account id in UserManager")) return getProfileForUser(accountId) + .map { server -> + // Preserve a locally-entered *unverified* contact when the server + // response omits it, so it survives profile refreshes until the user + // completes verification (or the server starts returning it). + val local = userManager.profile + server.copy( + phoneNumber = server.phoneNumber ?: local?.phoneNumber?.takeUnless { it.verified }, + email = server.email ?: local?.email?.takeUnless { it.verified }, + ) + } .onSuccess { - println("profile has ${it.socialAccounts.count()} social accounts, phone ${it.verifiedPhoneNumber != null}, email ${it.verifiedEmailAddress != null}") trace( tag = "Profile", message = "Updated user profile", diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt index cd63eefa7..566df85d2 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt @@ -4,6 +4,7 @@ import com.codeinc.flipcash.gen.profile.v1.Model import com.codeinc.flipcash.gen.profile.v1.emailAddressOrNull import com.codeinc.flipcash.gen.profile.v1.phoneNumberOrNull import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod import com.getcode.opencode.mapper.Mapper import javax.inject.Inject @@ -14,8 +15,9 @@ class UserProfileMapper @Inject constructor( return UserProfile( displayName = from.displayName, socialAccounts = from.socialProfilesList.mapNotNull { socialMapper.map(it) }, - verifiedPhoneNumber = from.phoneNumberOrNull?.value, - verifiedEmailAddress = from.emailAddressOrNull?.value, + // A value is only present on the server profile once verified. + phoneNumber = from.phoneNumberOrNull?.value?.let { VerifiableContactMethod(it, verified = true) }, + email = from.emailAddressOrNull?.value?.let { VerifiableContactMethod(it, verified = true) }, ) } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 61969dfc3..4c66371bf 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -13,6 +13,7 @@ import com.flipcash.services.models.NotificationPayload import com.flipcash.services.models.PagingToken import com.flipcash.services.models.Substitution import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.models.chat.ChatEvent import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember @@ -336,8 +337,8 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { UserProfile( displayName = displayName, socialAccounts = emptyList(), - verifiedPhoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }, - verifiedEmailAddress = emailAddress.value.takeIf { it.isNotEmpty() }, + phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, ) }, pointers = member.pointersList.map { it.toPointer() }, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt index 126bb7961..1647f4a9b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt @@ -3,9 +3,15 @@ package com.flipcash.services.models data class UserProfile( val displayName: String?, val socialAccounts: List, - val verifiedPhoneNumber: String?, - val verifiedEmailAddress: String?, -) + val phoneNumber: VerifiableContactMethod?, + val email: VerifiableContactMethod?, +) { + /** The phone number only when it has been verified — backwards-compatible accessor. */ + val verifiedPhoneNumber: String? get() = phoneNumber?.takeIf { it.verified }?.value + + /** The email address only when it has been verified — backwards-compatible accessor. */ + val verifiedEmailAddress: String? get() = email?.takeIf { it.verified }?.value +} sealed interface SocialAccount { val id: String diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt new file mode 100644 index 000000000..a5a699971 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt @@ -0,0 +1,14 @@ +package com.flipcash.services.models + +import kotlinx.serialization.Serializable + +/** + * A contact value (phone number or email address) paired with whether it has been + * verified with the server. An unverified contact is one the user entered locally + * (e.g. when server verification is skipped) but which has not been confirmed. + */ +@Serializable +data class VerifiableContactMethod( + val value: String, + val verified: Boolean, +) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ContactVerificationControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ContactVerificationControllerTest.kt new file mode 100644 index 000000000..1c08a5d29 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ContactVerificationControllerTest.kt @@ -0,0 +1,60 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.repository.ContactVerificationRepository +import com.flipcash.services.user.UserManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class ContactVerificationControllerTest { + + private val repository = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val controller = ContactVerificationController(repository, userManager) + + private fun emptyProfile() = UserProfile( + displayName = "Test", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + @Test + fun `setLocalUnverified writes an unverified email to the profile`() { + every { userManager.profile } returns emptyProfile() + + controller.setLocalUnverified(ContactMethod.Email("e@test.com")) + + verify { + userManager.set( + match { it.email == VerifiableContactMethod("e@test.com", verified = false) } + ) + } + } + + @Test + fun `setLocalUnverified writes an unverified phone to the profile`() { + every { userManager.profile } returns emptyProfile() + + controller.setLocalUnverified(ContactMethod.Phone("+15551234567")) + + verify { + userManager.set( + match { it.phoneNumber == VerifiableContactMethod("+15551234567", verified = false) } + ) + } + } + + @Test + fun `setLocalUnverified is a no-op when there is no profile`() { + every { userManager.profile } returns null + + controller.setLocalUnverified(ContactMethod.Email("e@test.com")) + + verify(exactly = 0) { userManager.set(any()) } + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt index 8d2c9e1ae..ba41c7e96 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt @@ -4,6 +4,7 @@ import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -59,6 +60,7 @@ class ProfileControllerTest { fun `updateUserProfile caches profile on success`() = runTest { stubOwner() every { userManager.accountId } returns listOf(1) + every { userManager.profile } returns null val profile = stubProfile() repository.getProfileResult = Result.success(profile) @@ -70,6 +72,35 @@ class ProfileControllerTest { verify { userManager.set(profile) } } + @Test + fun `updateUserProfile preserves locally-entered unverified contact when server omits it`() = runTest { + stubOwner() + every { userManager.accountId } returns listOf(1) + // Local profile has an unverified email the user entered; server profile has none. + val localEmail = VerifiableContactMethod("entered@test.com", verified = false) + every { userManager.profile } returns stubProfile().copy(email = localEmail) + repository.getProfileResult = Result.success(stubProfile()) + + val result = controller.updateUserProfile() + + assertEquals(localEmail, result.getOrThrow().email) + verify { userManager.set(match { it.email == localEmail }) } + } + + @Test + fun `updateUserProfile does not override a server-provided contact`() = runTest { + stubOwner() + every { userManager.accountId } returns listOf(1) + every { userManager.profile } returns stubProfile() + .copy(email = VerifiableContactMethod("stale@test.com", verified = false)) + val serverEmail = VerifiableContactMethod("verified@test.com", verified = true) + repository.getProfileResult = Result.success(stubProfile().copy(email = serverEmail)) + + val result = controller.updateUserProfile() + + assertEquals(serverEmail, result.getOrThrow().email) + } + @Test fun `updateUserProfile propagates failure without caching`() = runTest { stubOwner() @@ -195,8 +226,8 @@ class ProfileControllerTest { private fun stubProfile() = UserProfile( displayName = "Test User", socialAccounts = emptyList(), - verifiedPhoneNumber = null, - verifiedEmailAddress = null, + phoneNumber = null, + email = null, ) private fun stubTwitterXAccount() = SocialAccount.TwitterX( From 049e4ee82d617597d963d08d27a98cf58c90575f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 8 Jul 2026 13:37:53 -0400 Subject: [PATCH 14/17] fix(profile): properly handle NotFound error for GetProfile replace the local userprofile, honoring any local unverified methods to not stomp them out (e.g email address when verification is off) Signed-off-by: Brandon McAnsh --- .../email/EmailVerificationViewModel.kt | 1 - .../internal/UserProfileScreenContent.kt | 18 ++++--- .../internal/UserProfileViewModel.kt | 19 +++++-- .../app/onramp/CoinbaseOnRampController.kt | 4 +- .../InternalPurchaseMethodController.kt | 9 +++- .../ContactVerificationController.kt | 11 ++++ .../services/controllers/ProfileController.kt | 24 +++++++++ .../com/flipcash/services/user/UserManager.kt | 2 +- .../controllers/ProfileControllerTest.kt | 51 +++++++++++++++++++ 9 files changed, 122 insertions(+), 17 deletions(-) diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt index c633d048b..659dd7cc0 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/email/EmailVerificationViewModel.kt @@ -130,7 +130,6 @@ class EmailVerificationViewModel @Inject constructor( // and complete. The profile write is persisted by ProfileCoordinator. verificationController.setLocalUnverified(ContactMethod.Email(emailAddress)) dispatchEvent(Event.OnEntrySaved) - dispatchEvent(Event.OnSendingCodeChanged()) } } else { handleSendVerificationCode(ContactMethod.Email(emailAddress, computeClientData())) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt index 89c33c03a..220d6fb0f 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt @@ -218,12 +218,18 @@ internal fun UserProfileScreenContent( ) } - else -> ContactMethodRow( - value = email.value, - verified = false, - linkedForPayment = false, - onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) }, - ) + else -> SwipeActionRow( + onDelete = { dispatch(UserProfileViewModel.Event.UnlinkEmailClicked) }, + stateKey = email.value, + resetOnDismiss = true, + ) { + ContactMethodRow( + value = email.value, + verified = false, + linkedForPayment = false, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) }, + ) + } } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt index c557a7e22..340964cc0 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel internal class UserProfileViewModel @Inject constructor( @@ -111,7 +112,7 @@ internal class UserProfileViewModel @Inject constructor( actions = listOf( BottomBarAction(resources.getString(R.string.action_unlinkPhone)) { viewModelScope.launch { - delay(150) + delay(150.milliseconds) unlinkPhone() } } @@ -129,7 +130,7 @@ internal class UserProfileViewModel @Inject constructor( actions = listOf( BottomBarAction(resources.getString(R.string.action_unlinkEmail)) { viewModelScope.launch { - delay(150) + delay(150.milliseconds) unlinkEmail() } } @@ -147,7 +148,7 @@ internal class UserProfileViewModel @Inject constructor( actions = listOf( BottomBarAction(resources.getString(R.string.action_unlinkAccount)) { viewModelScope.launch { - delay(150) + delay(150.milliseconds) unlinkSocialAccount(event.account) } } @@ -235,7 +236,17 @@ internal class UserProfileViewModel @Inject constructor( } private suspend fun unlinkEmail() { - val email = userManager.profile?.verifiedEmailAddress ?: return + val emailMethod = userManager.profile?.email ?: return + val (email, isVerified) = emailMethod + if (!isVerified) { + contactController.removeLocalUnverified(ContactMethod.Email(email)) + BottomBarManager.showSuccess( + title = resources.getString(R.string.prompt_title_emailUnlinked), + message = resources.getString(R.string.prompt_description_emailUnlinked), + ) + return + } + contactController.unlink(ContactMethod.Email(email)) .onFailure { BottomBarManager.showError( diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 97cafac56..405ead4a1 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -350,9 +350,7 @@ class CoinbaseOnRampController @Inject constructor( ) is GetJwtError.PhoneVerificationRequired -> Result.failure( - OnRampAuthError.VerificationRequired( - phone = true - ) + OnRampAuthError.VerificationRequired(phone = true) ) else -> Result.failure(error) diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index 33a83e51e..53789af67 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -23,6 +23,7 @@ import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay @@ -48,7 +49,7 @@ import kotlin.time.Duration.Companion.milliseconds @Singleton class InternalPurchaseMethodController @Inject constructor( features: FeatureFlagController, - userFlags: UserFlagsCoordinator, + private val userFlags: UserFlagsCoordinator, reservesBalanceProvider: ReservesBalanceProvider, exchange: Exchange, private val resources: ResourceHelper, @@ -151,7 +152,11 @@ class InternalPurchaseMethodController @Inject constructor( PurchaseMethod.CoinbaseOnRamp -> { val profile = userManager.profile val needsPhone = profile?.verifiedPhoneNumber == null - val needsEmail = profile?.verifiedEmailAddress == null + val email = profile?.email?.value + val needsEmailVerification = userFlags.resolvedFlags.value + .requireCoinbaseEmailVerification.effectiveValue + val needsEmail = needsEmailVerification || email == null + trace("needsPhone: $needsPhone, needsEmail: $needsEmail, needsEmailVerification: $needsEmailVerification") val swapRoute = AppRoute.Token.Swap( purpose = SwapPurpose.Buy(Mint.usdf, FundingSource.Coinbase), popToRoot = popToRoot, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt index ee5fcd143..cf1af5b3d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt @@ -57,6 +57,17 @@ class ContactVerificationController @Inject constructor( userManager.set(updated) } + fun removeLocalUnverified(method: ContactMethod) { + val profile = userManager.profile ?: return + val updated = when (method) { + is ContactMethod.Phone -> + profile.copy(phoneNumber = null) + is ContactMethod.Email -> + profile.copy(email = null) + } + userManager.set(updated) + } + suspend fun linkForPayment(method: ContactMethod.Phone): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 0d48c9e54..11cfea6eb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -1,5 +1,6 @@ package com.flipcash.services.controllers +import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.LinkingToken import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest @@ -31,6 +32,29 @@ class ProfileController @Inject constructor( phoneNumber = server.phoneNumber ?: local?.phoneNumber?.takeUnless { it.verified }, email = server.email ?: local?.email?.takeUnless { it.verified }, ) + }.recoverCatching { error -> + // The server has no profile for this user (NotFound). Any verified + // contacts, display name, or social accounts were server-owned and no + // longer exist, but the user may have locally entered a phone/email + // that hasn't been verified yet. Preserve those unverified contacts so + // they survive the refresh; otherwise there's nothing to keep. + if (error !is GetUserProfileError.NotFound) throw error + + val local = userManager.profile + val unverifiedPhone = local?.phoneNumber?.takeUnless { it.verified } + val unverifiedEmail = local?.email?.takeUnless { it.verified } + + if (unverifiedPhone == null && unverifiedEmail == null) { + userManager.set(userProfile = null) + throw error + } + + UserProfile( + displayName = null, + socialAccounts = emptyList(), + phoneNumber = unverifiedPhone, + email = unverifiedEmail, + ) } .onSuccess { trace( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt index 6e13b3ac2..291042b43 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt @@ -179,7 +179,7 @@ class UserManager @Inject constructor( _state.update { it.copy(pushToken = pushToken) } } - fun set(userProfile: UserProfile) { + fun set(userProfile: UserProfile?) { _state.update { it.copy(userProfile = userProfile) } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt index ba41c7e96..eb06071c2 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt @@ -1,5 +1,6 @@ package com.flipcash.services.controllers +import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest @@ -101,6 +102,56 @@ class ProfileControllerTest { assertEquals(serverEmail, result.getOrThrow().email) } + @Test + fun `updateUserProfile preserves local unverified contacts when server returns NotFound`() = runTest { + stubOwner() + every { userManager.accountId } returns listOf(1) + val localEmail = VerifiableContactMethod("entered@test.com", verified = false) + val localPhone = VerifiableContactMethod("+15551234567", verified = false) + every { userManager.profile } returns stubProfile() + .copy(email = localEmail, phoneNumber = localPhone) + repository.getProfileResult = Result.failure(GetUserProfileError.NotFound()) + + val result = controller.updateUserProfile() + + assertTrue(result.isSuccess) + assertEquals(localEmail, result.getOrThrow().email) + assertEquals(localPhone, result.getOrThrow().phoneNumber) + verify { + userManager.set( + match { it.email == localEmail && it.phoneNumber == localPhone } + ) + } + verify(exactly = 0) { userManager.set(null as UserProfile?) } + } + + @Test + fun `updateUserProfile drops verified local contacts on NotFound`() = runTest { + stubOwner() + every { userManager.accountId } returns listOf(1) + every { userManager.profile } returns stubProfile() + .copy(email = VerifiableContactMethod("verified@test.com", verified = true)) + repository.getProfileResult = Result.failure(GetUserProfileError.NotFound()) + + val result = controller.updateUserProfile() + + assertTrue(result.isFailure) + verify { userManager.set(null as UserProfile?) } + } + + @Test + fun `updateUserProfile clears cache on NotFound with no local contacts`() = runTest { + stubOwner() + every { userManager.accountId } returns listOf(1) + every { userManager.profile } returns null + repository.getProfileResult = Result.failure(GetUserProfileError.NotFound()) + + val result = controller.updateUserProfile() + + assertTrue(result.isFailure) + verify { userManager.set(null as UserProfile?) } + } + @Test fun `updateUserProfile propagates failure without caching`() = runTest { stubOwner() From 751422dc8a071dbee53a6143a5d8cae2c507a548 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 8 Jul 2026 16:21:16 -0400 Subject: [PATCH 15/17] feat: present contextual modal for chat/send for the user to purchase a currency post adding money Chat/Send both require a launchpad/community currency (currently since GiveUsdf isn't enabled yet) so Adding Money doesn't unblock those flows. Now once they've added money (USDC -> USDF) we prompt them to discover and purchase a currency with their new funds. Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 2 + .../flipcash/app/messenger/ChatFlowScreen.kt | 18 ++++- .../app/messenger/internal/ChatViewModel.kt | 75 ++++++++++++------- .../flipcash/app/scanner/internal/Scanner.kt | 17 ++--- .../flipcash/app/session/SessionController.kt | 6 ++ .../session/internal/RealSessionController.kt | 6 ++ .../internal/delegates/DepositDelegate.kt | 52 ++++++++----- .../flipcash/app/tokens/TokenCoordinator.kt | 9 ++- 8 files changed, 126 insertions(+), 59 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 5c0e5ebd3..c9de2656a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -255,6 +255,8 @@ Tap above to Add Cash to your wallet You don\'t have any cash yet.\nTap below to add cash to your wallet No Balance Yet + No Community Currencies Yet + Discover and buy a currency, or create your own Insufficient Balance Add more money to create a currency Add more money, or enter a smaller amount diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index dcd6cf84d..b38365807 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -13,6 +13,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatSendResult import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.getcode.navigation.annotatedEntry @@ -26,6 +27,7 @@ import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.results.navigateForResult import com.getcode.navigation.results.resultBackNavigator +import com.getcode.navigation.scenes.LocalSheetNavigator import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map @@ -59,8 +61,10 @@ private fun chatEntryProvider( @Composable private fun FlowConversationScreen(identifier: ChatIdentifier) { val viewModel = flowSharedViewModel() - val flowNavigator = rememberFlowNavigator() val navigator = LocalCodeNavigator.current + // The sheet-owning (root) navigator — the one whose back stack holds this chat's + // Main.Sheet and whose pendingSheetDismiss the dismiss animation observes. + val sheetNavigator = LocalSheetNavigator.current LaunchedEffect(viewModel, identifier) { viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(identifier)) @@ -81,9 +85,15 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .map { it.route } - .collect { route -> - flowNavigator.navigate(route) + .collect { (route, asSheet) -> + if (asSheet) { + // Dismiss this chat sheet and open [route] as a fresh sheet. + // openAsSheet on the sheet-owning navigator animates the current + // sheet closed (via pendingSheetDismiss) before opening the new one. + sheetNavigator?.openAsSheet(route) + } else { + navigator.navigate(route) + } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 2cadd6f95..2f0210ad3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -143,7 +143,7 @@ internal class ChatViewModel @Inject constructor( data class NavigateToAmountEntry(val contact: DeviceContact) : Event data object PresentDepositOptions : Event - data class OpenScreen(val route: AppRoute): Event + data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event data object OnConfirmRequested : Event data class OnSendRequested( val amount: Fiat, @@ -519,34 +519,13 @@ internal class ChatViewModel @Inject constructor( eventFlow.filterIsInstance() .mapNotNull { stateFlow.value.chattingWith } .onEach { contact -> + val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX) if (!tokenCoordinator.hasGiveableBalance()) { - val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX) - val message = if (addMoney) { - resources.getString(R.string.description_noBalanceYetToSend) + if (!tokenCoordinator.hasBalance()) { + presentAddMoney(addMoney) } else { - resources.getString(R.string.description_noBalanceYetDiscover) + presentDiscoverCurrencies() } - val cta = if (addMoney) { - resources.getString(R.string.action_addMoney) - } else { - resources.getString(R.string.action_discover) - } - BottomBarManager.showInfo( - title = resources.getString(R.string.title_noBalanceYet), - message = message, - actions = listOf( - BottomBarAction( - text = cta - ) { - if (addMoney) { - dispatchEvent(Event.PresentDepositOptions) - } else { - dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery)) - } - }, - ), - showCancel = true, - ) return@onEach } amountDelegate.reset() @@ -706,6 +685,50 @@ internal class ChatViewModel @Inject constructor( } } + private fun presentAddMoney(addMoneyEnabled: Boolean) { + val message = if (addMoneyEnabled) { + resources.getString(R.string.description_noBalanceYetToSend) + } else { + resources.getString(R.string.description_noBalanceYetDiscover) + } + val cta = if (addMoneyEnabled) { + resources.getString(R.string.action_addMoney) + } else { + resources.getString(R.string.action_discover) + } + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noBalanceYet), + message = message, + actions = listOf( + BottomBarAction( + text = cta + ) { + if (addMoneyEnabled) { + dispatchEvent(Event.PresentDepositOptions) + } else { + dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery)) + } + }, + ), + showCancel = true, + ) + } + + private fun presentDiscoverCurrencies() { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noCommunityCurrenciesYet), + message = resources.getString(R.string.description_noCommunityCurrenciesYet), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_discoverCurrencies) + ) { + dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) + }, + ), + showCancel = true, + ) + } + companion object { val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 1dac9268e..8812d15ab 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -13,14 +12,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.analytics.rememberAnalytics -import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.extensions.navigateAll +import com.flipcash.app.core.extensions.openAsSheet +import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.BillContainer import com.flipcash.app.session.LocalSessionController -import com.flipcash.features.scanner.R import com.getcode.libs.code.detection.CodeScanResult -import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.biometrics.LocalBiometricsState import com.getcode.ui.components.OnLifecycleEvent @@ -32,10 +32,6 @@ import com.getcode.utils.ErrorUtils import com.kik.kikx.kikcodes.implementation.KikCodeResult import dev.theolm.rinku.DeepLink import timber.log.Timber -import com.flipcash.app.core.extensions.navigateAll -import com.flipcash.app.core.extensions.openAsSheet -import com.flipcash.app.core.navigation.DeeplinkType -import com.getcode.manager.BottomBarAction @Composable internal fun Scanner() { @@ -86,12 +82,15 @@ internal fun Scanner() { when (it) { ScannerDecorItem.Give -> { // only allow navigation to give when there is something to give + // Zero balance -> prompt to add money; otherwise the user has funds + // but nothing giveable -> prompt to discover a currency. + // presentDepositOptions picks the right prompt based on balance. if (!state.hasGiveableBalance) { session.presentDepositOptions { route -> navigator.openAsSheet(route) } - return@BillContainer + return@BillContainer } } else -> Unit diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index dc7980984..669878ad9 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -34,6 +34,11 @@ interface CashLinkOperations { } interface DepositOperations { + /** + * Presents the appropriate "you can't give yet" prompt based on the user's balance: + * an add-money prompt when the wallet is empty, or a discover-currencies prompt when + * the user has funds (e.g. reserves) but nothing giveable. + */ fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)? = null) } @@ -46,6 +51,7 @@ interface SessionController : BillOperations, CodeScanOperations, CashLinkOperat data class SessionState( val vibrateOnScan: Boolean = false, val hasGiveableBalance: Boolean = false, + val hasBalance: Boolean = false, val logScanTimes: Boolean = false, val showNetworkOffline: Boolean = false, val autoStartCamera: Boolean? = true, diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 4dfafd8a2..e7c302641 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -222,6 +222,12 @@ class RealSessionController @Inject constructor( .onEach { hasBalance -> stateHolder.update { it.copy(hasGiveableBalance = hasBalance) } } .launchIn(scope) + tokenCoordinator.tokenBalances + .map { tokenCoordinator.hasBalance() } + .distinctUntilChanged() + .onEach { hasBalance -> stateHolder.update { it.copy(hasBalance = hasBalance) } } + .launchIn(scope) + tokenCoordinator.tokens .onEach { tokens -> stateHolder.update { it.copy(tokens = tokens) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt index 724e212fe..01479b215 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt @@ -51,34 +51,35 @@ class DepositDelegate @Inject constructor( } override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) { - val depositFirstUx = stateHolder.current.addMoneyUx + // Prompt to add money only when the wallet is empty and add-money is available. + // Otherwise the user has funds (e.g. reserves) but nothing giveable — or can't + // add money at all — so steer them to discover/buy a currency. + val addMoneyEnabled = stateHolder.current.addMoneyUx + val hasBalance = stateHolder.current.hasBalance - val message = if (depositFirstUx) { - resources.getString(R.string.description_noBalanceYetToGive) + if (!hasBalance) { + presentAddMoney(addMoneyEnabled, onRoute) } else { - resources.getString(R.string.description_noBalanceYetDiscover) - } - val cta = if (depositFirstUx) { - resources.getString(R.string.action_addMoney) - } else { - resources.getString(R.string.action_discoverCurrencies) + presentDiscoverCurrencies(onRoute) } + } + private fun presentAddMoney(addMoneyEnabled: Boolean, onRoute: ((AppRoute) -> Unit)?) { BottomBarManager.showInfo( title = resources.getString(R.string.title_noBalanceYet), - message = message, + message = if (addMoneyEnabled) { + resources.getString(R.string.description_noBalanceYetToGive) + } else { + resources.getString(R.string.description_noBalanceYetDiscover) + }, actions = listOf( BottomBarAction( - text = cta + text = resources.getString(R.string.action_addMoney) ) { scope.launch { - if (depositFirstUx) { - val destination = purchaseMethodController.presentDepositOptions(popToRoot = true) - if (destination != null) { - onRoute?.invoke(destination) - } - } else { - onRoute?.invoke(AppRoute.Token.Discovery) + val destination = purchaseMethodController.presentDepositOptions(popToRoot = true) + if (destination != null) { + onRoute?.invoke(destination) } } }, @@ -87,6 +88,21 @@ class DepositDelegate @Inject constructor( ) } + private fun presentDiscoverCurrencies(onRoute: ((AppRoute) -> Unit)?) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noCommunityCurrenciesYet), + message = resources.getString(R.string.description_noCommunityCurrenciesYet), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_discoverCurrencies) + ) { + onRoute?.invoke(AppRoute.Token.Discovery) + }, + ), + showCancel = true, + ) + } + fun sweepIfNeeded() { val owner = userManager.accountCluster ?: return if (userManager.authState.canAccessAuthenticatedApis) { diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 49359b8a5..f9457b27d 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -193,16 +193,21 @@ class TokenCoordinator @Inject constructor( // region Public API — Balances + /** Can I hand money to a person right now? */ suspend fun hasGiveableBalance(): Boolean { // USDF is only giveable when the GiveUsdf flag is on; otherwise a USDF-only // balance must not count as giveable. val canGiveUsdf = featureFlags.get(FeatureFlag.GiveUsdf) - return _state.value.balances - .filterKeys { canGiveUsdf || it != Mint.usdf } + val state = _state.value + return state.balances.filterKeys { canGiveUsdf || it != Mint.usdf } .values .any { it.hasDisplayableValue } } + /** Do I have any balance at all, including reserves? */ + suspend fun hasBalance(): Boolean = + _state.value.balances.values.any { it.hasDisplayableValue } + fun balanceForToken(token: Token): Fiat = _state.value.balances[token.address] ?: Fiat.Zero fun balanceForToken(tokenAddress: Mint): Flow = From 8126474c20e3e160d9aacf97c2353b3942f1553c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 8 Jul 2026 16:23:38 -0400 Subject: [PATCH 16/17] fix(chat): prevent duplicate event streams from concurrent open open()/close() did a non-atomic check-then-act on streamRef while being invoked from multiple triggers (login, lifecycle onStart, network reconnect, feature-flag, heartbeat) on the multi-threaded IO scope. Two threads could both observe a null ref and each open a gRPC stream, leaving a second orphaned stream whose reconnect loop kept running. The server rejects the duplicate with ABORTED 'stream already exists' and the two streams knock each other offline in a persistent duel. Serialize open/close under a lock so only one stream can exist, and guard onError to clear only its own ref. Adds a 32-thread concurrency test. --- .../controllers/EventStreamingController.kt | 27 +++++++--- .../EventStreamingControllerTest.kt | 51 +++++++++++++++++-- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt index 9bac417ea..e6e46c3f5 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt @@ -20,13 +20,20 @@ class EventStreamingController @Inject constructor( private val _chatUpdates = Channel(capacity = Channel.UNLIMITED) val chatUpdates: Flow = _chatUpdates.receiveAsFlow() + // Guards all reads/writes of [streamRef]. open()/close() are invoked + // concurrently from multiple triggers (login, lifecycle onStart, network + // reconnect, feature-flag, heartbeat) on the multi-threaded IO scope, so + // the check-then-act on streamRef must be atomic — otherwise two threads + // both observe a null ref and each open a stream, producing a second + // orphaned gRPC stream and the server's "ABORTED: stream already exists". + private val lock = Any() private var streamRef: EventStreamReference? = null - val isConnected: Boolean get() = streamRef != null + val isConnected: Boolean get() = synchronized(lock) { streamRef != null } - val isStreamActive: Boolean get() = streamRef?.isActive == true + val isStreamActive: Boolean get() = synchronized(lock) { streamRef?.isActive == true } - fun open(scope: CoroutineScope): Boolean { + fun open(scope: CoroutineScope): Boolean = synchronized(lock) { if (streamRef != null) { trace("EventStreamingController: Stream already open, skipping") return true @@ -37,7 +44,8 @@ class EventStreamingController @Inject constructor( return false } - streamRef = repository.openEventStream( + lateinit var openedRef: EventStreamReference + openedRef = repository.openEventStream( scope = scope, owner = owner, onEvent = { update -> @@ -47,15 +55,18 @@ class EventStreamingController @Inject constructor( onError = { error -> trace("EventStreamingController: Stream error: ${error.message}") // Clear the ref so the next heartbeat, lifecycle, or network - // event creates a fresh stream. The framework guarantees this - // fires only once per stream, so no risk of clearing a newer ref. - streamRef = null + // event creates a fresh stream. Only clear if it is still THIS + // stream — never null out a newer ref opened after us. + synchronized(lock) { + if (streamRef === openedRef) streamRef = null + } }, ) + streamRef = openedRef return true } - fun close() { + fun close() = synchronized(lock) { streamRef?.destroy() streamRef = null } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/EventStreamingControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/EventStreamingControllerTest.kt index 24b2eedd8..1d73efd87 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/EventStreamingControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/EventStreamingControllerTest.kt @@ -11,10 +11,15 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull @OptIn(ExperimentalCoroutinesApi::class) @@ -69,11 +74,40 @@ class EventStreamingControllerTest { fun `chatUpdates flow is accessible`() { assertNotNull(controller.chatUpdates) } + + @Test + fun `concurrent open calls create only one stream`() { + stubOwner() + val scope = CoroutineScope(Dispatchers.Default) + val threadCount = 32 + val startLatch = CountDownLatch(1) + val doneLatch = CountDownLatch(threadCount) + + repeat(threadCount) { + thread { + startLatch.await() + controller.open(scope) + doneLatch.countDown() + } + } + + // Release all threads at once to maximize the check-then-act race. + startLatch.countDown() + doneLatch.await(5, TimeUnit.SECONDS) + + // Exactly one gRPC stream must be opened; opening two produces the + // server-side "ABORTED: stream already exists" duel. + assertEquals(1, repository.openCount) + } } private class FakeEventStreamingRepository : EventStreamingRepository { - var opened = false + private val lock = Any() + var openCount = 0 + private set + val opened: Boolean get() = openCount > 0 var lastStreamRef: EventStreamReference? = null + private set override fun openEventStream( scope: CoroutineScope, @@ -81,9 +115,16 @@ private class FakeEventStreamingRepository : EventStreamingRepository { onEvent: (ChatUpdate) -> Unit, onError: (Throwable) -> Unit, ): EventStreamReference { - opened = true - val ref = mockk(relaxed = true) - lastStreamRef = ref - return ref + // Widen the controller's check-then-act window so an unsynchronized + // open() lets multiple threads reach here concurrently. Mock creation + // and counting are serialized here so the assertion measures the + // controller's concurrency, not mockk's. + Thread.sleep(20) + return synchronized(lock) { + openCount++ + val ref = mockk(relaxed = true) + lastStreamRef = ref + ref + } } } From 0e82f817b570d549a9f6a81b6fdd248abab11133 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 8 Jul 2026 16:31:51 -0400 Subject: [PATCH 17/17] chore(swap): await coinbase order processing before awaiting a balance update Signed-off-by: Brandon McAnsh --- .../flipcash/app/tokens/SwapEntryScreen.kt | 2 +- .../app/onramp/CoinbaseOnRampController.kt | 44 +++++++++++++++- .../app/onramp/CoinbaseOnRampHandler.kt | 2 +- .../app/onramp/CoinbaseOnRampState.kt | 18 ++++++- .../flipcash/app/tokens/UsdcDepositSweep.kt | 5 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 52 +++++++++++++++---- .../app/tokens/UsdcDepositSweepTest.kt | 2 - .../app/tokens/ui/SwapViewModelErrorTest.kt | 37 +++++++++++++ 8 files changed, 141 insertions(+), 21 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt index 1158b89e6..fc12fd267 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapEntryScreen.kt @@ -162,7 +162,7 @@ internal fun SwapEntryScreen( } is CoinbaseOnRampCompletion.DepositSubmitted -> { viewModel.dispatchEvent(SwapViewModel.Event.UpdateProcessingState(loading = true)) - viewModel.dispatchEvent(SwapViewModel.Event.DepositSubmitted) + viewModel.dispatchEvent(SwapViewModel.Event.DepositSubmitted(completion.orderId)) flowNavigator.navigateTo(SwapStep.Processing) } } diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 405ead4a1..525bee8bc 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -35,6 +35,7 @@ import com.getcode.utils.ErrorUtils import com.getcode.utils.NotifiableError import com.getcode.utils.trace import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -48,6 +49,12 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonIgnoreUnknownKeys import retrofit2.HttpException import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +// Coinbase settles the on-chain send after payment; give delivery a generous window +// (~5 min at 5s intervals) before falling back to the session's foreground sweep. +private val DELIVERY_POLL_INTERVAL = 5.seconds +private const val DELIVERY_POLL_MAX_ATTEMPTS = 60 sealed class PurchaseGate : Throwable() { data class WebViewWarning(val channel: WebViewChannel) : PurchaseGate() @@ -94,11 +101,46 @@ class CoinbaseOnRampController @Inject constructor( val current = _state.value if (current is CoinbaseOnRampState.Paying) { _state.update { - CoinbaseOnRampState.Completed(current.swapId, current.token, current.amount) + CoinbaseOnRampState.Completed( + current.swapId, + current.token, + current.amount, + orderId = current.order.orderId, + ) } } } + /** + * Polls the order until Coinbase confirms the on-chain send (the order reports a + * `txHash`) or it reaches a terminal failure. Payment success only means the fiat + * side cleared; the asset lands on-chain later, while the order is PROCESSING. Sweeping + * before that would poll an empty ATA, so callers wait on this first. + */ + suspend fun awaitOrderDelivered(orderId: String): OrderDeliveryResult { + repeat(DELIVERY_POLL_MAX_ATTEMPTS) { + val order = lookupOrder(orderId).getOrNull() + if (order != null) { + trace( + tag = "OnRamp", + message = "Order $orderId status=${order.status} txHash=${order.txHash}", + ) + order.txHash?.let { return OrderDeliveryResult.Delivered(it) } + if (order.status.isTerminalFailureStatus()) return OrderDeliveryResult.Failed + } + delay(DELIVERY_POLL_INTERVAL) + } + return OrderDeliveryResult.TimedOut + } + + private fun String.isTerminalFailureStatus(): Boolean { + val status = uppercase() + return status.contains("FAILED") || + status.contains("CANCEL") || + status.contains("EXPIRE") || + status.contains("DECLINE") + } + fun onPaymentFailure(error: CoinbaseOnRampWebError) { _state.update { CoinbaseOnRampState.Failed(error) } } diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt index 1115b7133..a289d76f6 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt @@ -41,7 +41,7 @@ fun CoinbaseOnRampHandler( val completion = if (swapId != null) { CoinbaseOnRampCompletion.SwapSubmitted(swapId) } else { - CoinbaseOnRampCompletion.DepositSubmitted + CoinbaseOnRampCompletion.DepositSubmitted(current.orderId) } controller.emitCompletion(completion) controller.reset() diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt index 2c725ea7b..665f9c0f7 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt @@ -17,11 +17,25 @@ data class OnrampOrder( sealed interface CoinbaseOnRampState { data object Idle : CoinbaseOnRampState data class Paying(val order: OnrampOrder, val token: Token, val amount: VerifiedFiat, val swapId: SwapId? = null) : CoinbaseOnRampState - data class Completed(val swapId: SwapId?, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState + data class Completed(val swapId: SwapId?, val token: Token, val amount: VerifiedFiat, val orderId: String) : CoinbaseOnRampState data class Failed(val error: CoinbaseOnRampWebError) : CoinbaseOnRampState } sealed interface CoinbaseOnRampCompletion { data class SwapSubmitted(val swapId: SwapId) : CoinbaseOnRampCompletion - data object DepositSubmitted : CoinbaseOnRampCompletion + data class DepositSubmitted(val orderId: String) : CoinbaseOnRampCompletion +} + +/** + * Outcome of waiting for a Coinbase order's on-chain crypto delivery. After the + * payment succeeds the order sits in PROCESSING until Coinbase actually sends the + * asset on-chain; the send is only confirmed once the order reports a `txHash`. + */ +sealed interface OrderDeliveryResult { + /** The send was confirmed on-chain (the order reported a transaction hash). */ + data class Delivered(val txHash: String) : OrderDeliveryResult + /** The order reached a terminal failed/cancelled state. */ + data object Failed : OrderDeliveryResult + /** Delivery did not confirm within the polling window; it may still land later. */ + data object TimedOut : OrderDeliveryResult } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt index 758a2bcab..9f366c8fb 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt @@ -17,13 +17,14 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +@Singleton class UsdcDepositSweep( private val transactionOperations: TransactionOperations, private val accountController: AccountController, - private val tokenCoordinator: TokenCoordinator, private val balancePoller: BalancePoller, private val dispatchers: DispatcherProvider, private val maxRetries: Int = MAX_RETRIES, @@ -35,13 +36,11 @@ class UsdcDepositSweep( @Inject constructor( transactionOperations: TransactionOperations, accountController: AccountController, - tokenCoordinator: TokenCoordinator, balancePoller: BalancePoller, dispatchers: DispatcherProvider, ) : this( transactionOperations = transactionOperations, accountController = accountController, - tokenCoordinator = tokenCoordinator, balancePoller = balancePoller, dispatchers = dispatchers, maxRetries = MAX_RETRIES, diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index feec88bc1..d98d81b59 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -18,6 +18,7 @@ import com.flipcash.app.onramp.CoinbaseOnRampState import com.flipcash.app.onramp.DeeplinkError import com.flipcash.app.onramp.DeeplinkOnRampError import com.flipcash.app.onramp.OnRampAuthError +import com.flipcash.app.onramp.OrderDeliveryResult import com.flipcash.app.onramp.PhantomSwapResult import com.flipcash.app.onramp.PhantomWalletController import com.flipcash.app.onramp.PurchaseGate @@ -28,6 +29,7 @@ import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.UsdcDepositSweep import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -106,6 +108,7 @@ class SwapViewModel @Inject constructor( private val phantomWalletController: PhantomWalletController, private val userFlags: UserFlagsCoordinator, private val featureFlags: FeatureFlagController, + private val usdcDepositSweep: UsdcDepositSweep, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -275,7 +278,9 @@ class SwapViewModel @Inject constructor( data object PhantomConnected : Event data class PhantomNavigateToProcessing(val swapId: SwapId? = null) : Event data object PhantomCeremonyFailed : Event - data object DepositSubmitted : Event + // orderId is the Coinbase order to poll for on-chain delivery before sweeping. + // Null for Phantom deposits, which are already on-chain and can sweep immediately. + data class DepositSubmitted(val orderId: String? = null) : Event data class CreateAndSendTransactionToWallet(val token: Token, val amount: VerifiedFiat) : Event @@ -703,14 +708,39 @@ class SwapViewModel @Inject constructor( eventFlow .filterIsInstance() - .map { tokenCoordinator.balanceForToken(Mint.usdf).first() } - .flatMapLatest { baseline -> - // Observe balance — UsdcDepositSweep handles the actual - // USDC→USDF sweep + polling, we just wait for the result. - tokenCoordinator.balanceForToken(Mint.usdf) - .filter { it > baseline } - } - .onEach { + .onEach { event -> + val owner = userManager.accountCluster ?: return@onEach + val baseline = tokenCoordinator.balanceForToken(Mint.usdf).first() + + // A Coinbase order only settles the on-chain send after payment, while it + // sits in PROCESSING. Wait for that delivery before sweeping so we don't + // poll an empty ATA and give up too early. Phantom deposits (no orderId) + // are already on-chain, so they sweep immediately. + val delivered = when (val orderId = event.orderId) { + null -> true // phantom deposit, no waiting needed + else -> when (coinbaseOnRampController.awaitOrderDelivered(orderId)) { + is OrderDeliveryResult.Delivered -> true + OrderDeliveryResult.Failed -> { + dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) + false + } + // The deposit may still land later; the session's foreground sweep + // will reconcile it. Don't claim success we can't confirm. + OrderDeliveryResult.TimedOut -> { + dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) + false + } + } + } + if (!delivered) return@onEach + + // Funds are on-chain now: kick off the USDC→USDF sweep, then wait for the + // resulting USDF balance bump to mark the deposit complete. UsdcDepositSweep + // owns the actual sweep + on-chain polling; we just observe the result. + if (event.orderId != null) { + usdcDepositSweep.execute(owner) + } + tokenCoordinator.balanceForToken(Mint.usdf).first { it > baseline } feedCoordinator.fetchSinceLatest() dispatchEvent(Event.UpdateProcessingState(loading = false, success = true)) }.launchIn(viewModelScope) @@ -1135,7 +1165,7 @@ class SwapViewModel @Inject constructor( is PhantomSwapResult.DepositCompleted -> { dispatchEvent(Event.UpdateProcessingState(loading = true)) dispatchEvent(Event.PhantomNavigateToProcessing()) - dispatchEvent(Event.DepositSubmitted) + dispatchEvent(Event.DepositSubmitted()) } } }.onFailure { error -> @@ -1263,7 +1293,7 @@ class SwapViewModel @Inject constructor( Event.PhantomConnected, is Event.PhantomNavigateToProcessing, Event.PhantomCeremonyFailed, - Event.DepositSubmitted, + is Event.DepositSubmitted, Event.OnAmountConfirmed -> { state -> state } is Event.UpdateBuyState -> { state -> diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt index 44f8c7ec2..5f54048a2 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt @@ -27,7 +27,6 @@ class UsdcDepositSweepTest { private val transactionOperations: TransactionOperations = mockk(relaxed = true) private val accountController: AccountController = mockk(relaxed = true) - private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) private val balancePoller: BalancePoller = mockk(relaxed = true) private val owner: AccountCluster = mockk(relaxed = true) @@ -45,7 +44,6 @@ class UsdcDepositSweepTest { sweep = UsdcDepositSweep( transactionOperations = transactionOperations, accountController = accountController, - tokenCoordinator = tokenCoordinator, balancePoller = balancePoller, dispatchers = testDispatchers, maxRetries = 3, diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index 60c79fc67..b39d31918 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -7,6 +7,7 @@ import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.onramp.CoinbaseOnRampController import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.UsdcDepositSweep import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations @@ -24,12 +25,16 @@ import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.FakeResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.onramp.OrderDeliveryResult import com.flipcash.app.onramp.PhantomWalletController import com.flipcash.app.userflags.UserFlagsCoordinator +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -67,6 +72,7 @@ class SwapViewModelErrorTest { private val phantomWalletController = mockk(relaxed = true) private val userFlagsCoordinator = mockk(relaxed = true) private val featureFlags = mockk(relaxed = true) + private val usdcDepositSweep = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) @@ -109,6 +115,7 @@ class SwapViewModelErrorTest { dispatchers = dispatchers, userFlags = userFlagsCoordinator, featureFlags = featureFlags, + usdcDepositSweep = usdcDepositSweep, ) } @@ -159,4 +166,34 @@ class SwapViewModelErrorTest { assertTrue(BottomBarManager.messages.value.any { it.title == "error_title_buySellFailed" }) } + + @Test + fun `coinbase deposit waits for delivery before sweeping`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { tokenCoordinator.balanceForToken(Mint.usdf) } returns MutableStateFlow(Fiat.Zero) + coEvery { coinbaseOnRampController.awaitOrderDelivered("order-1") } returns + OrderDeliveryResult.Delivered(txHash = "sig") + + val vm = createViewModel() + vm.dispatchEvent(SwapViewModel.Event.DepositSubmitted(orderId = "order-1")) + advanceUntilIdle() + + // Coinbase delivery must be confirmed on-chain before the sweep runs. + coVerify { coinbaseOnRampController.awaitOrderDelivered("order-1") } + verify { usdcDepositSweep.execute(accountCluster) } + } + + @Test + fun `coinbase deposit does not sweep when delivery fails`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { tokenCoordinator.balanceForToken(Mint.usdf) } returns MutableStateFlow(Fiat.Zero) + coEvery { coinbaseOnRampController.awaitOrderDelivered("order-2") } returns + OrderDeliveryResult.Failed + + val vm = createViewModel() + vm.dispatchEvent(SwapViewModel.Event.DepositSubmitted(orderId = "order-2")) + advanceUntilIdle() + + verify(exactly = 0) { usdcDepositSweep.execute(any()) } + } }