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/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/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 6ef05b575..c9de2656a 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,16 @@ 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 + 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 + 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 @@ -352,6 +362,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? @@ -386,7 +398,7 @@ Debit Card with Google Pay Debit Card Credit Card - Manual Deposit + Manual USDC Deposit Backpack Wallet Phantom Wallet Solflare Wallet @@ -497,6 +509,8 @@ USDF Buy More + Add Money + Amount to Buy Amount to Sell Enter up to %1$s @@ -509,7 +523,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 +744,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/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/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..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 @@ -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,26 @@ 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) + } + } else { + handleSendVerificationCode(ContactMethod.Email(emailAddress, computeClientData())) + } + }.launchIn(viewModelScope) eventFlow .filterIsInstance() @@ -270,6 +289,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/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 067fb1c62..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,16 +26,17 @@ 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 -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 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 @@ -63,6 +64,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 @@ -80,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( @@ -103,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(), @@ -200,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( @@ -284,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()) } @@ -429,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)) } @@ -619,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) } /** @@ -738,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 } 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 -> 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 001b4e533..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 @@ -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 @@ -71,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 { @@ -168,7 +165,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() } @@ -262,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/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 d69f409d8..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 @@ -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 @@ -32,6 +30,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 @@ -45,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 @@ -70,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 @@ -146,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, @@ -237,7 +234,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) }, @@ -522,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 depositFirst = featureFlags.get(FeatureFlag.DepositFirstUX) - val message = if (depositFirst) { - resources.getString(R.string.description_noBalanceYet) - } else { - resources.getString(R.string.description_noBalanceYetDiscover) - } - val cta = if (depositFirst) { - resources.getString(R.string.action_depositFunds) + if (!tokenCoordinator.hasBalance()) { + presentAddMoney(addMoney) } else { - resources.getString(R.string.action_discover) + presentDiscoverCurrencies() } - BottomBarManager.showInfo( - title = resources.getString(R.string.title_noBalanceYet), - message = message, - actions = listOf( - BottomBarAction( - text = cta - ) { - if (depositFirst) { - dispatchEvent(Event.PresentDepositOptions) - } else { - dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery)) - } - }, - ), - showCancel = true, - ) return@onEach } amountDelegate.reset() @@ -709,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/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..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 @@ -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,50 +163,71 @@ 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 -> SwipeActionRow( + onDelete = { dispatch(UserProfileViewModel.Event.UnlinkEmailClicked) }, + stateKey = email.value, + resetOnDismiss = true, + ) { + ContactMethodRow( + value = email.value, + verified = false, + linkedForPayment = false, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) }, ) } } @@ -278,7 +308,8 @@ private fun ProfileValueRow(value: String) { @Composable private fun ContactMethodRow( value: String, - subtitle: String?, + verified: Boolean, + linkedForPayment: Boolean, onRowClick: () -> Unit, ) { Row( @@ -291,23 +322,79 @@ 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, + ) } } 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..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 @@ -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 @@ -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( @@ -44,8 +45,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 +57,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 +94,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(), ) @@ -110,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() } } @@ -128,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() } } @@ -146,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) } } @@ -234,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( @@ -260,8 +272,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/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/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..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,14 +61,26 @@ 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) } 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 +129,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..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 @@ -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) }, @@ -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( @@ -127,6 +128,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() @@ -135,6 +146,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) { @@ -144,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/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/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/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/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/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..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 @@ -46,12 +47,14 @@ 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 +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() @@ -98,9 +101,44 @@ 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) { @@ -134,70 +172,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 +203,7 @@ class CoinbaseOnRampController @Inject constructor( fund = { Result.success(Unit) } ).getOrThrow() - startPayment(order, token, verifiedFiat, swapId) + startPayment(order, resolvedToken, verifiedFiat, swapId) } } } @@ -234,7 +227,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( @@ -254,7 +247,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) @@ -279,7 +273,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( @@ -299,7 +293,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) @@ -341,22 +336,24 @@ 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 { - 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() + } } } @@ -395,9 +392,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/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/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..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,163 +289,57 @@ 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()) - } + // region email resolution (requireCoinbaseEmailVerification flag) @Test - fun `resolveOnRampToken returns USDF when phone is null`() = runTest { - stubProfile(phone = null) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } + 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")) - @Test - fun `resolveOnRampToken returns USDF for NYC phone when USDF is tradable`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) + 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 `resolveOnRampToken returns USDC for NYC phone when USDF not tradable`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - assertEquals(Token.usdc, controller.resolveOnRampToken()) - } + fun `returns VerificationRequired when verification not required and no email`() = runTest { + stubAccountCluster() + stubRequireEmailVerification(false) + stubProfile(email = null) - @Test - fun `resolveOnRampToken returns USDC for Canadian phone when USDF not tradable`() = runTest { - stubProfile(phone = "+14165551234") // Toronto - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - assertEquals(Token.usdc, controller.resolveOnRampToken()) + val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD)) + assertIs(result.exceptionOrNull()) + assertTrue((result.exceptionOrNull() as OnRampAuthError.VerificationRequired).email) } @Test - fun `resolveOnRampToken returns USDF for international phone when USDF tradable`() = runTest { - stubProfile(phone = "+442071234567") // UK - stubBuyOptionsApi(buyOptionsResponseWithUsdf()) - assertEquals(Token.usdf, controller.resolveOnRampToken()) - } + 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) - @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()) + val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD)) + assertIs(result.exceptionOrNull()) + assertTrue((result.exceptionOrNull() as OnRampAuthError.VerificationRequired).email) } @Test - fun `resolveOnRampToken caches buy-options result per region`() = runTest { - stubProfile(phone = "+12125551234") - stubBuyOptionsApi(buyOptionsResponseWithoutUsdf()) - - controller.resolveOnRampToken() - controller.resolveOnRampToken() + 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")) - // API should only be called once due to caching - coVerify(exactly = 1) { api.getBuyOptions(any(), any(), any(), any()) } + val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD)) + assertTrue(result.exceptionOrNull() !is OnRampAuthError.VerificationRequired) } // endregion + } class CoinbaseOnRampApiErrorParseTest { 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..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 @@ -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, @@ -73,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) } ) @@ -81,7 +83,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..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 @@ -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 @@ -22,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 @@ -30,10 +32,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 @@ -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, @@ -91,7 +92,6 @@ class InternalPurchaseMethodController @Inject constructor( userFlags.resolvedFlags .mapNotNull { it.preferredOnRampProvider.effectiveValue } - .filterIsInstance() .onEach { provider -> _state.update { it.copy(preferredProvider = provider) } }.launchIn(scope) @@ -119,7 +119,7 @@ class InternalPurchaseMethodController @Inject constructor( selected = true scope.launch { val selection = PurchaseMethodSelection(method, metadata) - delay(300) + delay(300.milliseconds) _selections.emit(selection) } }, @@ -138,6 +138,7 @@ class InternalPurchaseMethodController @Inject constructor( present(PurchaseMethodMetadata( mint = Mint.usdf, purpose = PurchasePurpose.Deposit, + paymentAction = PaymentAction.Plain, showReserves = false, canUseOtherWallets = true) ) @@ -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, @@ -167,10 +172,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 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/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..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 @@ -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 @@ -36,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) } @@ -48,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, @@ -58,7 +62,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/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 699573614..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 @@ -212,12 +212,22 @@ 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) + 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 8e0903ab9..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 @@ -45,40 +45,41 @@ 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 + // 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_noBalanceYet) + if (!hasBalance) { + presentAddMoney(addMoneyEnabled, onRoute) } else { - resources.getString(R.string.description_noBalanceYetDiscover) - } - val cta = if (depositFirstUx) { - resources.getString(R.string.action_depositFunds) - } 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 5bfb49f25..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,10 +193,20 @@ class TokenCoordinator @Inject constructor( // region Public API — Balances - fun hasGiveableBalance(): Boolean = - _state.value.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) + 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 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 63dd89fa1..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 @@ -5,31 +5,40 @@ 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 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 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 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 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 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 +62,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 +73,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 @@ -78,6 +85,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, @@ -99,6 +107,8 @@ class SwapViewModel @Inject constructor( private val coinbaseOnRampController: CoinbaseOnRampController, private val phantomWalletController: PhantomWalletController, private val userFlags: UserFlagsCoordinator, + private val featureFlags: FeatureFlagController, + private val usdcDepositSweep: UsdcDepositSweep, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -107,8 +117,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,15 +150,22 @@ 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 }, 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 } @@ -196,6 +235,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 { @@ -234,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 @@ -264,8 +310,14 @@ 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 } private val enteredAmount: Fiat @@ -295,6 +347,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 @@ -342,14 +399,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 } @@ -479,6 +554,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 @@ -490,54 +567,65 @@ 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), - ) - return@onEach + 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) } - val netAmount = amountFiat.localFiat.nativeAmount - dispatchEvent(Event.UpdateBuyState(loading = true)) - dispatchEvent( - Event.OnAmountAccepted( - amountFiat, - netTransferAmount = netAmount, - enteredAmount = enteredAmount, - feeAmount = feeAmount, + !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, + 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, + ) ) - ) - 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 + dispatchEvent(Event.ProceedWithPurchase(amountFiat)) } - 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) + } } } } @@ -620,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) @@ -802,10 +915,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 } @@ -860,6 +986,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? { @@ -1026,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 -> @@ -1154,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 -> @@ -1208,6 +1347,8 @@ class SwapViewModel @Inject constructor( is Event.OnVerificationNeeded -> { state -> state } Event.Exit -> { state -> state } + Event.PresentDepositOptions -> { state -> state } + is Event.OpenScreen -> { state -> state } } } } 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/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 0a56c2233..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 @@ -3,9 +3,11 @@ 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 +import com.flipcash.app.tokens.UsdcDepositSweep import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations @@ -23,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 @@ -65,6 +71,8 @@ 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 usdcDepositSweep = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) @@ -106,6 +114,8 @@ class SwapViewModelErrorTest { phantomWalletController = phantomWalletController, dispatchers = dispatchers, userFlags = userFlagsCoordinator, + featureFlags = featureFlags, + usdcDepositSweep = usdcDepositSweep, ) } @@ -156,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()) } + } } 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 --- 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..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 @@ -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,41 @@ 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) + } + + 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/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/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 32b9218be..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 @@ -22,8 +23,40 @@ 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 }, + ) + }.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 { - 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/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/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/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 + } } } 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..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,9 +1,11 @@ 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 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 +61,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 +73,85 @@ 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 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() @@ -195,8 +277,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(