From 3e7a2d15a9ec433817d40307e2d6c79f530b6e42 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 13 May 2026 16:17:37 -0400 Subject: [PATCH 1/2] feat(deposit): convert deposit into a multi-step flow with USDC interstitial Restructure deposit as a FlowHost-based flow (matching withdrawals) with a new USDC informational step for USDF deposits. Move deposit/withdraw actions to the token info screen for reserves and remove the now-unused TransferDirection and Withdraw menu item. Signed-off-by: Brandon McAnsh --- .../ui/navigation/AppScreenContent.kt | 7 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 11 ++ .../app/core/deposit/DepositResult.kt | 16 ++ .../flipcash/app/core/deposit/DepositStep.kt | 22 +++ .../app/core/transfers/TransferDirection.kt | 54 ------ .../core/src/main/res/values/strings.xml | 4 + .../balance/internal/BalanceScreenContent.kt | 10 +- ...tScreen.kt => DepositDestinationScreen.kt} | 6 +- .../flipcash/app/deposit/DepositFlowScreen.kt | 91 ++++++++++ .../deposit/internal/DepositScreenContent.kt | 2 +- .../app/deposit/internal/DepositViewModel.kt | 20 ++- .../internal/UsdcDepositInformationScreen.kt | 98 +++++++++++ .../flipcash/app/menu/internal/MenuItems.kt | 13 -- .../flipcash/app/tokens/TokenInfoScreen.kt | 15 +- .../flipcash/app/tokens/TokenSelectScreen.kt | 3 +- .../app/tokens/internal/TokenInfoScreen.kt | 166 +++++++++++++----- .../app/tokens/ui/SelectTokenViewModel.kt | 10 +- 17 files changed, 400 insertions(+), 148 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositResult.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt delete mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/transfers/TransferDirection.kt rename apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/{DepositScreen.kt => DepositDestinationScreen.kt} (90%) create mode 100644 apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt create mode 100644 apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/UsdcDepositInformationScreen.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 35f21d26e..d59dba7a0 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -28,7 +28,8 @@ import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.core.AppRoute import com.flipcash.app.currency.RegionSelectionScreen -import com.flipcash.app.deposit.DepositScreen +import com.flipcash.app.deposit.DepositDestinationScreen +import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.discovery.TokenDiscoverySheet import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator @@ -126,13 +127,15 @@ fun appEntryProvider( annotatedEntry { AppSettingsScreen() } annotatedEntry { LabsScreen() } annotatedEntry { MyAccountScreen() } - annotatedEntry { key -> DepositScreen(key.mint) } annotatedEntry { BackupKeyScreen() } annotatedEntry { AdvancedFeaturesScreen() } annotatedEntry { DeviceLogsScreen() } annotatedEntry { UserFlagsScreen() } // Transfers + annotatedEntry { key -> + DepositFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } annotatedEntry { key -> WithdrawalFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } 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 11ef0c849..b683339f2 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 @@ -2,6 +2,8 @@ package com.flipcash.app.core import android.os.Parcelable import androidx.navigation3.runtime.NavKey +import com.flipcash.app.core.deposit.DepositResult +import com.flipcash.app.core.deposit.DepositStep import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.tokens.CurrencyCreatorResult import com.flipcash.app.core.tokens.CurrencyCreatorStep @@ -168,6 +170,15 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable @Parcelize sealed interface Transfers : AppRoute { + @Serializable + data class Deposit(val mint: Mint): Transfers, FlowRouteWithResult { + override val initialStack: List + get() = if (mint == Mint.usdf) { + listOf(DepositStep.UsdcInformational) + } else { + listOf(DepositStep.Destination(mint)) + } + } @Serializable data class Withdrawal(val mint: Mint) : Transfers, FlowRouteWithResult { diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositResult.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositResult.kt new file mode 100644 index 000000000..87010430d --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositResult.kt @@ -0,0 +1,16 @@ +package com.flipcash.app.core.deposit + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +sealed interface DepositResult : Parcelable { + @Parcelize + @Serializable + data object Success : DepositResult + + @Parcelize + @Serializable + data object Canceled : DepositResult +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt new file mode 100644 index 000000000..560ff27f3 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/deposit/DepositStep.kt @@ -0,0 +1,22 @@ +package com.flipcash.app.core.deposit + +import android.os.Parcelable +import com.getcode.navigation.flow.FlowStep +import com.getcode.solana.keys.Mint +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Steps inside the Withdrawal flow. Owned by [com.flipcash.app.core.AppRoute.Transfers.Withdrawal] + * and rendered inside a [com.getcode.navigation.flow.FlowHost]. + */ +@Serializable +sealed interface DepositStep : FlowStep, Parcelable { + @Parcelize + object UsdcInformational : DepositStep + + + @Parcelize + @Serializable + data class Destination(val mint: Mint) : DepositStep +} \ No newline at end of file diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/transfers/TransferDirection.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/transfers/TransferDirection.kt deleted file mode 100644 index 18dc0baca..000000000 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/transfers/TransferDirection.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.flipcash.app.core.transfers - -import android.os.Parcelable -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.flipcash.app.core.AppRoute -import com.flipcash.core.R -import com.getcode.solana.keys.Mint -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Parcelize -sealed interface TransferDirection : Parcelable { - - @get:Composable - val title: String - - val nextScreen: AppRoute - - @get:Composable - val description: String - - @get:Composable - val learnMoreAction: String - - @get:Composable - val continueAction: String - - data object Incoming : TransferDirection { - @IgnoredOnParcel - override val nextScreen: AppRoute = AppRoute.Menu.Deposit(Mint.usdf) - override val title: String - @Composable get() = stringResource(R.string.title_depositFunds) - override val description: String - @Composable get() = stringResource(R.string.title_learnToDeposit) - override val learnMoreAction: String - @Composable get() = stringResource(R.string.action_learnHowToDepositFunds) - override val continueAction: String - @Composable get() = stringResource(R.string.action_depositUsdc) - } - - data object Outgoing : TransferDirection { - @IgnoredOnParcel - override val nextScreen: AppRoute = AppRoute.Transfers.Withdrawal(Mint.usdf) - override val title: String - @Composable get() = stringResource(R.string.title_withdrawFunds) - override val description: String - @Composable get() = stringResource(R.string.title_learnToWithdraw) - override val learnMoreAction: String - @Composable get() = stringResource(R.string.action_learnHowToWithdrawFunds) - override val continueAction: String - @Composable get() = stringResource(R.string.action_withdrawFundsNow) - } -} \ No newline at end of file diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 303a97d5d..a663181f7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ The current value of your currencies of US dollar stablecoins Deposit + Deposit Deposit Funds Withdraw @@ -647,6 +648,9 @@ Discover + Deposit USDC + Your Solana USDC will be converted 1:1 to USD on Flipcash (USDF) + Buy With Phantom Purchase using Solana USDC in Phantom. Simply connect your wallet and confirm the transaction 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 1d8f4087b..1a2b148f5 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 @@ -63,6 +63,7 @@ private fun BalanceScreenContent( TokenList( modifier = Modifier.weight(1f), itemModifier = { Modifier.animateItem(fadeInSpec = null) }, + includeReserves = true, header = { BalanceHeader( modifier = Modifier @@ -124,15 +125,6 @@ private fun BalanceScreenContent( } } }, - reserves = { mint, reserves -> - CashReservesRow(reserves) { - dispatchEvent( - BalanceViewModel.Event.OpenScreen( - AppRoute.Token.Info(mint) - ) - ) - } - }, pinFooter = true, footer = if (tokenState.discoveryEnabled) { { diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositDestinationScreen.kt similarity index 90% rename from apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt rename to apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositDestinationScreen.kt index 0a5cddcf1..93d125332 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositScreen.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositDestinationScreen.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.deposit.internal.DepositScreen +import com.flipcash.app.deposit.internal.DepositDestinationScreen import com.flipcash.app.deposit.internal.DepositViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator @@ -18,7 +18,7 @@ import com.getcode.solana.keys.Mint import com.getcode.ui.components.AppBarWithTitle @Composable -fun DepositScreen(mint: Mint) { +fun DepositDestinationScreen(mint: Mint) { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -33,7 +33,7 @@ fun DepositScreen(mint: Mint) { backButton = true, onBackIconClicked = { navigator.pop() }, ) - DepositScreen(viewModel) + DepositDestinationScreen(viewModel) } LaunchedEffect(viewModel, mint) { diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt new file mode 100644 index 000000000..0171e0b03 --- /dev/null +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt @@ -0,0 +1,91 @@ +package com.flipcash.app.deposit + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.deposit.DepositResult +import com.flipcash.app.core.deposit.DepositStep +import com.flipcash.app.deposit.internal.DepositViewModel +import com.flipcash.app.deposit.internal.UsdcDepositInformationScreen +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.getcode.navigation.annotatedEntry +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.FlowExitReason +import com.getcode.navigation.flow.FlowHost +import com.getcode.navigation.flow.LocalFlowNavigator +import com.getcode.navigation.flow.PreviewFlowNavigator +import com.getcode.navigation.flow.deliverFlowResult +import com.getcode.navigation.flow.rememberInitialStack +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.solana.keys.Mint + +@Composable +fun DepositFlowScreen( + route: AppRoute.Transfers.Deposit, + resultStateRegistry: NavResultStateRegistry, +) { + val outerNavigator = LocalCodeNavigator.current + + val initialStack = route.rememberInitialStack() + + FlowHost( + initialStack = initialStack, + resultStateRegistry = resultStateRegistry, + onExit = { reason -> + val result: DepositResult = when (reason) { + is FlowExitReason.Completed -> reason.result + FlowExitReason.Canceled, + FlowExitReason.BackedOutOfRoot -> DepositResult.Canceled + } + outerNavigator.deliverFlowResult( + route = route, + value = NavResultOrCanceled.ReturnValue(result), + ) + when (result) { + DepositResult.Success -> { + outerNavigator.popUntil { it == AppRoute.Sheets.Menu } + } + DepositResult.Canceled -> { + outerNavigator.pop() + } + } + }, + entryProvider = depositEntryProvider(route.mint), + ) +} + +private fun depositEntryProvider( + mint: Mint, +): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { + UsdcDepositInformationScreen() + } + annotatedEntry { + DepositDestinationScreen(mint) + } +} + +@Composable +private fun DepositFlowPreview( + content: @Composable (state: DepositViewModel.State) -> Unit +) { + CompositionLocalProvider( + LocalFlowNavigator provides PreviewFlowNavigator(), + ) { + val state = DepositViewModel.State() + content(state) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UsdcInformational() { + DepositFlowPreview { UsdcDepositInformationScreen() } +} diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositScreenContent.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositScreenContent.kt index fae69831b..76b93354d 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositScreenContent.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositScreenContent.kt @@ -34,7 +34,7 @@ import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold @Composable -internal fun DepositScreen(viewModel: DepositViewModel) { +internal fun DepositDestinationScreen(viewModel: DepositViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() DepositScreenContent(state, viewModel::dispatchEvent) } diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt index 7e322a9e7..74b316a85 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt @@ -12,6 +12,9 @@ import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 import com.getcode.util.resources.ResourceHelper import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.usdf import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -55,7 +58,15 @@ internal class DepositViewModel @Inject constructor( .mapNotNull { tokenController.getTokenMetadata(it.mint) } .onResult( onSuccess = { result -> - val address = userManager.accountCluster?.depositAddressFor(result.token)?.base58() + val address = if (result.token.address == Mint.usdf) { + val usdfSwapAccounts = userManager.accountCluster?.let { + Token.usdf.timelockSwapAccounts(it.authorityPublicKey) + } + usdfSwapAccounts?.pda?.publicKey?.base58() + } else { + userManager.accountCluster?.depositAddressFor(result.token)?.base58() + } + if (address == null) { BottomBarManager.showError( title = resources.getString(R.string.error_title_tokenNotFound), @@ -65,7 +76,12 @@ internal class DepositViewModel @Inject constructor( } return@onResult } - dispatchEvent(Event.OnTokenChanged(address, result.token.name)) + val tokenName = if (result.token.address == Mint.usdf) { + resources.getString(R.string.displayName_usdc) + } else { + result.token.name + } + dispatchEvent(Event.OnTokenChanged(address, tokenName)) }, onError = { BottomBarManager.showError( diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/UsdcDepositInformationScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/UsdcDepositInformationScreen.kt new file mode 100644 index 000000000..b90044278 --- /dev/null +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/UsdcDepositInformationScreen.kt @@ -0,0 +1,98 @@ +package com.flipcash.app.deposit.internal + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.core.deposit.DepositResult +import com.flipcash.app.core.deposit.DepositStep +import com.flipcash.core.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold + +@Composable +internal fun UsdcDepositInformationScreen() { + val flowNavigator = rememberFlowNavigator() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_deposit), + isInModal = true, + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + titleAlignment = Alignment.CenterHorizontally, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_next), + ) { + flowNavigator.navigateTo(DepositStep.Destination(Mint.usdf)) + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x11), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_deposit_usdc_as_usdf), + contentDescription = null, + ) + } + + Column( + modifier = Modifier.fillMaxWidth(0.60f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.title_depositUsdcAsUsdf), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.description_depositUsdcAsUsdf), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt index 9ed610420..1277930d1 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt @@ -4,18 +4,15 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Science import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.core.transfers.TransferDirection import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.menu.FullMenuItem import com.flipcash.app.menu.StaffMenuItem import com.flipcash.features.menu.R -import com.getcode.util.resources.icons.Delete internal data object MyAccount : FullMenuItem() { override val icon: Painter @@ -27,16 +24,6 @@ internal data object MyAccount : FullMenuItem() { ) } -internal data object Withdraw : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_menu_withdraw) - override val name: String - @Composable get() = stringResource(R.string.title_withdrawFunds) - override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( - AppRoute.Sheets.TokenSelection(TokenPurpose.Withdraw) - ) -} - internal data object AdvancedFeatures : FullMenuItem() { override val icon: Painter @Composable get() = painterResource(R.drawable.ic_advanced_features) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 359d8c27b..830961ce2 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -41,7 +41,6 @@ fun TokenInfoScreen( fromDeeplink: Boolean, ) { val navigator = LocalCodeNavigator.current - val externalWalletOnRampController = LocalExternalWalletOnRampController.current Column( modifier = Modifier.fillMaxSize(), @@ -54,15 +53,11 @@ fun TokenInfoScreen( isInModal = true, title = { state.token.dataOrNull?.let { token -> - if (state.isCashReserve) { - AppBarDefaults.Title(text = stringResource(R.string.title_cashReserves)) - } else { - TokenIconWithName( - token = token, - imageSize = CodeTheme.dimens.staticGrid.x5, - spacing = CodeTheme.dimens.grid.x1, - ) - } + TokenIconWithName( + token = token, + imageSize = CodeTheme.dimens.staticGrid.x5, + spacing = CodeTheme.dimens.grid.x1, + ) } }, titleAlignment = Alignment.CenterHorizontally, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt index 982cb179c..eeb2b28de 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt @@ -8,7 +8,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.core.AppRoute.Menu.Deposit +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.AppRoute.Transfers.Deposit import com.flipcash.app.core.AppRoute.Transfers.Withdrawal import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.tokens.internal.SelectTokenScreen diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index 4142d13ba..86e2f7b9b 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -30,17 +31,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.analytics.Button +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.money.RegionSelectionKind import com.flipcash.app.core.tokens.SwapPurpose -import com.flipcash.app.tokens.ui.TokenInfoViewModel import com.flipcash.app.tokens.internal.components.info.MarketCapSection import com.flipcash.app.tokens.internal.components.info.TokenBalance import com.flipcash.app.tokens.internal.components.info.TokenDetailsSection +import com.flipcash.app.tokens.ui.TokenInfoViewModel import com.flipcash.features.tokens.R import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.measured @@ -110,6 +113,7 @@ private fun TokenInfoScreen( } } } + is Loadable.Error -> { item { Box(modifier = Modifier.fillParentMaxSize()) { @@ -129,6 +133,7 @@ private fun TokenInfoScreen( } } } + is Loadable.Loaded -> { item { TokenBalance( @@ -198,7 +203,8 @@ private fun TokenInfoScreen( if (!state.isCashReserve) { // market cap state.marketCap?.let { mcap -> - val loadable = state.historicalMarketCapData[state.selectedPeriod] ?: Loadable.Loaded(emptyList()) + val loadable = state.historicalMarketCapData[state.selectedPeriod] + ?: Loadable.Loaded(emptyList()) item { MarketCapSection( modifier = Modifier @@ -209,10 +215,18 @@ private fun TokenInfoScreen( selectedPeriod = state.selectedPeriod, rawHistoricalData = loadable, onRetry = { - dispatch(TokenInfoViewModel.Event.LoadHistoricalDataForPeriod(state.selectedPeriod)) + dispatch( + TokenInfoViewModel.Event.LoadHistoricalDataForPeriod( + state.selectedPeriod + ) + ) }, onPeriodSelected = { - dispatch(TokenInfoViewModel.Event.OnMarketCapPeriodSelected(it)) + dispatch( + TokenInfoViewModel.Event.OnMarketCapPeriodSelected( + it + ) + ) }, ) } @@ -280,50 +294,114 @@ private fun BottomBarButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - if (state.isCashReserve) return@Row - val canGive = state.balance.nativeAmount.isPositive - - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_buy), - ) { - dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) - } - - if (canGive) { - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled20, - text = stringResource(R.string.action_give), - ) { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give(mint = loadable.data.address, fromTokenInfo = true) - ) - ) - } - } + if (state.isCashReserve) { + ReserveButtonOptions( + mint = loadable.data.address, - if (state.canSell) { - CodeButton( - modifier = Modifier - .weight(1f), - buttonState = ButtonState.Filled20, - text = stringResource(R.string.action_sell), - ) { - analytics.buttonTapped(Button.TokenSell) - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Token.Swap( - purpose = SwapPurpose.Sell(loadable.data.address), - ) - ) - ) - } + state = state, + dispatch = dispatch, + ) + } else { + ButtonOptions( + analytics = analytics, + mint = loadable.data.address, + shortfall = shortfall, + state = state, + dispatch = dispatch, + ) } } } + is Loadable.Loading -> Unit } +} + +@Composable +private fun RowScope.ReserveButtonOptions( + mint: Mint, + state: TokenInfoViewModel.State, + dispatch: (TokenInfoViewModel.Event) -> Unit +) { + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_depositFunds), + ) { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Transfers.Deposit(mint = mint) + ) + ) + } + + val hasBalance = state.balance.nativeAmount.isPositive + + if (hasBalance) { + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled20, + text = stringResource(R.string.action_withdraw), + ) { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Transfers.Withdrawal(mint = mint) + ) + ) + } + } +} + +@Composable +private fun RowScope.ButtonOptions( + analytics: FlipcashAnalyticsService, + mint: Mint, + shortfall: Fiat?, + state: TokenInfoViewModel.State, + dispatch: (TokenInfoViewModel.Event) -> Unit +) { + val canGive = state.balance.nativeAmount.isPositive + + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_buy), + ) { + dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) + } + + if (canGive) { + CodeButton( + modifier = Modifier.weight(1f), + buttonState = ButtonState.Filled20, + text = stringResource(R.string.action_give), + ) { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Sheets.Give( + mint = mint, + fromTokenInfo = true + ) + ) + ) + } + } + + if (state.canSell) { + CodeButton( + modifier = Modifier + .weight(1f), + buttonState = ButtonState.Filled20, + text = stringResource(R.string.action_sell), + ) { + analytics.buttonTapped(Button.TokenSell) + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Token.Swap( + purpose = SwapPurpose.Sell(mint), + ) + ) + ) + } + } } \ No newline at end of file diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index 089a1627c..ec3a1f95b 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -132,19 +132,11 @@ class SelectTokenViewModel @Inject constructor( TokenWithLocalizedBalance( token = it.token, - displayName = if (it.token.address == Mint.usdf) { - resources.getString(R.string.title_cashReserves) - } else { - it.token.name - }, balance = balance, appreciation = appreciation ) } - .sortedWith(compareByDescending { item -> - if (item.isReserves) Fiat.MIN_VALUE - else item.balance.nativeAmount - }) + .sortedWith(compareByDescending { item -> item.balance.nativeAmount }) .filter { val hasBalance = it.balance.nativeAmount.hasDisplayableValue when (purpose) { From 75aefa144101bfbbfee96f46b93e92d22944f4c4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 14 May 2026 13:02:38 -0400 Subject: [PATCH 2/2] chore(deposit): move direct USDC deposit behind beta flag Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 1 + .../features/deposit/build.gradle.kts | 1 + .../flipcash/app/deposit/DepositFlowScreen.kt | 12 ++++- .../app/deposit/internal/DepositViewModel.kt | 51 +++++++++++-------- .../flipcash/app/featureflags/FeatureFlag.kt | 11 ++++ .../com/getcode/navigation/flow/FlowRoute.kt | 14 +++++ 6 files changed, 69 insertions(+), 21 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a663181f7..f96b2ff84 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -639,6 +639,7 @@ Your USDF will be converted 1:1 to Solana USDC on withdrawal Solana USDC USDC + USDF Withdrawal amount Less fee Net amount diff --git a/apps/flipcash/features/deposit/build.gradle.kts b/apps/flipcash/features/deposit/build.gradle.kts index 362b84321..f0ccbbe22 100644 --- a/apps/flipcash/features/deposit/build.gradle.kts +++ b/apps/flipcash/features/deposit/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(project(":libs:messaging")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":services:flipcash")) } diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt index 0171e0b03..ec3c6742d 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt @@ -10,6 +10,8 @@ import androidx.navigation3.runtime.entryProvider import com.flipcash.app.core.AppRoute import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.deposit.internal.DepositViewModel import com.flipcash.app.deposit.internal.UsdcDepositInformationScreen import com.flipcash.app.theme.FlipcashThemeWrapper @@ -31,8 +33,16 @@ fun DepositFlowScreen( resultStateRegistry: NavResultStateRegistry, ) { val outerNavigator = LocalCodeNavigator.current + val featureFlags = LocalFeatureFlags.current - val initialStack = route.rememberInitialStack() + val initialStack = route.rememberInitialStack { steps -> + val directDeposit = featureFlags.observe(FeatureFlag.DepositUsdc).value + if (!directDeposit && route.mint == Mint.usdf) { + listOf(DepositStep.Destination(route.mint)) + } else { + steps + } + } FlowHost( initialStack = initialStack, diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt index 74b316a85..606a74cdb 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt @@ -13,6 +13,8 @@ import com.getcode.solana.keys.base58 import com.getcode.util.resources.ResourceHelper import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf import com.getcode.view.BaseViewModel2 @@ -22,6 +24,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -32,6 +35,7 @@ internal class DepositViewModel @Inject constructor( clipboardManager: ClipboardManager, resources: ResourceHelper, dispatchers: DispatcherProvider, + featureFlags: FeatureFlagController, ) : BaseViewModel2( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -58,30 +62,37 @@ internal class DepositViewModel @Inject constructor( .mapNotNull { tokenController.getTokenMetadata(it.mint) } .onResult( onSuccess = { result -> - val address = if (result.token.address == Mint.usdf) { - val usdfSwapAccounts = userManager.accountCluster?.let { - Token.usdf.timelockSwapAccounts(it.authorityPublicKey) + viewModelScope.launch { + val directDeposit = featureFlags.get(FeatureFlag.DepositUsdc) + val address = if (result.token.address == Mint.usdf && directDeposit) { + val usdfSwapAccounts = userManager.accountCluster?.let { + Token.usdf.timelockSwapAccounts(it.authorityPublicKey) + } + usdfSwapAccounts?.ata?.publicKey?.base58() + } else { + userManager.accountCluster?.depositAddressFor(result.token)?.base58() } - usdfSwapAccounts?.pda?.publicKey?.base58() - } else { - userManager.accountCluster?.depositAddressFor(result.token)?.base58() - } - if (address == null) { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_tokenNotFound), - message = resources.getString(R.string.error_description_tokenNotFound), - ) { - dispatchEvent(Event.Exit) + if (address == null) { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_tokenNotFound), + message = resources.getString(R.string.error_description_tokenNotFound), + ) { + dispatchEvent(Event.Exit) + } + return@launch } - return@onResult - } - val tokenName = if (result.token.address == Mint.usdf) { - resources.getString(R.string.displayName_usdc) - } else { - result.token.name + val tokenName = when { + directDeposit && result.token.address == Mint.usdf -> { + resources.getString(R.string.displayName_usdc) + } + result.token.address == Mint.usdf -> { + resources.getString(R.string.displayName_usdf) + } + else -> result.token.name + } + dispatchEvent(Event.OnTokenChanged(address, tokenName)) } - dispatchEvent(Event.OnTokenChanged(address, tokenName)) }, onError = { BottomBarManager.showError( 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 86436bc1c..f9ef25fb0 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 @@ -135,6 +135,15 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object DepositUsdc: FeatureFlag { + override val key: String = "deposit_usdc_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + companion object { val entries: List get() = FeatureFlagEntries.entries @@ -162,6 +171,7 @@ val FeatureFlag.title: String FeatureFlag.TokenDiscovery -> "Token Discovery" FeatureFlag.CurrencyCreator -> "Currency Creator" FeatureFlag.BillTextures -> "Bill Textures" + FeatureFlag.DepositUsdc -> "Deposit USDC" } val FeatureFlag.message: String @@ -180,6 +190,7 @@ val FeatureFlag.message: String FeatureFlag.TokenDiscovery -> "When enabled, you'll gain access to leaderboards for tokens and discovery" FeatureFlag.CurrencyCreator -> "When enabled, you'll gain access to create new currencies" FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" + FeatureFlag.DepositUsdc -> "When enabled, you'll gain the ability to deposit USDC directly from any external wallet app instead of purchasing a currency first and sell" } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowRoute.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowRoute.kt index 2beb2efca..a3fab9858 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowRoute.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowRoute.kt @@ -39,3 +39,17 @@ inline fun FlowRoute.rememberInitialStack(): List { initialStack as List } } + +/** + * Remembers the [FlowRoute.initialStack] cast to the concrete [FlowStep] type, + * applying [transform] to modify the stack before it is remembered. + */ +@Composable +inline fun FlowRoute.rememberInitialStack( + noinline transform: (List) -> List, +): List { + return remember(this) { + @Suppress("UNCHECKED_CAST") + transform(initialStack as List) + } +}