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 ->