Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
13 changes: 13 additions & 0 deletions apps/flipcash/core/src/main/res/drawable/ic_arrow_down.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Figma "IconArrowDown" (node 9536:8434) — single down arrow for the Buy In / Buy More tiles. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="28"
android:viewportHeight="28">
<path
android:pathData="M21.2917 16.3333L14 23.625L6.70833 16.3333M14 22.75V4.375"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.75"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
16 changes: 16 additions & 0 deletions apps/flipcash/core/src/main/res/drawable/ic_arrow_up_large.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!-- Vertical mirror of ic_arrow_down, for the Withdraw tile on Dollars. The design has no
28px up arrow of its own — Figma's IconArrowUp is the 15.8px square-cap market-cap
indicator, and the tile row it draws (node 9631:2168) has no Withdraw tile at all.
Named _large because ui/components already owns a 20dp ic_arrow_up for chat send. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="28"
android:viewportHeight="28">
<path
android:pathData="M21.2917 11.6667L14 4.375L6.70833 11.6667M14 5.25V23.625"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.75"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
7 changes: 3 additions & 4 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@
<string name="action_buy">Buy</string>
<string name="action_sell">Sell</string>
<string name="action_convert">Convert</string>
<string name="action_get">Get</string>
<string name="action_buyIn">Buy In</string>
<string name="label_createdAt">Created %1$s</string>
<string name="subtitle_about">About</string>
<string name="title_amountToWithdraw">Amount to Withdraw</string>
Expand All @@ -575,9 +575,8 @@
<string name="subtitle_amountAvailable">%1$s available</string>
<string name="action_confirmConversion">Confirm</string>

<!-- Currency purchase (v2 Get flow) -->
<string name="title_get">Get</string>
<string name="label_getWith">Get with</string>
<!-- Currency purchase (v2 buy flow) -->
<string name="label_buyWith">Buy with</string>
<string name="subtitle_youGet">You Get</string>
<string name="action_confirm">Confirm</string>
<string name="label_buyWarning">Review the above before confirming.\nOnce made, your transaction is irreversible.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Event.OnPurposeChanged>()
.map { it.purpose.mint }
.distinctUntilChanged()
.flatMapLatest { accountController.observeHasAccountFor(it) }
.onEach { dispatchEvent(Event.OnTokenAccountKnown(it)) }
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.OnPurposeChanged>()
.map { it.purpose }
.flatMapLatest { purpose ->
Expand Down Expand Up @@ -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 ->
Expand Down
Loading