diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt index 33d66d205..bc29cc68c 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/SwapStep.kt @@ -40,7 +40,7 @@ sealed interface SwapStep : FlowStep, Parcelable { data object ConvertDestinationSelection : SwapStep, Sheet, WrapContentSheet, HalfSheet /** - * v2 Get only: the payment-source picker, opened from the inline "Get with" row on amount + * v2 Get only: the payment-source picker, opened from the inline "Buy with" row on amount * entry. Distinct from [TokenSelection] because picking here pops back to the amount screen * rather than advancing to the receipt. */ diff --git a/apps/flipcash/core/src/main/res/drawable/ic_arrow_down.xml b/apps/flipcash/core/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 000000000..37f7e932a --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,13 @@ + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_arrow_up_large.xml b/apps/flipcash/core/src/main/res/drawable/ic_arrow_up_large.xml new file mode 100644 index 000000000..465487762 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_arrow_up_large.xml @@ -0,0 +1,16 @@ + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 00ea96e45..dc07f4d0b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -549,7 +549,7 @@ Buy Sell Convert - Get + Buy In Created %1$s About Amount to Withdraw @@ -575,9 +575,8 @@ %1$s available Confirm - - Get - Get with + + Buy with You Get Confirm Review the above before confirming.\nOnce made, your transaction is irreversible. 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 95237979c..2879183ab 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 @@ -55,9 +55,11 @@ internal fun SwapEntryScreen( is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible -> stringResource(R.string.title_amountToAdd) is SwapPurpose.Convert -> stringResource(R.string.title_amountToConvert) - // v2 renames the direct buy to "Get" and states the amount in the header instead - // of the title, matching Convert. - is SwapPurpose.BalanceIncrease if state.isGet -> stringResource(R.string.title_get) + // v2 titles the direct buy after the currency-info tile that opened it and + // states the amount in the header instead of the title, matching Convert. + is SwapPurpose.BalanceIncrease if state.isGet -> stringResource( + if (state.isBuyingMore) R.string.action_buyMore else R.string.action_buyIn + ) is SwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy) is SwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell) }, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt index 7154a204e..a940b2533 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenBuyReceiptScreen.kt @@ -33,7 +33,11 @@ internal fun BuyReceiptScreen() { ) { AppBarWithTitle( title = stringResource( - if (state.isGet) R.string.title_get else R.string.title_confirmPurchase + when { + !state.isGet -> R.string.title_confirmPurchase + state.isBuyingMore -> R.string.action_buyMore + else -> R.string.action_buyIn + } ), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { flowNavigator.back() } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt index f5a0b7edf..170c74bb6 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt @@ -104,14 +104,14 @@ internal fun ConvertDestinationSelector( modifier = modifier, ) -/** "Get with [🪙 Currency ⌄]" — what a v2 Get is paid from. */ +/** "Buy with [🪙 Currency ⌄]" — what a v2 buy is paid from. */ @Composable internal fun BuyFundingSelector( funding: TokenWithBalance?, onClick: () -> Unit, modifier: Modifier = Modifier, ) = TokenSelectorRow( - label = stringResource(R.string.label_getWith), + label = stringResource(R.string.label_buyWith), selected = funding, onClick = onClick, modifier = modifier, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index 5e85ad642..dbf290abb 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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 @@ -27,9 +28,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Icon import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowDownward -import androidx.compose.material.icons.outlined.ArrowUpward import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -332,19 +330,57 @@ private fun CurrencyActionTiles( dispatch: (TokenInfoViewModel.Event) -> Unit, modifier: Modifier = Modifier, ) { + val giveTile: @Composable RowScope.() -> Unit = { + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_give), + icon = { + Icon( + painter = painterResource(R.drawable.ic_banknote), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Sheets.Give(mint = tokenMint, fromTokenInfo = true) + ) + ) + }, + ) + } + + val convertTile: @Composable RowScope.() -> Unit = { + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_convert), + icon = { + Icon( + painter = painterResource(R.drawable.ic_convert), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { dispatch(TokenInfoViewModel.Event.OnConvert) }, + ) + } + Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { when { !isHeld -> { - // Single full-width "Get" tile + // Single full-width "Buy In" tile ActionTile( modifier = Modifier.weight(1f), - label = stringResource(R.string.action_get), + label = stringResource(R.string.action_buyIn), icon = { Icon( - imageVector = Icons.Outlined.ArrowDownward, + painter = painterResource(R.drawable.ic_arrow_down), contentDescription = null, tint = CodeTheme.colors.textMain, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), @@ -354,61 +390,51 @@ private fun CurrencyActionTiles( ) } - else -> { - // Held (incl. USDF/Dollars in v2): Give + Convert + Withdraw + // Dollars keeps Withdraw: there is no "buy more" of the cash reserve, and cashing + // out is the action people come to this screen for. + tokenMint == Mint.usdf -> { + giveTile() + convertTile() ActionTile( modifier = Modifier.weight(1f), - label = stringResource(R.string.action_give), + label = stringResource(R.string.action_withdraw), icon = { Icon( - painter = painterResource(R.drawable.ic_banknote), + painter = painterResource(R.drawable.ic_arrow_up_large), contentDescription = null, tint = CodeTheme.colors.textMain, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), ) }, onClick = { + // Preselect the currency being viewed: Dollars detours through the + // "Withdraw as USDC" intro, anything else opens straight on the amount screen. dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give(mint = tokenMint, fromTokenInfo = true) + AppRoute.Transfers.Withdrawal(preselectedMint = tokenMint) ) ) }, ) + } + + // Held non-USDF token: Give + Buy More + Convert + else -> { + giveTile() ActionTile( modifier = Modifier.weight(1f), - label = stringResource(R.string.action_convert), - icon = { - Icon( - painter = painterResource(R.drawable.ic_convert), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - ) - }, - onClick = { dispatch(TokenInfoViewModel.Event.OnConvert) }, - ) - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_withdraw), + label = stringResource(R.string.action_buyMore), icon = { Icon( - imageVector = Icons.Outlined.ArrowUpward, + painter = painterResource(R.drawable.ic_arrow_down), contentDescription = null, tint = CodeTheme.colors.textMain, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), ) }, - onClick = { - // Preselect the currency being viewed: Dollars detours through the - // "Withdraw as USDC" intro, anything else opens straight on the amount screen. - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Transfers.Withdrawal(preselectedMint = tokenMint) - ) - ) - }, + onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, ) + convertTile() } } } 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 563431358..9e4d5b4ff 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 @@ -39,6 +39,7 @@ 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.AccountController import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiat @@ -106,6 +107,7 @@ data class AmountEntryState( @HiltViewModel class SwapViewModel @Inject constructor( private val userManager: UserManager, + private val accountController: AccountController, private val exchange: Exchange, private val verifiedFiatCalculator: VerifiedFiatCalculator, transactionController: TransactionOperations, @@ -268,6 +270,8 @@ class SwapViewModel @Inject constructor( val fundingTokenWithBalance: TokenWithBalance? = null, // Convert only: the currency the conversion lands in. `tokenWithBalance` is the source. val destinationTokenWithBalance: TokenWithBalance? = null, + /** Whether the account already holds a token account for the target mint. See [isBuyingMore]. */ + val hasTokenAccount: Boolean = false, ) { val sellFee: Double? get() { @@ -324,6 +328,16 @@ class SwapViewModel @Inject constructor( val isGet: Boolean get() = purpose is SwapPurpose.Buy && !isAddingMoney + /** + * Whether a Get is adding to a position the account already has, which titles its screens + * "Buy More" rather than "Buy In". + * + * Held is the same test the currency-info tile row uses — an existing token account, or a + * positive balance — so the tile the user tapped and the screen it opens always agree. + */ + val isBuyingMore: Boolean + get() = isGet && (hasTokenAccount || tokenBalance.isPositive) + /** The currency a Get is paid from. Null until the default is seeded or one is picked. */ val fundingMint: Mint? get() = fundingTokenWithBalance?.token?.address @@ -346,6 +360,7 @@ class SwapViewModel @Inject constructor( sealed interface Event { data class OnPurposeChanged(val purpose: SwapPurpose) : Event data class OnSelectedTokenChanged(val token: TokenWithBalance) : Event + data class OnTokenAccountKnown(val exists: Boolean) : Event data class OnReservesUpdated(val reserves: TokenWithBalance) : Event data class OnLimitsChanged(val limits: Limits?) : Event @@ -404,7 +419,7 @@ class SwapViewModel @Inject constructor( data object ShowSellReceipt : Event // region v2 Get — the payment source is chosen inline, before the amount is confirmed. - /** Opens the "Get with" picker. */ + /** Opens the "Buy with" picker. */ data object SelectBuyFundingSource : Event /** A payment source was picked (or defaulted); the token still needs resolving. */ data class OnFundingSourceSelected(val mint: Mint) : Event @@ -701,6 +716,15 @@ class SwapViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnFundingSourceResolved(it)) } .launchIn(viewModelScope) + // A token account outlives a balance that has gone to zero, so it — not the balance + // alone — is what tells a Get whether it is a first buy or a top-up. + eventFlow.filterIsInstance() + .map { it.purpose.mint } + .distinctUntilChanged() + .flatMapLatest { accountController.observeHasAccountFor(it) } + .onEach { dispatchEvent(Event.OnTokenAccountKnown(it)) } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() .map { it.purpose } .flatMapLatest { purpose -> @@ -1907,6 +1931,7 @@ class SwapViewModel @Inject constructor( state.copy(purpose = resolved) } is Event.OnSelectedTokenChanged -> { state -> state.copy(tokenWithBalance = event.token) } + is Event.OnTokenAccountKnown -> { state -> state.copy(hasTokenAccount = event.exists) } is Event.OnReservesUpdated -> { state -> state.copy(reservesWithBalance = event.reserves) } is Event.OnAmountAccepted -> { state ->