From d77a60ae0e26a705b1cfc15226dad4bd80318d96 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:14:57 -0400 Subject: [PATCH 1/9] feat(convert): currency-to-currency Convert flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert moves value between two currencies the user already holds: a destination picker on the amount-entry screen, a receipt that names both sides, and the swap plumbing behind it. The processing title reads a plain "Converting" rather than naming the destination β€” the destination is already on the receipt the user just confirmed, and long currency names truncated in the app bar. The receipt shows token logos rather than currency flags, matching the withdrawal and Get receipts; a flag can't tell two currencies apart when they share one (or have none). --- .../flipcash/app/core/ui/AmountWithKeypad.kt | 11 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 1 + .../com/flipcash/app/core/tokens/SwapStep.kt | 8 + .../flipcash/app/core/tokens/TokenPurpose.kt | 6 + .../app/core/tokens/TokenSwapPurpose.kt | 10 + .../core/src/main/res/values/strings.xml | 11 + .../flipcash/app/tokens/SwapEntryScreen.kt | 34 ++- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 33 +++ .../app/tokens/TokenConvertReceiptScreen.kt | 56 ++++ .../flipcash/app/tokens/TokenSelectScreen.kt | 1 + .../internal/ConvertDestinationSelector.kt | 67 +++++ .../tokens/internal/SwapEntryScreenContent.kt | 2 + .../internal/TokenConvertReceiptScreen.kt | 218 ++++++++++++++ .../app/tokens/internal/TokenSelectScreen.kt | 10 +- .../internal/TokenTxProcessingScreen.kt | 3 + .../components/info/CurrencyInfoContentV2.kt | 2 +- .../shared/amountentry/AmountEntryScreen.kt | 3 + .../app/tokens/ui/SelectTokenViewModel.kt | 7 + .../flipcash/app/tokens/ui/SwapViewModel.kt | 267 +++++++++++++++++- .../app/tokens/ui/TokenInfoViewModel.kt | 39 +++ 20 files changed, 782 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenConvertReceiptScreen.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt index 4a1a9afcd0..6b87207838 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt @@ -26,6 +26,8 @@ fun AmountWithKeypad( hint: String = "", isError: Boolean = false, isClickable: Boolean = false, + /** Optional row rendered between the amount and the keypad (e.g. a Convert destination picker). */ + accessory: (@Composable () -> Unit)? = null, onNumberPressed: (Int) -> Unit, onAmountClicked: () -> Unit = { }, onBackspace: () -> Unit, @@ -64,6 +66,15 @@ fun AmountWithKeypad( ) } + accessory?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + ) { it() } + } + CodeKeyPad( modifier = Modifier .fillMaxWidth() 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 83a3b9a93e..629dbe7667 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 @@ -224,6 +224,7 @@ sealed interface AppRoute : NavKey, Parcelable { } } is SwapPurpose.Sell -> listOf(SwapStep.Entry(purpose, initialAmount = shortfall)) + is SwapPurpose.Convert -> listOf(SwapStep.Entry(purpose, initialAmount = shortfall)) } 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 877bba58d4..b0fdef6a63 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 @@ -27,6 +27,14 @@ sealed interface SwapStep : FlowStep, Parcelable { @Serializable data object SellReceipt : SwapStep + @Parcelize + @Serializable + data object ConvertDestinationSelection : SwapStep + + @Parcelize + @Serializable + data object ConvertReceipt : SwapStep + @Parcelize @Serializable data object PhantomConnect: SwapStep diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt index 1fb093d570..483d887108 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt @@ -17,6 +17,12 @@ sealed interface TokenPurpose: Parcelable { } @Serializable data class Swap(val desiredToken: Mint, val amount: Fiat) : TokenPurpose + + /** + * Picks the currency a Convert lands in. [source] is the currency being spent (excluded from + * the list); [current] is the destination already chosen, shown with a checkmark. + */ + @Serializable data class ConvertDestination(val source: Mint, val current: Mint) : TokenPurpose @Serializable data class LaunchFunding(val amount: Fiat): TokenPurpose @Serializable data class Tip(val amount: Fiat?): TriggersChange @Serializable data object Withdraw: TokenPurpose diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt index dc4fb5d848..d45cae9721 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt @@ -32,4 +32,14 @@ sealed interface SwapPurpose : Parcelable { val fundingSource: FundingSource = FundingSource.Flexible, ) : SwapPurpose, BalanceIncrease @Serializable data class Sell(override val mint: Mint) : SwapPurpose, BalanceDecrease + + /** + * Converts one held currency directly into another. [mint] is the *source* currency the amount + * is entered in (so balance/limit semantics match [Sell]); [destinationMint] is what the user + * receives. + */ + @Serializable data class Convert( + override val mint: Mint, + val destinationMint: Mint, + ) : SwapPurpose, BalanceDecrease } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index d251530f62..f3ec6d9582 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -567,6 +567,17 @@ Adding Money Selling %1$s Review the above before confirming.\nOnce made, your transaction is irreversible. + + + Convert + Convert + Converting + Convert To + Convert to + Amount to convert + Conversion fee + You Convert + Confirm Review the above before confirming.\nOnce made, your transaction is irreversible. Yes 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 e6d014f6b9..a6fb1bc7db 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 @@ -17,6 +17,7 @@ import com.flipcash.app.core.tokens.SwapStep import com.flipcash.app.core.verification.VerificationResult import com.flipcash.app.onramp.CoinbaseOnRampCompletion import com.flipcash.app.onramp.LocalCoinbaseOnRampController +import com.flipcash.app.tokens.internal.ConvertDestinationSelector import com.flipcash.app.tokens.internal.SwapEntryScreenContent import com.flipcash.app.tokens.ui.SwapViewModel import com.flipcash.features.tokens.R @@ -51,6 +52,7 @@ internal fun SwapEntryScreen( title = when (purpose) { is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible -> stringResource(R.string.title_amountToAdd) + is SwapPurpose.Convert -> stringResource(R.string.title_amountToConvert) is SwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy) is SwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell) }, @@ -64,7 +66,21 @@ internal fun SwapEntryScreen( } ) - SwapEntryScreenContent(viewModel) + SwapEntryScreenContent( + viewModel = viewModel, + accessory = if (purpose is SwapPurpose.Convert) { + { + ConvertDestinationSelector( + destination = state.destinationTokenWithBalance, + onClick = { + viewModel.dispatchEvent(SwapViewModel.Event.SelectConvertDestination) + }, + ) + } + } else { + null + }, + ) } LaunchedEffect(viewModel) { @@ -90,6 +106,22 @@ internal fun SwapEntryScreen( }.launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + flowNavigator.navigateTo(SwapStep.ConvertDestinationSelection) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + flowNavigator.navigateTo(SwapStep.ConvertReceipt) + }.launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() 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 66fee4150d..7c4f8af6b3 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 @@ -2,6 +2,7 @@ package com.flipcash.app.tokens import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -97,6 +98,10 @@ private fun swapEntryProvider( BuyReceiptScreen() } annotatedEntry { SellReceiptScreen() } + annotatedEntry { + ConvertDestinationSelectScreen() + } + annotatedEntry { ConvertReceiptScreen() } annotatedEntry { PhantomConnectConfirmationScreen(depositFirstPurpose = depositFirstPurpose) } @@ -108,6 +113,34 @@ private fun swapEntryProvider( } } +/** + * Destination picker for a conversion. Selecting a currency updates the in-flight purpose and pops + * straight back to amount entry β€” nothing else in the flow changes, so there's no resolve to await. + */ +@Composable +private fun ConvertDestinationSelectScreen() { + val selectionViewModel = hiltViewModel() + val viewModel = flowSharedViewModel() + val flowNavigator = rememberFlowNavigator() + val purpose = viewModel.stateFlow.collectAsStateWithLifecycle().value.purpose + + val convert = purpose as? SwapPurpose.Convert ?: return + + TokenSelectScreen(TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint)) + + LaunchedEffect(selectionViewModel) { + selectionViewModel.eventFlow + .filterIsInstance() + .filter { it.fromUser } + .map { it.mint } + .onEach { + viewModel.dispatchEvent(SwapViewModel.Event.OnDestinationSelected(it)) + flowNavigator.back() + } + .launchIn(this) + } +} + @Composable private fun SwapPurchaseTokenSelectScreen(targetMint: Mint, amount: Fiat) { val selectionViewModel = hiltViewModel() diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenConvertReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenConvertReceiptScreen.kt new file mode 100644 index 0000000000..7df60ceb6f --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenConvertReceiptScreen.kt @@ -0,0 +1,56 @@ +package com.flipcash.app.tokens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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 +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.tokens.SwapResult +import com.flipcash.app.core.tokens.SwapStep +import com.flipcash.app.tokens.internal.TokenConvertReceiptScreen +import com.flipcash.app.tokens.ui.SwapViewModel +import com.flipcash.features.tokens.R +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.ui.components.AppBarWithTitle +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +internal fun ConvertReceiptScreen() { + val flowNavigator = rememberFlowNavigator() + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_confirmConversion), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { + if (state.sellProgress.loading) { + // swallow + } else { + flowNavigator.back() + } + } + ) + + TokenConvertReceiptScreen(viewModel) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + flowNavigator.navigateTo(SwapStep.Processing) + }.launchIn(this) + } +} 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 87a6e8986f..6124d53e9e 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 @@ -95,6 +95,7 @@ fun TokenSelectScreen( is TokenPurpose.Tip -> Unit is TokenPurpose.LaunchFunding -> Unit is TokenPurpose.Swap -> Unit + is TokenPurpose.ConvertDestination -> Unit } }.launchIn(this) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt new file mode 100644 index 0000000000..87708b40ed --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.tokens.internal + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.ui.TokenIconWithName +import com.flipcash.features.tokens.R +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.theme.CodeTheme + +/** + * "Convert to [πŸͺ™ Currency βŒ„]" β€” the destination picker that sits between the entered amount and + * the keypad on the Convert amount screen. Tapping it pushes the currency list as a flow step. + */ +@Composable +internal fun ConvertDestinationSelector( + destination: TokenWithBalance?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + destination ?: return + + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.label_convertTo), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + TokenIconWithName( + token = destination.token, + imageSize = CodeTheme.dimens.staticGrid.x6, + textStyle = CodeTheme.typography.textMedium, + spacing = CodeTheme.dimens.grid.x2, + displayName = { destination.displayName }, + ) + + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt index 0cd4ed73a2..2d0941a7da 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt @@ -9,6 +9,7 @@ import com.getcode.navigation.core.LocalCodeNavigator @Composable internal fun SwapEntryScreenContent( viewModel: SwapViewModel, + accessory: (@Composable () -> Unit)? = null, ) { val navigator = LocalCodeNavigator.current @@ -16,5 +17,6 @@ internal fun SwapEntryScreenContent( controller = viewModel.amountDelegate, onConfirm = { viewModel.dispatchEvent(SwapViewModel.Event.OnAmountConfirmed) }, onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) }, + accessory = accessory, ) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt new file mode 100644 index 0000000000..c0f9c711cc --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt @@ -0,0 +1,218 @@ +package com.flipcash.app.tokens.internal + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.ui.ReceiptLineItem +import com.flipcash.app.core.ui.TokenBalanceRow +import com.flipcash.app.core.ui.TokenBalanceStyle +import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling +import com.flipcash.app.tokens.ui.SwapViewModel +import com.flipcash.features.tokens.R +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.plus +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.bolded +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold + +@Composable +internal fun TokenConvertReceiptScreen(viewModel: SwapViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + TokenConvertReceiptScreen(state, viewModel::dispatchEvent) +} + +@Composable +private fun TokenConvertReceiptScreen( + state: SwapViewModel.State, + dispatchEvent: (SwapViewModel.Event) -> Unit, +) { + val source = state.tokenWithBalance ?: return + val destination = state.destinationTokenWithBalance ?: return + + // Converting out of Dollars charges the fee on top of the entered amount, so the debit is + // entered + fee and the user receives the full entered amount. Every other direction takes the + // fee out of the sale, so the debit is what was entered and the receipt is net of the fee. + val totalDebited = if (state.isConvertingFromDollars) { + state.enteredAmount + state.feeAmount + } else { + state.enteredAmount + } + + CodeScaffold( + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x5), + text = stringResource(R.string.label_sellWarning), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_confirmConversion), + buttonState = ButtonState.Filled, + isLoading = state.sellProgress.loading, + isSuccess = state.sellProgress.success, + ) { + dispatchEvent(SwapViewModel.Event.OnConvertConfirmed) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x8), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.inset, + alignment = Alignment.CenterVertically + ) + ) { + ConvertReceipt( + source = source, + destination = destination, + totalDebited = totalDebited, + amountToConvert = state.enteredAmount, + feeAmount = state.feeAmount, + amountReceived = state.netTransferAmount, + ) + } + } +} + +@Composable +private fun ConvertReceipt( + source: TokenWithBalance, + destination: TokenWithBalance, + totalDebited: Fiat, + amountToConvert: Fiat, + feeAmount: Fiat, + amountReceived: Fiat, + modifier: Modifier = Modifier, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = CodeTheme.dimens.border, + color = CodeTheme.colors.border, + shape = CodeTheme.shapes.medium + ) + .background(White05, CodeTheme.shapes.medium) + .padding( + horizontal = CodeTheme.dimens.grid.x4, + vertical = CodeTheme.dimens.inset + ) + .then(modifier), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x6), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AmountWithCurrency( + title = stringResource(R.string.subtitle_youConvert), + tokenWithBalance = source.copy(balance = totalDebited), + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_amountToConvert), + amount = amountToConvert.formatted(), + ) + + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_conversionFee), + amount = feeAmount.formatted( + extraPrefix = if (feeAmount.decimalValue < 0.01) "~ " else null, + ), + ) + } + + AmountWithCurrency( + title = stringResource(R.string.subtitle_youReceive), + tokenWithBalance = destination.copy(balance = amountReceived), + ) + } +} + +/** A titled amount paired with the token it's denominated in β€” the receipt's two anchor rows. */ +@Composable +private fun AmountWithCurrency( + title: String, + tokenWithBalance: TokenWithBalance, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = title, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + + // Token logo, not the currency flag: a receipt names the token being moved, and the + // withdrawal and Get receipts already read that way. Currencies with a shared flag + // (or none at all) are indistinguishable otherwise. + TokenBalanceRow( + tokenWithBalance = tokenWithBalance, + showName = false, + showLogo = true, + showFlag = false, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + styling = rememberTokenBalanceRowStyling( + balanceDisplayStyle = TokenBalanceStyle.Large( + textStyle = CodeTheme.typography.displaySmall.bolded() + ), + ), + contentPadding = PaddingValues(0.dp), + ) + + Text( + text = tokenWithBalance.displayName, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } +} 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 38a3031ff1..680a63d642 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 @@ -46,7 +46,10 @@ private fun SelectTokenScreenContent( TokenList( modifier = Modifier.fillMaxSize(), tokens = tokens, - selectedToken = state.selectedToken, + // A Convert destination check-marks the currency already chosen for *this* flow, not the + // globally selected token. + selectedToken = (state.purpose as? TokenPurpose.ConvertDestination)?.current + ?: state.selectedToken, styling = rememberTokenBalanceRowStyling( balanceDisplayStyle = TokenBalanceStyle.Pill(), selectionStyle = when (state.purpose) { @@ -56,15 +59,18 @@ private fun SelectTokenScreenContent( is TokenPurpose.LaunchFunding -> TokenSelectionStyle.Chevron is TokenPurpose.Select -> TokenSelectionStyle.Checkbox is TokenPurpose.Tip -> TokenSelectionStyle.Checkbox + is TokenPurpose.ConvertDestination -> TokenSelectionStyle.Checkbox TokenPurpose.Withdraw -> TokenSelectionStyle.Chevron } ), - showSelections = state.purpose is TokenPurpose.Select, + showSelections = state.purpose is TokenPurpose.Select || + state.purpose is TokenPurpose.ConvertDestination, showFlags = when (state.purpose) { is TokenPurpose.Select -> false is TokenPurpose.Swap -> false is TokenPurpose.LaunchFunding -> false is TokenPurpose.Tip -> false + is TokenPurpose.ConvertDestination -> false else -> true }, enableGreaterThanAmount = atLeast@{ _, amount -> 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 08f2691162..a68130b5b7 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 @@ -58,6 +58,8 @@ private fun TokenTxProcessingScreen( is SwapPurpose.Buy if purpose.fundingSource != FundingSource.Flexible -> stringResource(R.string.title_addingMoney) + is SwapPurpose.Convert -> stringResource(R.string.title_converting) + is SwapPurpose.BalanceIncrease -> stringResource( R.string.title_purchasingToken, state.tokenName @@ -126,6 +128,7 @@ private fun TokenTxProcessingScreen( LoadingSuccessState.State.Loading -> stringResource(R.string.title_processingYourTransaction) LoadingSuccessState.State.Success -> { val name = when (state.purpose) { + is SwapPurpose.Convert -> state.destinationTokenName is SwapPurpose.BalanceIncrease -> state.tokenName else -> stringResource(R.string.title_cashReserves) } 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 95b07c93db..0fe6ee70ac 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 @@ -386,7 +386,7 @@ private fun CurrencyActionTiles( modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), ) }, - onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, + onClick = { dispatch(TokenInfoViewModel.Event.OnConvert) }, ) ActionTile( modifier = Modifier.weight(1f), 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 85fdee4f50..8cc48e2b54 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 @@ -23,6 +23,8 @@ fun AmountEntryScreen( onConfirm: () -> Unit, onChangeCurrency: () -> Unit = {}, appBar: (@Composable () -> Unit)? = null, + /** Optional row rendered between the amount and the keypad. */ + accessory: (@Composable () -> Unit)? = null, ) { val delegateState by controller.state.collectAsStateWithLifecycle() val config by controller.config.collectAsStateWithLifecycle() @@ -86,6 +88,7 @@ fun AmountEntryScreen( isClickable = config.canChangeCurrency, onAmountClicked = onChangeCurrency, isError = config.hint is AmountEntryHint.Error, + accessory = accessory, onNumberPressed = { controller.onNumber(it) }, onBackspace = { controller.onBackspace() }, onDecimal = { controller.onDecimal() }, 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 7c02f5d148..6757370a86 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 @@ -142,6 +142,7 @@ class SelectTokenViewModel @Inject constructor( } is TokenPurpose.Swap, + is TokenPurpose.ConvertDestination, is TokenPurpose.LaunchFunding, is TokenPurpose.Tip, TokenPurpose.Deposit, @@ -198,6 +199,12 @@ class SelectTokenViewModel @Inject constructor( false } } + + // Every currency except the one being spent is a valid landing + // spot β€” including ones the user doesn't hold yet. + is TokenPurpose.ConvertDestination -> { + it.token.address != purpose.source + } // show all tokens with non-zero balance else -> hasBalance } 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 e7fa945866..12d21c30b3 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 @@ -181,6 +181,8 @@ class SwapViewModel @Inject constructor( limit } is SwapPurpose.Sell -> tokenBalance + // Convert spends the *source* currency, so the ceiling is that balance β€” same as Sell. + is SwapPurpose.Convert -> tokenBalance null -> null } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) @@ -222,6 +224,8 @@ class SwapViewModel @Inject constructor( val minimumBuyAmount: Fiat? = null, val pendingInitialAmount: Fiat? = null, val fundingTokenWithBalance: TokenWithBalance? = null, + // Convert only: the currency the conversion lands in. `tokenWithBalance` is the source. + val destinationTokenWithBalance: TokenWithBalance? = null, ) { val sellFee: Double? get() { @@ -233,6 +237,16 @@ class SwapViewModel @Inject constructor( val tokenName: String get() = tokenWithBalance?.displayName.orEmpty() + val destinationTokenName: String + get() = destinationTokenWithBalance?.displayName.orEmpty() + + /** + * Converting *out of* Dollars is the one direction with no launchpad sale to skim the fee + * from, so the fee is charged on top of the entered amount instead of out of it. + */ + val isConvertingFromDollars: Boolean + get() = (purpose as? SwapPurpose.Convert)?.mint == Mint.usdf + val canTransact: Boolean get() = buyProgress.isIdle && sellProgress.isIdle && processingProgress.isIdle @@ -317,6 +331,16 @@ class SwapViewModel @Inject constructor( data object ShowSellReceipt : Event + // region convert + data object SelectConvertDestination : Event + data class OnDestinationSelected(val mint: Mint) : Event + data class OnDestinationTokenResolved(val token: TokenWithBalance) : Event + data object ShowConvertReceipt : Event + data object OnConvertConfirmed : Event + data class ProceedWithConversion(val amount: VerifiedFiat) : Event + data class OnConvertSubmitted(val token: Token, val swapId: SwapId) : Event + // endregion + data class OnPurchaseSubmitted(val token: Token, val swapId: SwapId) : Event data class OnSellSubmitted(val token: Token, val swapId: SwapId) : Event @@ -363,22 +387,51 @@ class SwapViewModel @Inject constructor( ) } + /** + * Basis points skimmed by a conversion. Converting *from* Dollars has no launchpad sale to take + * a fee from, so it pays the flat house rate; every other direction pays the source pool's own + * sell fee (falling back to the house rate when the pool doesn't declare one). + */ + private val convertFeeBps: Int + get() { + val purpose = stateFlow.value.purpose as? SwapPurpose.Convert ?: return 0 + if (purpose.mint == Mint.usdf) return DEFAULT_CONVERT_FEE_BPS + return stateFlow.value.tokenWithBalance?.token?.launchpadMetadata?.sellFeeBps + ?: DEFAULT_CONVERT_FEE_BPS + } + private val feeAmount: Fiat get() { + if (stateFlow.value.purpose is SwapPurpose.Convert) { + return enteredAmount.launchpadSellFee(convertFeeBps) + } val bps = stateFlow.value.tokenWithBalance?.token ?.launchpadMetadata?.sellFeeBps ?: return Fiat.Zero return enteredAmount.launchpadSellFee(bps) } + /** What the user actually receives: entered minus the fee, unless the fee rides on top. */ private val netTransferAmount: Fiat - get() = stateFlow.value.confirmedNetTransferAmount ?: when (stateFlow.value.purpose) { - is SwapPurpose.BalanceIncrease -> enteredAmount + get() = stateFlow.value.confirmedNetTransferAmount ?: when { + stateFlow.value.purpose is SwapPurpose.BalanceIncrease -> enteredAmount + stateFlow.value.isConvertingFromDollars -> enteredAmount else -> Fiat( fiat = enteredAmount.decimalValue - feeAmount.decimalValue, currencyCode = enteredAmount.currencyCode, ) } + /** What leaves the source balance: entered, plus the fee when it rides on top. */ + private val totalDebitAmount: Fiat + get() = if (stateFlow.value.isConvertingFromDollars) { + Fiat( + fiat = enteredAmount.decimalValue + feeAmount.decimalValue, + currencyCode = enteredAmount.currencyCode, + ) + } else { + enteredAmount + } + /** * Cross-currency buys pay the funding pool's sell fee on top of the entered amount, so * "You Pay" = amount + fee. If that total exceeds the funding token's wallet balance, the user @@ -450,6 +503,7 @@ class SwapViewModel @Inject constructor( } } is SwapPurpose.Sell -> stateFlow.value.tokenBalance + is SwapPurpose.Convert -> stateFlow.value.tokenBalance null -> Fiat.Zero } } @@ -570,6 +624,7 @@ class SwapViewModel @Inject constructor( val tokenAddress = when (purpose) { is SwapPurpose.Buy -> Mint.usdf is SwapPurpose.Sell -> purpose.mint + is SwapPurpose.Convert -> purpose.mint } combine( @@ -612,6 +667,45 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.OnReservesUpdated(TokenWithBalance(Token.usdf, it.nativeAmount))) }.launchIn(viewModelScope) + // Convert: keep the destination token resolved as the user swaps it in the picker. The + // route can hand us a destination that isn't usable (converting Dollarsβ†’Dollars), so pick a + // sensible default in that case rather than dead-ending the flow. + stateFlow.map { it.purpose } + .filterIsInstance() + .map { it.mint to it.destinationMint } + .distinctUntilChanged() + .flatMapLatest { (source, destination) -> + combine( + tokenCoordinator.tokenBalances, + exchange.observePreferredRate(), + ) { balances, rate -> + if (destination == source) { + val fallback = if (source == Mint.usdf) { + balances.filter { it.token.address != source } + .maxByOrNull { it.balance }?.token?.address + } else { + Mint.usdf + } + fallback?.let { dispatchEvent(Event.OnDestinationSelected(it)) } + return@combine null + } + + val held = balances.find { it.token.address == destination } + val token = held?.token + ?: tokenCoordinator.getTokenMetadata(destination).getOrNull()?.token + ?: return@combine null + + TokenWithBalance( + token = token, + balance = (held?.balance ?: Fiat.Zero).convertingTo(rate), + ) + } + } + .filterNotNull() + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnDestinationTokenResolved(it)) } + .launchIn(viewModelScope) + transactionController.limits .onEach { dispatchEvent(Event.OnLimitsChanged(it)) } .launchIn(viewModelScope) @@ -707,6 +801,35 @@ class SwapViewModel @Inject constructor( } } + is SwapPurpose.Convert -> { + val rate = exchange.preferredRate + val sourceWithBalance = stateFlow.value.tokenWithBalance ?: return@onEach + // The pin is taken against the total debited, which is the entered amount + // plus the fee when converting out of Dollars (see [totalDebitAmount]). + val amountFiat = verifiedFiatCalculator.compute( + amount = totalDebitAmount, + token = sourceWithBalance.token, + balance = sourceWithBalance.balance, + rate = rate, + ).getOrElse { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + return@onEach + } + + dispatchEvent( + Event.OnAmountAccepted( + amountFiat, + netTransferAmount = netTransferAmount, + enteredAmount = enteredAmount, + feeAmount = feeAmount, + ) + ) + dispatchEvent(Event.ShowConvertReceipt) + } + is SwapPurpose.Sell -> { val rate = exchange.preferredRate val tokenWithBalance = stateFlow.value.tokenWithBalance!! @@ -853,6 +976,95 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.ProceedWithSale(it)) }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .map { stateFlow.value.amountEntryState.selectedAmount } + .onEach { + dispatchEvent(Event.UpdateSellState(loading = true)) + dispatchEvent(Event.ProceedWithConversion(it)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.UpdateSellState(loading = true)) } + .mapNotNull { event -> + val owner = userManager.accountCluster ?: return@mapNotNull null + stateFlow.value.purpose as? SwapPurpose.Convert ?: return@mapNotNull null + val source = stateFlow.value.tokenWithBalance?.token ?: return@mapNotNull null + val destination = stateFlow.value.destinationTokenWithBalance?.token + ?: return@mapNotNull null + Triple(owner, source to destination, event.amount) + } + .onEach { (owner, tokens, amount) -> + val (source, destination) = tokens + val rate = exchange.preferredRate + + // Refresh the source balance from the network so the debit we're about to submit is + // checked against what's actually on-chain (mirrors ProceedWithSale). + tokenCoordinator.updateTokenAccount(source.address) + val amountInUsd = + Fiat.tokenBalance(amount.localFiat.underlyingTokenAmount.quarks, source) + val refreshedBalance = tokenCoordinator.balanceForToken(source) + if (amountInUsd > refreshedBalance) { + dispatchEvent(Event.UpdateSellState(loading = false)) + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_insufficientFunds), + message = resources.getString(R.string.error_description_insufficientFunds), + ) + return@onEach + } + + // Three legs, one screen: into Dollars is a plain sale, out of Dollars is a buy + // that carries an explicit fee, and currencyβ†’currency is a cross-currency swap + // whose pool fee is applied on-chain (so it's sent as null, server-enforced). + val result = when { + destination.address == Mint.usdf -> transactionController.sell( + owner = owner, + amount = amount, + of = source, + ) + + source.address == Mint.usdf -> transactionController.buy( + owner = owner, + amount = amount, + feeAmount = LocalFiat.fromUsd( + usdf = stateFlow.value.feeAmount.convertingToUsdIfNeeded(rate), + rate = rate, + ), + of = destination, + ) + + else -> transactionController.swap( + owner = owner, + amount = amount, + from = source, + to = destination, + ) + } + + result.onSuccess { swapId -> + trackTransaction(source) + dispatchEvent(Event.OnSwapIdChanged(swapId)) + dispatchEvent(Event.OnConvertSubmitted(destination, swapId)) + dispatchEvent(Event.UpdateSellState(loading = false, success = true)) + tokenCoordinator.subtract(source, amount.localFiat) + }.onFailure { cause -> + trackTransaction(source, error = cause) + dispatchEvent(Event.UpdateSellState(loading = false, success = false)) + if (cause is SwapError.Denied && cause.amountTooLowForFee) { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_sellFailedDueToBeingTooLow), + message = resources.getString(R.string.error_description_sellFailedDueToBeingTooLow), + ) + return@onFailure + } + BottomBarManager.showError( + title = resources.getString(R.string.error_title_buySellFailed), + message = resources.getString(R.string.error_description_buySellFailed), + ) + } + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .filter { stateFlow.value.purpose is SwapPurpose.Buy } @@ -1103,6 +1315,12 @@ class SwapViewModel @Inject constructor( if (isUsingReserves) { viewModelScope.launch { tokenCoordinator.updateTokenAccount(Mint.usdf) } } + // A conversion moves value between two accounts; refresh the one it landed in. + (stateFlow.value.purpose as? SwapPurpose.Convert)?.let { convert -> + viewModelScope.launch { + tokenCoordinator.updateTokenAccount(convert.destinationMint) + } + } viewModelScope.launch { // update activity feed to grab the tx as a result of this buy/sell feedCoordinator.fetchSinceLatest() @@ -1491,9 +1709,32 @@ class SwapViewModel @Inject constructor( private val minimumCoinbasePurchaseAmount = 5.toFiat() internal companion object { + /** + * House rate for a conversion when the source pool doesn't charge its own sell fee β€” 1%, + * matching the launchpad default. + */ + private const val DEFAULT_CONVERT_FEE_BPS = 100 + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - is Event.OnPurposeChanged -> { state -> state.copy(purpose = event.purpose) } + is Event.OnPurposeChanged -> { state -> + val incoming = event.purpose + val existing = state.purpose + // The entry step re-dispatches its *route* purpose every time it re-enters + // composition (e.g. popping back from the destination picker). For a Convert + // that would clobber the destination the user just picked, so keep the + // in-flight one whenever the source currency still matches. + val resolved = if ( + incoming is SwapPurpose.Convert && + existing is SwapPurpose.Convert && + existing.mint == incoming.mint + ) { + existing + } else { + incoming + } + state.copy(purpose = resolved) + } is Event.OnSelectedTokenChanged -> { state -> state.copy(tokenWithBalance = event.token) } is Event.OnReservesUpdated -> { state -> state.copy(reservesWithBalance = event.reserves) } @@ -1555,6 +1796,26 @@ class SwapViewModel @Inject constructor( Event.OnBuyConfirmed, Event.OnAmountConfirmed -> { state -> state } + is Event.OnDestinationSelected -> { state -> + val purpose = state.purpose + if (purpose is SwapPurpose.Convert) { + state.copy(purpose = purpose.copy(destinationMint = event.mint)) + } else { + state + } + } + + is Event.OnDestinationTokenResolved -> { state -> + state.copy(destinationTokenWithBalance = event.token) + } + + Event.SelectConvertDestination, + Event.ShowConvertReceipt, + Event.OnConvertConfirmed -> { state -> state } + + is Event.ProceedWithConversion -> { state -> state } + is Event.OnConvertSubmitted -> { state -> state } + is Event.SelectFundingToken -> { state -> state } is Event.OnFundingTokenSelected -> { state -> state } is Event.OnFundingTokenResolved -> { state -> state.copy(fundingTokenWithBalance = event.token) } 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 a11d56bd18..23bcc6f29a 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 @@ -104,6 +104,7 @@ class TokenInfoViewModel @Inject constructor( data class ExpandDescription(val expand: Boolean) : Event data object Share : Event data class OnBuy(val shortFall: Fiat? = null) : Event + data object OnConvert : Event data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event data object Exit : Event @@ -326,6 +327,43 @@ class TokenInfoViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + val mint = stateFlow.value.mint ?: return@onEach + // Converting spends this currency and lands in another, so it needs both a + // balance here and somewhere for it to go. + if (!stateFlow.value.canSell || !stateFlow.value.hasFundableBalance) { + 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, + ) + return@onEach + } + + // Dollars is the default landing spot; when Dollars *is* the source the swap + // view model substitutes the user's largest other holding. + dispatchEvent( + Event.OpenScreen( + AppRoute.Token.Swap( + purpose = SwapPurpose.Convert( + mint = mint, + destinationMint = Mint.usdf, + ), + ) + ) + ) + } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { @@ -370,6 +408,7 @@ class TokenInfoViewModel @Inject constructor( is Event.OnMarketCapPeriodSelected -> { state -> state.copy(selectedPeriod = event.period) } is Event.OpenScreen -> { state -> state } is Event.OnBuy -> { state -> state } + Event.OnConvert -> { state -> state } is Event.LoadHistoricalDataForPeriod -> { state -> state } is Event.Share -> { state -> state } is Event.Exit -> { state -> state } From 3b07766717e4f3ad33d454d893f5d7b047ca8a67 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:15:28 -0400 Subject: [PATCH 2/9] feat(convert): v2 amount header + Select Currency sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the Convert flow in line with the iOS spec: - New `displayExtraLarge` (74sp bold) type token, and map `textTertiary` to the real tertiary grey rather than White50. - `AmountArea`/`AmountTextAnimated` gain a content colour and a horizontal alignment, so the amount can sit start-aligned with the prefix and the un-entered placeholder digits rendered in tertiary grey. - `AmountWithKeypad` grows a `largeHeader` layout: the amount is top-anchored and left-aligned at display-extra-large over an "$X available" line, which simply turns red once the entry exceeds the balance instead of swapping in a separate over-limit sentence. - The Convert destination picker is now a bottom sheet rather than a pushed step: `SwapStep.ConvertDestinationSelection` is marked as a `Sheet` and the swap `FlowHost` gets the modal-sheet scene strategy. Because that is an overlay scene, amount entry stays composed underneath and never re-runs its entry effects. - Restrict Convert destinations to currencies the user already holds, matching iOS β€” acquiring something new is a Get, not a Convert. --- .../flipcash/app/core/ui/AmountWithKeypad.kt | 78 ++++++++++++++----- .../com/flipcash/app/core/tokens/SwapStep.kt | 17 +++- .../flipcash/app/core/tokens/TokenPurpose.kt | 7 ++ .../core/src/main/res/values/strings.xml | 1 + .../flipcash/app/tokens/SwapEntryScreen.kt | 1 + .../com/flipcash/app/tokens/SwapFlowScreen.kt | 35 ++++++++- .../flipcash/app/tokens/TokenSelectScreen.kt | 1 + .../tokens/internal/SwapEntryScreenContent.kt | 2 + .../app/tokens/internal/TokenSelectScreen.kt | 10 ++- .../shared/amountentry/AmountEntryScreen.kt | 3 + .../app/tokens/ui/SelectTokenViewModel.kt | 12 ++- .../flipcash/app/tokens/ui/SwapViewModel.kt | 17 +++- .../getcode/ui/components/text/AmountArea.kt | 14 +++- .../ui/components/text/AmountTextAnimated.kt | 17 +++- .../text/animated/NormalizedPrefixText.kt | 3 +- .../main/kotlin/com/getcode/theme/Theme.kt | 2 +- .../src/main/kotlin/com/getcode/theme/Type.kt | 6 ++ 17 files changed, 186 insertions(+), 40 deletions(-) diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt index 6b87207838..b8d91f9733 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt @@ -26,6 +26,11 @@ fun AmountWithKeypad( hint: String = "", isError: Boolean = false, isClickable: Boolean = false, + /** + * v2 layout: instead of the centred amount with an "Enter up to" caption, the amount is + * top-anchored and left-aligned at display-extra-large, over an "$X available" line. + */ + largeHeader: Boolean = false, /** Optional row rendered between the amount and the keypad (e.g. a Convert destination picker). */ accessory: (@Composable () -> Unit)? = null, onNumberPressed: (Int) -> Unit, @@ -44,26 +49,57 @@ fun AmountWithKeypad( .fillMaxWidth() .weight(0.65f) ) { - AmountArea( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(horizontal = CodeTheme.dimens.inset), - amountPrefix = prefix, - amountText = "", - placeholder = placeholder, - captionText = hint, - currencyResId = currencyFlag, - isAltCaptionKinIcon = false, - isAltCaption = isError, - uiModel = amountAnimatedModel, - isAnimated = true, - isClickable = isClickable, - onClick = { onAmountClicked.invoke() }, - networkState = networkState, - textStyle = CodeTheme.typography.displayLarge, - decimalPlaces = decimalPlaces - ) + if (largeHeader) { + // The header carries no currency flag or chevron β€” currency is fixed for the flow. + AmountArea( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + // 24dp above the amount, per spec β€” AmountArea's own row contributes 8dp. + .padding(top = CodeTheme.dimens.grid.x4) + .padding(horizontal = CodeTheme.dimens.inset), + amountPrefix = prefix, + amountText = "", + placeholder = placeholder, + captionText = hint, + currencyResId = null, + isAltCaptionKinIcon = false, + isAltCaption = isError, + uiModel = amountAnimatedModel, + isAnimated = true, + isClickable = false, + networkState = networkState, + textStyle = CodeTheme.typography.displayExtraLarge, + horizontalAlignment = Alignment.Start, + contentColor = if (amountAnimatedModel.amountData.amount == 0.0) { + CodeTheme.colors.textTertiary + } else { + CodeTheme.colors.textMain + }, + decimalPlaces = decimalPlaces, + ) + } else { + AmountArea( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .padding(horizontal = CodeTheme.dimens.inset), + amountPrefix = prefix, + amountText = "", + placeholder = placeholder, + captionText = hint, + currencyResId = currencyFlag, + isAltCaptionKinIcon = false, + isAltCaption = isError, + uiModel = amountAnimatedModel, + isAnimated = true, + isClickable = isClickable, + onClick = { onAmountClicked.invoke() }, + networkState = networkState, + textStyle = CodeTheme.typography.displayLarge, + decimalPlaces = decimalPlaces + ) + } } accessory?.let { @@ -86,4 +122,4 @@ fun AmountWithKeypad( isDecimal = decimalPlaces > 0, ) } -} \ No newline at end of file +} 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 b0fdef6a63..0c60051b0b 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 @@ -3,6 +3,7 @@ package com.flipcash.app.core.tokens import android.os.Parcelable import com.getcode.navigation.NonDismissableRoute import com.getcode.navigation.NonDraggableRoute +import com.getcode.navigation.Sheet import com.getcode.navigation.flow.FlowStep import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.financial.Fiat @@ -27,9 +28,23 @@ sealed interface SwapStep : FlowStep, Parcelable { @Serializable data object SellReceipt : SwapStep + /** + * Destination picker for a conversion. Presented as a bottom sheet over amount entry β€” the + * amount screen stays composed underneath, so picking a currency never re-runs its entry + * effects. + */ @Parcelize @Serializable - data object ConvertDestinationSelection : SwapStep + data object ConvertDestinationSelection : SwapStep, Sheet + + /** + * v2 Get only: the payment-source picker, opened from the inline "Get with" row on amount + * entry. Distinct from [TokenSelection] because picking here pops back to the amount screen + * rather than advancing to the receipt. + */ + @Parcelize + @Serializable + data object FundingSelection : SwapStep, Sheet @Parcelize @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt index 483d887108..4022191533 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt @@ -23,6 +23,13 @@ sealed interface TokenPurpose: Parcelable { * the list); [current] is the destination already chosen, shown with a checkmark. */ @Serializable data class ConvertDestination(val source: Mint, val current: Mint) : TokenPurpose + + /** + * Picks the currency a Get is funded from. [target] is the currency being bought (excluded from + * the list, since the server rejects same-mint swaps); [current] is the source already chosen, + * shown with a checkmark. + */ + @Serializable data class BuyFunding(val target: Mint, val current: Mint) : TokenPurpose @Serializable data class LaunchFunding(val amount: Fiat): TokenPurpose @Serializable data class Tip(val amount: Fiat?): TriggersChange @Serializable data object Withdraw: TokenPurpose diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f3ec6d9582..33a254094c 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -577,6 +577,7 @@ Amount to convert Conversion fee You Convert + %1$s available 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 a6fb1bc7db..3740f20ff9 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 @@ -68,6 +68,7 @@ internal fun SwapEntryScreen( SwapEntryScreenContent( viewModel = viewModel, + largeHeader = purpose is SwapPurpose.Convert, accessory = if (purpose is SwapPurpose.Convert) { { ConvertDestinationSelector( 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 7c4f8af6b3..73444ee820 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 @@ -1,12 +1,19 @@ package com.flipcash.app.tokens +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.core.AppRoute import com.flipcash.app.core.toast.LocalToastController import com.flipcash.app.core.tokens.FundingSource @@ -27,7 +34,10 @@ import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.flipcash.features.tokens.R import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -73,6 +83,12 @@ fun SwapFlowScreen( } }, entryProvider = swapEntryProvider(route), + // The currency pickers are overlay scenes, so amount entry stays composed beneath them β€” + // picking a currency never re-runs the entry screen's effects. + sceneStrategies = listOf( + ModalBottomSheetSceneStrategy(outerNavigator.resultStore) { null }, + SinglePaneSceneStrategy(), + ), ) } @@ -126,7 +142,24 @@ private fun ConvertDestinationSelectScreen() { val convert = purpose as? SwapPurpose.Convert ?: return - TokenSelectScreen(TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint)) + Column(modifier = Modifier.fillMaxSize()) { + // A sheet has no app bar: the title sits flush-left above the list, and the sheet's own + // scrim/drag handles dismissal. + Text( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + text = stringResource(R.string.title_selectCurrency), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + + TokenSelectScreen( + purpose = TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint), + showTopBar = false, + ) + } LaunchedEffect(selectionViewModel) { selectionViewModel.eventFlow 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 6124d53e9e..267c5fcaae 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 @@ -96,6 +96,7 @@ fun TokenSelectScreen( is TokenPurpose.LaunchFunding -> Unit is TokenPurpose.Swap -> Unit is TokenPurpose.ConvertDestination -> Unit + is TokenPurpose.BuyFunding -> Unit } }.launchIn(this) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt index 2d0941a7da..9750d2e080 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapEntryScreenContent.kt @@ -9,6 +9,7 @@ import com.getcode.navigation.core.LocalCodeNavigator @Composable internal fun SwapEntryScreenContent( viewModel: SwapViewModel, + largeHeader: Boolean = false, accessory: (@Composable () -> Unit)? = null, ) { val navigator = LocalCodeNavigator.current @@ -17,6 +18,7 @@ internal fun SwapEntryScreenContent( controller = viewModel.amountDelegate, onConfirm = { viewModel.dispatchEvent(SwapViewModel.Event.OnAmountConfirmed) }, onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) }, + largeHeader = largeHeader, accessory = accessory, ) } 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 680a63d642..a60476e61c 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 @@ -46,9 +46,10 @@ private fun SelectTokenScreenContent( TokenList( modifier = Modifier.fillMaxSize(), tokens = tokens, - // A Convert destination check-marks the currency already chosen for *this* flow, not the - // globally selected token. + // A Convert destination / Get payment source check-marks the currency already chosen for + // *this* flow, not the globally selected token. selectedToken = (state.purpose as? TokenPurpose.ConvertDestination)?.current + ?: (state.purpose as? TokenPurpose.BuyFunding)?.current ?: state.selectedToken, styling = rememberTokenBalanceRowStyling( balanceDisplayStyle = TokenBalanceStyle.Pill(), @@ -60,17 +61,20 @@ private fun SelectTokenScreenContent( is TokenPurpose.Select -> TokenSelectionStyle.Checkbox is TokenPurpose.Tip -> TokenSelectionStyle.Checkbox is TokenPurpose.ConvertDestination -> TokenSelectionStyle.Checkbox + is TokenPurpose.BuyFunding -> TokenSelectionStyle.Checkbox TokenPurpose.Withdraw -> TokenSelectionStyle.Chevron } ), showSelections = state.purpose is TokenPurpose.Select || - state.purpose is TokenPurpose.ConvertDestination, + state.purpose is TokenPurpose.ConvertDestination || + state.purpose is TokenPurpose.BuyFunding, showFlags = when (state.purpose) { is TokenPurpose.Select -> false is TokenPurpose.Swap -> false is TokenPurpose.LaunchFunding -> false is TokenPurpose.Tip -> false is TokenPurpose.ConvertDestination -> false + is TokenPurpose.BuyFunding -> false else -> true }, enableGreaterThanAmount = atLeast@{ _, amount -> 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 8cc48e2b54..62191b0e23 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 @@ -23,6 +23,8 @@ fun AmountEntryScreen( onConfirm: () -> Unit, onChangeCurrency: () -> Unit = {}, appBar: (@Composable () -> Unit)? = null, + /** v2 layout: top-anchored, left-aligned display-extra-large amount over an "$X available" line. */ + largeHeader: Boolean = false, /** Optional row rendered between the amount and the keypad. */ accessory: (@Composable () -> Unit)? = null, ) { @@ -88,6 +90,7 @@ fun AmountEntryScreen( isClickable = config.canChangeCurrency, onAmountClicked = onChangeCurrency, isError = config.hint is AmountEntryHint.Error, + largeHeader = largeHeader, accessory = accessory, onNumberPressed = { controller.onNumber(it) }, onBackspace = { controller.onBackspace() }, 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 6757370a86..f7f5bd1bfb 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 @@ -143,6 +143,7 @@ class SelectTokenViewModel @Inject constructor( is TokenPurpose.Swap, is TokenPurpose.ConvertDestination, + is TokenPurpose.BuyFunding, is TokenPurpose.LaunchFunding, is TokenPurpose.Tip, TokenPurpose.Deposit, @@ -200,10 +201,15 @@ class SelectTokenViewModel @Inject constructor( } } - // Every currency except the one being spent is a valid landing - // spot β€” including ones the user doesn't hold yet. + // A conversion moves between currencies the user already holds; + // acquiring something new is a Get, not a Convert. is TokenPurpose.ConvertDestination -> { - it.token.address != purpose.source + it.token.address != purpose.source && hasBalance + } + + // Anything held except the currency being bought can fund a Get. + is TokenPurpose.BuyFunding -> { + it.token.address != purpose.target && hasBalance } // show all tokens with non-zero balance else -> hasBalance 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 12d21c30b3..ae866f4694 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 @@ -125,6 +125,7 @@ class SwapViewModel @Inject constructor( val isBuy = vmState.purpose is SwapPurpose.Buy val isAddingMoney = vmState.isAddingMoney val isAddingMoneyViaPhantom = isAddingMoney && vmState.addingMoneyFrom == FundingSource.Phantom + val isConverting = vmState.purpose is SwapPurpose.Convert AmountEntryStyle( actionLabel = when { isAddingMoneyViaPhantom -> { @@ -148,11 +149,19 @@ class SwapViewModel @Inject constructor( } }, canChangeCurrency = (vmState.purpose as? SwapPurpose.Buy)?.fundingSource != FundingSource.Phantom, - infoHint = { resources.getString(R.string.subtitle_buySellCashHint, it) }, + // Convert's v2 header states the ceiling as a plain "$X available" line that simply + // turns red once exceeded, rather than swapping in a separate over-limit sentence. + infoHint = { + if (isConverting) resources.getString(R.string.subtitle_amountAvailable, it) + else resources.getString(R.string.subtitle_buySellCashHint, it) + }, overMaxHint = { - if (vmState.purpose is SwapPurpose.BalanceIncrease) - resources.getString(R.string.subtitle_buyHintLimitExceeded, it) - else resources.getString(R.string.subtitle_sellHintLimitExceeded, it) + when { + isConverting -> resources.getString(R.string.subtitle_amountAvailable, it) + vmState.purpose is SwapPurpose.BalanceIncrease -> + resources.getString(R.string.subtitle_buyHintLimitExceeded, it) + else -> resources.getString(R.string.subtitle_sellHintLimitExceeded, it) + } }, belowMinHint = { resources.getString(R.string.subtitle_buyHintBelowMinimum, it) }, ) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountArea.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountArea.kt index 971c660ab4..90127a695d 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountArea.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountArea.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -53,12 +54,15 @@ fun AmountArea( textStyle: TextStyle = CodeTheme.typography.displayMedium.bolded(), uiModel: AmountAnimatedInputUiModel? = null, networkState: NetworkState = LocalNetworkObserver.current.state.value, + horizontalAlignment: Alignment.Horizontal = CenterHorizontally, + /** Colour for the currency prefix and un-entered placeholder digits. */ + contentColor: Color = White, onClick: () -> Unit = {} ) { Column( modifier .let { if (isClickable) it.clickable { onClick() } else it }, - horizontalAlignment = CenterHorizontally + horizontalAlignment = horizontalAlignment ) { if (!isLoading) { Row( @@ -100,12 +104,18 @@ fun AmountArea( textStyle = textStyle, isClickable = isClickable, totalDecimals = decimalPlaces, + contentColor = contentColor, + horizontalArrangement = if (horizontalAlignment == Alignment.Start) { + Arrangement.Start + } else { + Arrangement.Center + }, ) } } } ValueHint( - modifier = Modifier.align(CenterHorizontally), + modifier = Modifier.align(horizontalAlignment), showIcon = isAltCaption && isAltCaptionKinIcon, iconColor = altCaptionColor ?: CodeTheme.colors.brandLight, captionColor = if (isAltCaption) (altCaptionColor ?: CodeTheme.colors.errorText) else CodeTheme.colors.textSecondary, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt index f994b5d06d..4ef661fbc5 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt @@ -83,6 +83,10 @@ internal fun AmountTextAnimated( totalDecimals: Int = 2, textStyle: TextStyle, isClickable: Boolean, + /** Colour for the currency prefix and the un-entered placeholder digits. */ + contentColor: Color = Color.White, + /** Where the amount sits within the full width β€” centred by default, start for the v2 header. */ + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, ) { uiModel ?: return @@ -286,11 +290,17 @@ internal fun AmountTextAnimated( .fillMaxWidth() .padding(top = CodeTheme.dimens.grid.x2) .height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.Center, + horizontalArrangement = horizontalArrangement, verticalAlignment = Alignment.CenterVertically, ) { + // The leading gap only balances a centred amount; a start-aligned one sits flush with the + // caption beneath it. val prefixPadding by animateDpAsState( - if (amountSuffix.isEmpty()) CodeTheme.dimens.staticGrid.x2 else CodeTheme.dimens.staticGrid.x1 + when { + horizontalArrangement == Arrangement.Start -> 0.dp + amountSuffix.isEmpty() -> CodeTheme.dimens.staticGrid.x2 + else -> CodeTheme.dimens.staticGrid.x1 + } ) Spacer(modifier = Modifier.width(prefixPadding)) @@ -326,6 +336,7 @@ internal fun AmountTextAnimated( amountPrefix = amountPrefix, textStyle = textStyle, textSize = textSize, + color = contentColor, ) { AnimatedPlaceholderDigit( modifier = Modifier.fillMaxHeight(), @@ -337,7 +348,7 @@ internal fun AmountTextAnimated( density = density, placeholderEnter = zeroEnter, placeholderExit = zeroExit, - placeholderColor = Color.White + placeholderColor = contentColor ) for (i in 1 until maxDigits) { diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/animated/NormalizedPrefixText.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/animated/NormalizedPrefixText.kt index 2d583a971e..82479c8d25 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/animated/NormalizedPrefixText.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/animated/NormalizedPrefixText.kt @@ -18,6 +18,7 @@ internal fun NormalizedPrefixText( textStyle: TextStyle, textSize: TextUnit, modifier: Modifier = Modifier, + color: Color = Color.White, content: @Composable () -> Unit ) { val measurer = rememberTextMeasurer() @@ -47,7 +48,7 @@ internal fun NormalizedPrefixText( // Actual amountPrefix with constrained height Text( text = amountPrefix, - style = textStyle.copy(fontSize = textSize, fontWeight = FontWeight.Bold, color = Color.White), + style = textStyle.copy(fontSize = textSize, fontWeight = FontWeight.Bold, color = color), modifier = Modifier.height(with (LocalDensity.current) { targetHeight.toDp() }) ) // Rest of the content (digits, etc.) diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index 0c530e6d78..68661de848 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -44,7 +44,7 @@ internal val CodeDefaultColorScheme = ColorScheme( successText = Success, textMain = TextMain, textSecondary = TextSecondary, - textTertiary = White50, + textTertiary = TextTertiary, border = BrandLight, divider = White10, dividerVariant = White05, diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt index 59bb312d73..1540a3eff6 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Type.kt @@ -32,6 +32,7 @@ private val RobotoMono = FontFamily( ) data class CodeTypography( + val displayExtraLarge: TextStyle, val displayLarge: TextStyle, val displayMedium: TextStyle, val displaySmall: TextStyle, @@ -60,6 +61,11 @@ fun ProvideTypography( } val codeTypography = CodeTypography( + displayExtraLarge = TextStyle( + fontFamily = Avenir, + fontSize = 74.sp, + fontWeight = FontWeight.Bold, + ), displayLarge = TextStyle( fontFamily = Avenir, fontSize = 55.sp, From 9593bdbd4bdb3173a19fb3719ad683a1fe2b6d21 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:15:28 -0400 Subject: [PATCH 3/9] feat(nav): half-detent sheets for the currency picker Adds a half-detent option to the modal sheet scene and points the select-currency step at it, so the picker opens at half height and sizes its list to the detent instead of the full screen. --- .../com/flipcash/app/core/tokens/SwapStep.kt | 6 +- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 59 +++++++++++++------ .../flipcash/app/tokens/TokenSelectScreen.kt | 7 ++- .../app/tokens/internal/TokenSelectScreen.kt | 14 ++++- .../com/flipcash/app/tokens/ui/TokenList.kt | 7 ++- .../com/getcode/navigation/NavMetadata.kt | 2 + .../kotlin/com/getcode/navigation/Types.kt | 7 +++ .../scenes/ModalBottomSheetSceneStrategy.kt | 22 ++++++- 8 files changed, 96 insertions(+), 28 deletions(-) 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 0c60051b0b..33d66d2059 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 @@ -1,9 +1,11 @@ package com.flipcash.app.core.tokens import android.os.Parcelable +import com.getcode.navigation.HalfSheet import com.getcode.navigation.NonDismissableRoute import com.getcode.navigation.NonDraggableRoute import com.getcode.navigation.Sheet +import com.getcode.navigation.WrapContentSheet import com.getcode.navigation.flow.FlowStep import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.financial.Fiat @@ -35,7 +37,7 @@ sealed interface SwapStep : FlowStep, Parcelable { */ @Parcelize @Serializable - data object ConvertDestinationSelection : SwapStep, Sheet + data object ConvertDestinationSelection : SwapStep, Sheet, WrapContentSheet, HalfSheet /** * v2 Get only: the payment-source picker, opened from the inline "Get with" row on amount @@ -44,7 +46,7 @@ sealed interface SwapStep : FlowStep, Parcelable { */ @Parcelize @Serializable - data object FundingSelection : SwapStep, Sheet + data object FundingSelection : SwapStep, Sheet, WrapContentSheet, HalfSheet @Parcelize @Serializable 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 73444ee820..da950cf91b 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 @@ -1,7 +1,11 @@ package com.flipcash.app.tokens import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -129,6 +133,12 @@ private fun swapEntryProvider( } } +/** Mirrors the modal sheet's expanded detent so wrap-content sheets never overrun it. */ +private const val SheetExpandedFraction = 0.925f + +/** Floor for the currency sheet: a short list still opens to a half-height sheet, not a stub. */ +private const val SheetMinimumFraction = 0.5f + /** * Destination picker for a conversion. Selecting a currency updates the in-flight purpose and pops * straight back to amount entry β€” nothing else in the flow changes, so there's no resolve to await. @@ -142,23 +152,38 @@ private fun ConvertDestinationSelectScreen() { val convert = purpose as? SwapPurpose.Convert ?: return - Column(modifier = Modifier.fillMaxSize()) { - // A sheet has no app bar: the title sits flush-left above the list, and the sheet's own - // scrim/drag handles dismissal. - Text( - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x3, - ), - text = stringResource(R.string.title_selectCurrency), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - - TokenSelectScreen( - purpose = TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint), - showTopBar = false, - ) + // The sheet hugs its content, so the list is measured at its natural height β€” bounded to + // max(half screen, content) so a short list isn't a stub and a long one can't run past the + // sheet's expanded detent. + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn( + min = maxHeight * SheetMinimumFraction, + max = maxHeight * SheetExpandedFraction, + ) + // Wrap-content sheets stop short of the system bars, so pad for them here. + .navigationBarsPadding(), + ) { + // A sheet has no app bar: the title sits flush-left above the list, and the sheet's own + // scrim/drag handles dismissal. + Text( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + text = stringResource(R.string.title_selectCurrency), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + + TokenSelectScreen( + purpose = TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint), + showTopBar = false, + wrapHeight = true, + ) + } } LaunchedEffect(selectionViewModel) { 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 267c5fcaae..0ad7ed135c 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 @@ -2,6 +2,7 @@ package com.flipcash.app.tokens import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -29,6 +30,8 @@ import kotlinx.coroutines.flow.onEach fun TokenSelectScreen( purpose: TokenPurpose, showTopBar: Boolean = purpose !is TokenPurpose.LaunchFunding, + /** Size the list to its content rather than the full height β€” for wrap-content sheets. */ + wrapHeight: Boolean = false, ) { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() @@ -43,7 +46,7 @@ fun TokenSelectScreen( CompositionLocalProvider(LocalFlowDismissStyle provides dismissStyle) { Column( - modifier = Modifier.fillMaxSize(), + modifier = if (wrapHeight) Modifier.fillMaxWidth() else Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { if (showTopBar) { @@ -58,7 +61,7 @@ fun TokenSelectScreen( ) } - SelectTokenScreen(viewModel) + SelectTokenScreen(viewModel, wrapHeight = wrapHeight) } } 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 a60476e61c..6e47244543 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 @@ -30,21 +30,24 @@ import com.getcode.theme.CodeTheme @Composable internal fun SelectTokenScreen( tokenViewModel: SelectTokenViewModel, + wrapHeight: Boolean = false, ) { val state by tokenViewModel.stateFlow.collectAsStateWithLifecycle() - SelectTokenScreenContent(state, tokenViewModel::dispatchEvent) + SelectTokenScreenContent(state, wrapHeight, tokenViewModel::dispatchEvent) } @Composable private fun SelectTokenScreenContent( state: SelectTokenViewModel.State, + wrapHeight: Boolean = false, dispatch: (SelectTokenViewModel.Event) -> Unit, ) { val tokens = remember(state.tokens) { state.tokens } TokenList( - modifier = Modifier.fillMaxSize(), + modifier = if (wrapHeight) Modifier.fillMaxWidth() else Modifier.fillMaxSize(), + wrapHeight = wrapHeight, tokens = tokens, // A Convert destination / Get payment source check-marks the currency already chosen for // *this* flow, not the globally selected token. @@ -92,7 +95,12 @@ private fun SelectTokenScreenContent( emptyState = { Box( modifier = Modifier - .fillParentMaxSize() + .then( + if (wrapHeight) Modifier + .fillParentMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x10) + else Modifier.fillParentMaxSize() + ) .padding(bottom = CodeTheme.dimens.inset), contentAlignment = Alignment.Center ) { diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt index 08c180eecf..1de24e6db6 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt @@ -2,8 +2,10 @@ package com.flipcash.app.tokens.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope @@ -51,6 +53,8 @@ fun TokenList( showSelections: Boolean = false, includeReserves: Boolean = true, pinFooter: Boolean = false, + /** Size the list to its content instead of the full height β€” for wrap-content sheets. */ + wrapHeight: Boolean = false, enableGreaterThanAmount: (mint: Mint, LocalFiat) -> Boolean = { _, _ -> true }, emptyState: (@Composable LazyItemScope.() -> Unit)? = null, reserves: (@Composable LazyItemScope.(mint: Mint, cashReserves: LocalFiat) -> Unit)? = null, @@ -76,7 +80,8 @@ fun TokenList( Box(modifier = modifier) { LazyColumn( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .then(if (wrapHeight) Modifier.wrapContentHeight() else Modifier.fillMaxHeight()) .verticalScrollStateGradient( scrollState = listState, color = CodeTheme.colors.background, diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt index 3113fddf4d..3ff4996d24 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -17,6 +17,7 @@ enum class NavMetadataKeys(val key: String, ) { IsNonDraggable("non_draggable"), IsSheet("sheet"), IsWrapContentSheet("sheet_wrap_content"), + IsHalfSheet("sheet_half"), IsSolitarySheet("sheet_solitary"), NavResultKey("navresult_key"), } @@ -68,6 +69,7 @@ fun KClass<*>.metadata(): Map { return mapOf( NavMetadataKeys.IsSheet.key to Sheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsWrapContentSheet.key to WrapContentSheet::class.java.isAssignableFrom(this.java), + NavMetadataKeys.IsHalfSheet.key to HalfSheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsSolitarySheet.key to SolitarySheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsNonDismissable.key to NonDismissableRoute::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsNonDraggable.key to NonDraggableRoute::class.java.isAssignableFrom(this.java), diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt index 1215898feb..4f93a1da24 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt @@ -4,6 +4,13 @@ import androidx.navigation3.runtime.NavKey interface Sheet: NavKey interface WrapContentSheet: NavKey + +/** + * Opens at a half-height resting detent and can be dragged the rest of the way up, so the top of + * the screen underneath stays visible. Content shorter than the detent bottom-aligns as usual, so + * pair this with [WrapContentSheet] and a half-height floor if a stub sheet isn't wanted. + */ +interface HalfSheet: NavKey interface NonDismissableRoute: NavKey interface NonDraggableRoute: NavKey interface SolitarySheet: NavKey diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index 6ee996386c..581216ac17 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -62,6 +62,11 @@ private val Expanded = SheetDetent("expanded") { containerHeight, _ -> containerHeight * 0.925f } +/** Resting detent for a [com.getcode.navigation.HalfSheet]; still draggable up to [Expanded]. */ +private val Half = SheetDetent("half") { containerHeight, _ -> + containerHeight * 0.5f +} + /** An [OverlayScene] that renders an [entry] within an [UnstyledBottomSheet]. */ internal class ModalBottomSheetScene constructor( override val key: T, @@ -119,9 +124,18 @@ internal class ModalBottomSheetScene constructor( val isWrapContent = metadata[NavMetadataKeys.IsWrapContentSheet.key] as? Boolean ?: false + // A half sheet gains an extra resting detent and opens there; every other sheet keeps + // the two-detent (hidden/expanded) behaviour. + val isHalfSheet = metadata[NavMetadataKeys.IsHalfSheet.key] as? Boolean ?: false + val restingDetent = if (isHalfSheet) Half else Expanded + val sheetState = rememberBottomSheetState( initialDetent = SheetDetent.Hidden, - detents = listOf(SheetDetent.Hidden, Expanded), + detents = if (isHalfSheet) { + listOf(SheetDetent.Hidden, Half, Expanded) + } else { + listOf(SheetDetent.Hidden, Expanded) + }, confirmDetentChange = { detent -> detent != SheetDetent.Hidden || allowDismiss }, @@ -130,7 +144,7 @@ internal class ModalBottomSheetScene constructor( // Animate the sheet in on first composition and after // same-route dismiss-replace (sheetGeneration increments). LaunchedEffect(navigator.sheetGeneration) { - sheetState.animateTo(Expanded) + sheetState.animateTo(restingDetent) } val composeScope = rememberCoroutineScope() @@ -197,8 +211,10 @@ internal class ModalBottomSheetScene constructor( modifier = Modifier .fillMaxSize() .drawBehind { + // Saturate the scrim at the sheet's resting detent, not at fully + // expanded β€” a half sheet would otherwise open under-dimmed. val progress = sheetState - .progress(SheetDetent.Hidden, Expanded) + .progress(SheetDetent.Hidden, restingDetent) .coerceIn(0f, 1f) // A bill beneath the sheet already dims the screen with its own // (constant) scrim, so don't stack a second dim here β€” otherwise From 8f18ed7b194a92f3d80a15a8704f55e2e55c623a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:15:28 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(sheets):=20currency=20sheet=20to=20spe?= =?UTF-8?q?c=20=E2=80=94=20self-sizing=20height=20and=20edge=20fade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the select-currency sheet to spec: row metrics, the list's edge fade, and a height the sheet itself owns. Height is the sheet's business, not the content's β€” restating a fraction of the screen in the content capped the list short of the sheet's own bottom edge and stranded the fade above it. The sheet now expands only when its content needs more room than the detent gives. --- .../flipcash/app/core/ui/AmountWithKeypad.kt | 2 +- .../flipcash/app/core/ui/TokenBalanceRow.kt | 31 +++++- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 66 ++++++------- .../flipcash/app/tokens/TokenSelectScreen.kt | 9 +- .../app/tokens/internal/TokenSelectScreen.kt | 58 ++++++++---- .../theme/internal/FlipcashDesignSystem.kt | 2 + .../com/flipcash/app/tokens/ui/TokenList.kt | 60 +++++++++++- .../kotlin/com/getcode/ui/core/Modifier.kt | 94 ++++++++----------- .../kotlin/com/getcode/navigation/Types.kt | 9 +- .../scenes/ModalBottomSheetSceneStrategy.kt | 37 +++++++- .../com/getcode/ui/utils/SheetExpansion.kt | 38 ++++++++ .../main/kotlin/com/getcode/theme/Theme.kt | 7 ++ 12 files changed, 286 insertions(+), 127 deletions(-) create mode 100644 ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt index b8d91f9733..690bf58a9c 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt @@ -72,7 +72,7 @@ fun AmountWithKeypad( textStyle = CodeTheme.typography.displayExtraLarge, horizontalAlignment = Alignment.Start, contentColor = if (amountAnimatedModel.amountData.amount == 0.0) { - CodeTheme.colors.textTertiary + CodeTheme.colors.textPlaceholder } else { CodeTheme.colors.textMain }, diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt index 16b7ab8766..5b7484f45b 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenBalanceRow.kt @@ -19,7 +19,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -42,6 +44,12 @@ sealed interface TokenBalanceStyle { data class Large(override val textStyle: TextStyle = TextStyle.Default) : TokenBalanceStyle data class Pill(override val textStyle: TextStyle = TextStyle.Default) : TokenBalanceStyle + + /** Bare trailing text with no pill chrome β€” the compact picker sheet's balance treatment. */ + data class Plain( + override val textStyle: TextStyle = TextStyle.Default, + val color: Color = Color.Unspecified, + ) : TokenBalanceStyle } enum class TokenSelectionStyle { @@ -58,6 +66,7 @@ data class TokenBalanceRowStyling( val flagSize: Dp, val selectionStyle: TokenSelectionStyle, val disabledAlpha: Float, + val contentPadding: PaddingValues, ) @Composable @@ -68,6 +77,7 @@ fun rememberTokenBalanceRowStyling( flagSize: Dp = CodeTheme.dimens.staticGrid.x3, selectionStyle: TokenSelectionStyle = TokenSelectionStyle.None, disabledAlpha: Float = 0.8f, + contentPadding: PaddingValues = PaddingValues(vertical = CodeTheme.dimens.inset), ): TokenBalanceRowStyling = TokenBalanceRowStyling( nameTextStyle = nameTextStyle, @@ -75,7 +85,8 @@ fun rememberTokenBalanceRowStyling( iconSize = iconSize, flagSize = flagSize, selectionStyle = selectionStyle, - disabledAlpha = disabledAlpha + disabledAlpha = disabledAlpha, + contentPadding = contentPadding, ) @Composable @@ -91,7 +102,7 @@ fun TokenBalanceRow( formattedBalance: (Fiat) -> String = { it.formatted() }, horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, styling: TokenBalanceRowStyling = rememberTokenBalanceRowStyling(), - contentPadding: PaddingValues = PaddingValues(vertical = CodeTheme.dimens.inset), + contentPadding: PaddingValues = styling.contentPadding, onClick: (() -> Unit)? = null, ) { val (token, balance, _, displayName) = tokenWithBalance @@ -127,7 +138,7 @@ fun TokenBalanceRow( formattedBalance: (Fiat) -> String = { it.formatted() }, horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, styling: TokenBalanceRowStyling = rememberTokenBalanceRowStyling(), - contentPadding: PaddingValues = PaddingValues(vertical = CodeTheme.dimens.inset), + contentPadding: PaddingValues = styling.contentPadding, onClick: (() -> Unit)? = null, ) { val (token, balance, _, displayName) = tokenWithBalance @@ -166,7 +177,7 @@ fun TokenBalanceRow( formattedBalance: (Fiat) -> String = { it.formatted() }, horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, styling: TokenBalanceRowStyling = rememberTokenBalanceRowStyling(), - contentPadding: PaddingValues = PaddingValues(vertical = CodeTheme.dimens.inset), + contentPadding: PaddingValues = styling.contentPadding, onClick: (() -> Unit)? = null, ) { val exchange = LocalExchange.current @@ -258,6 +269,18 @@ fun TokenBalanceRow( ) } + is TokenBalanceStyle.Plain -> { + val resolvedTextStyle = displayStyle.textStyle + .takeUnless { it == TextStyle.Default } + ?: CodeTheme.typography.textMedium + + Text( + text = formattedBalance(balance), + color = displayStyle.color.takeOrElse { CodeTheme.colors.textSecondary }, + style = resolvedTextStyle, + ) + } + is TokenBalanceStyle.Pill -> { val resolvedTextStyle = displayStyle.textStyle .takeUnless { it == TextStyle.Default } 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 da950cf91b..17403e5c09 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 @@ -1,10 +1,8 @@ package com.flipcash.app.tokens import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material.Text @@ -26,6 +24,7 @@ import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.flipcash.app.tokens.ui.TokenListPresentation import com.flipcash.app.tokens.ui.SwapViewModel import com.getcode.opencode.model.financial.Fiat import com.getcode.navigation.annotatedEntry @@ -133,12 +132,6 @@ private fun swapEntryProvider( } } -/** Mirrors the modal sheet's expanded detent so wrap-content sheets never overrun it. */ -private const val SheetExpandedFraction = 0.925f - -/** Floor for the currency sheet: a short list still opens to a half-height sheet, not a stub. */ -private const val SheetMinimumFraction = 0.5f - /** * Destination picker for a conversion. Selecting a currency updates the in-flight purpose and pops * straight back to amount entry β€” nothing else in the flow changes, so there's no resolve to await. @@ -152,38 +145,35 @@ private fun ConvertDestinationSelectScreen() { val convert = purpose as? SwapPurpose.Convert ?: return - // The sheet hugs its content, so the list is measured at its natural height β€” bounded to - // max(half screen, content) so a short list isn't a stub and a long one can't run past the - // sheet's expanded detent. - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - Column( + // Height is the sheet's business, not the content's: the sheet hands down its current detent + // height and the list simply fills it. Re-stating a fraction of the screen here would fight + // that β€” it capped the list short of the sheet's own bottom edge, stranding the list's edge + // fade above it. + Column( + modifier = Modifier + .fillMaxWidth() + // Sheets stop short of the system bars, so pad for them here. + .navigationBarsPadding(), + ) { + // A sheet has no app bar: the title sits flush-left above the list, and the sheet's own + // scrim/drag handles dismissal. + Text( modifier = Modifier - .fillMaxWidth() - .heightIn( - min = maxHeight * SheetMinimumFraction, - max = maxHeight * SheetExpandedFraction, - ) - // Wrap-content sheets stop short of the system bars, so pad for them here. - .navigationBarsPadding(), - ) { - // A sheet has no app bar: the title sits flush-left above the list, and the sheet's own - // scrim/drag handles dismissal. - Text( - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x3, + .padding(horizontal = CodeTheme.dimens.inset) + .padding( + top = CodeTheme.dimens.staticGrid.x7, + bottom = CodeTheme.dimens.staticGrid.x5, ), - text = stringResource(R.string.title_selectCurrency), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - - TokenSelectScreen( - purpose = TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint), - showTopBar = false, - wrapHeight = true, - ) - } + text = stringResource(R.string.title_selectCurrency), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + + TokenSelectScreen( + purpose = TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint), + showTopBar = false, + presentation = TokenListPresentation.Sheet, + ) } LaunchedEffect(selectionViewModel) { 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 0ad7ed135c..b4f7e09e5f 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 @@ -15,6 +15,7 @@ import com.flipcash.app.core.AppRoute.Transfers.Withdrawal import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.tokens.internal.SelectTokenScreen import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.flipcash.app.tokens.ui.TokenListPresentation import com.flipcash.features.tokens.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowDismissStyle @@ -30,8 +31,7 @@ import kotlinx.coroutines.flow.onEach fun TokenSelectScreen( purpose: TokenPurpose, showTopBar: Boolean = purpose !is TokenPurpose.LaunchFunding, - /** Size the list to its content rather than the full height β€” for wrap-content sheets. */ - wrapHeight: Boolean = false, + presentation: TokenListPresentation = TokenListPresentation.Default, ) { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() @@ -46,7 +46,8 @@ fun TokenSelectScreen( CompositionLocalProvider(LocalFlowDismissStyle provides dismissStyle) { Column( - modifier = if (wrapHeight) Modifier.fillMaxWidth() else Modifier.fillMaxSize(), + modifier = if (presentation.wrapHeight) Modifier.fillMaxWidth() + else Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { if (showTopBar) { @@ -61,7 +62,7 @@ fun TokenSelectScreen( ) } - SelectTokenScreen(viewModel, wrapHeight = wrapHeight) + SelectTokenScreen(viewModel, presentation = presentation) } } 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 6e47244543..fa2c00c843 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 @@ -3,6 +3,7 @@ package com.flipcash.app.tokens.internal import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -13,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -24,37 +26,44 @@ import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.app.tokens.ui.TokenList +import com.flipcash.app.tokens.ui.TokenListPresentation import com.flipcash.features.tokens.R import com.getcode.theme.CodeTheme @Composable internal fun SelectTokenScreen( tokenViewModel: SelectTokenViewModel, - wrapHeight: Boolean = false, + presentation: TokenListPresentation = TokenListPresentation.Default, ) { val state by tokenViewModel.stateFlow.collectAsStateWithLifecycle() - SelectTokenScreenContent(state, wrapHeight, tokenViewModel::dispatchEvent) + SelectTokenScreenContent(state, presentation, tokenViewModel::dispatchEvent) } @Composable private fun SelectTokenScreenContent( state: SelectTokenViewModel.State, - wrapHeight: Boolean = false, + presentation: TokenListPresentation = TokenListPresentation.Default, dispatch: (SelectTokenViewModel.Event) -> Unit, ) { val tokens = remember(state.tokens) { state.tokens } - TokenList( - modifier = if (wrapHeight) Modifier.fillMaxWidth() else Modifier.fillMaxSize(), - wrapHeight = wrapHeight, - tokens = tokens, - // A Convert destination / Get payment source check-marks the currency already chosen for - // *this* flow, not the globally selected token. - selectedToken = (state.purpose as? TokenPurpose.ConvertDestination)?.current - ?: (state.purpose as? TokenPurpose.BuyFunding)?.current - ?: state.selectedToken, - styling = rememberTokenBalanceRowStyling( + // The sheet picker (Figma 9120:15219) is a plain name/balance list: no pill around the balance, + // no trailing selection control, tighter rows. Everything else keeps the full-screen treatment. + val styling = if (presentation.compactRows) { + rememberTokenBalanceRowStyling( + nameTextStyle = CodeTheme.typography.textMedium, + balanceDisplayStyle = TokenBalanceStyle.Plain( + textStyle = CodeTheme.typography.textMedium + .copy(fontWeight = FontWeight.Medium), + color = CodeTheme.colors.textSecondary, + ), + iconSize = CodeTheme.dimens.staticGrid.x8, + selectionStyle = TokenSelectionStyle.None, + contentPadding = PaddingValues(vertical = CodeTheme.dimens.staticGrid.x2), + ) + } else { + rememberTokenBalanceRowStyling( balanceDisplayStyle = TokenBalanceStyle.Pill(), selectionStyle = when (state.purpose) { TokenPurpose.Balance -> TokenSelectionStyle.Chevron @@ -67,10 +76,23 @@ private fun SelectTokenScreenContent( is TokenPurpose.BuyFunding -> TokenSelectionStyle.Checkbox TokenPurpose.Withdraw -> TokenSelectionStyle.Chevron } - ), - showSelections = state.purpose is TokenPurpose.Select || - state.purpose is TokenPurpose.ConvertDestination || - state.purpose is TokenPurpose.BuyFunding, + ) + } + + TokenList( + modifier = if (presentation.wrapHeight) Modifier.fillMaxWidth() else Modifier.fillMaxSize(), + presentation = presentation, + tokens = tokens, + // A Convert destination / Get payment source check-marks the currency already chosen for + // *this* flow, not the globally selected token. + selectedToken = (state.purpose as? TokenPurpose.ConvertDestination)?.current + ?: (state.purpose as? TokenPurpose.BuyFunding)?.current + ?: state.selectedToken, + styling = styling, + showSelections = !presentation.compactRows && + (state.purpose is TokenPurpose.Select || + state.purpose is TokenPurpose.ConvertDestination || + state.purpose is TokenPurpose.BuyFunding), showFlags = when (state.purpose) { is TokenPurpose.Select -> false is TokenPurpose.Swap -> false @@ -96,7 +118,7 @@ private fun SelectTokenScreenContent( Box( modifier = Modifier .then( - if (wrapHeight) Modifier + if (presentation.wrapHeight) Modifier .fillParentMaxWidth() .padding(vertical = CodeTheme.dimens.grid.x10) else Modifier.fillParentMaxSize() diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index 711b1e3312..ebc0c0fdac 100644 --- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -32,6 +32,7 @@ object Flipcash2ColorSpec { val primaryLight = Color(0xFF303031) val secondary = Color(115, 129, 121) val secondaryText = Color.White.copy(alpha = 0.5f) + val placeholderText = Color(0xFF4E4E4E) val cashBill = Color(0xFF06450F) val notification = Color(0xFF058AFF) val trackColor = Color.White.copy(alpha = 0.07f) @@ -111,6 +112,7 @@ private val colors = with(Flipcash2ColorSpec) { textMain = Color.White, textSecondary = secondaryText, textTertiary = White10, + textPlaceholder = placeholderText, border = White08, divider = White10, dividerVariant = White05, diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt index 1de24e6db6..dd351b47fe 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.flipcash.app.core.ui.TokenBalanceRow import com.flipcash.app.core.ui.TokenBalanceRowStyling import com.flipcash.app.core.ui.TokenBalanceStyle @@ -37,8 +39,46 @@ import com.getcode.solana.keys.base58 import com.getcode.theme.CodeTheme import com.getcode.ui.core.isScrolledToEnd import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable import com.getcode.ui.utils.sheetResignmentBehavior +/** + * How a [TokenList] is laid out and framed. The full-screen list fills its parent and rules off rows + * with dividers; the compact picker sheet hugs its content, drops the dividers, and pins its bottom + * edge fade on. Pinning matters because a fade that is added and removed by scroll position blinks + * out whenever the viewport resizes β€” which is exactly what a sheet does while it is dragged between + * detents. + * + * [compactRows] additionally restyles the rows themselves β€” see the callers that build a + * [com.flipcash.app.core.ui.TokenBalanceRowStyling] from it. + */ +data class TokenListPresentation( + val wrapHeight: Boolean, + val showDividers: Boolean, + val alwaysFadeEnd: Boolean, + val edgeFadeSize: Dp?, + val compactRows: Boolean, +) { + companion object { + val Default = TokenListPresentation( + wrapHeight = false, + showDividers = true, + alwaysFadeEnd = false, + edgeFadeSize = null, + compactRows = false, + ) + + /** Compact currency picker rendered inside a bottom sheet (Figma 9120:15219). */ + val Sheet = TokenListPresentation( + wrapHeight = true, + showDividers = false, + alwaysFadeEnd = true, + edgeFadeSize = 24.dp, + compactRows = true, + ) + } +} + @Composable fun TokenList( tokens: List?, @@ -53,8 +93,7 @@ fun TokenList( showSelections: Boolean = false, includeReserves: Boolean = true, pinFooter: Boolean = false, - /** Size the list to its content instead of the full height β€” for wrap-content sheets. */ - wrapHeight: Boolean = false, + presentation: TokenListPresentation = TokenListPresentation.Default, enableGreaterThanAmount: (mint: Mint, LocalFiat) -> Boolean = { _, _ -> true }, emptyState: (@Composable LazyItemScope.() -> Unit)? = null, reserves: (@Composable LazyItemScope.(mint: Mint, cashReserves: LocalFiat) -> Unit)? = null, @@ -64,6 +103,12 @@ fun TokenList( ) { val listState = rememberLazyListState() + // A wrap-height list is a sheet picker: let the host sheet know whether the list has anything + // below the fold, so it only becomes draggable-to-expanded when expanding would show more. + if (presentation.wrapHeight) { + AllowSheetExpansionWhenScrollable(listState) + } + val cashReserves = remember(tokens) { tokens?.find { it.token.address == Mint.usdf }?.balance ?: LocalFiat.Zero } @@ -81,11 +126,16 @@ fun TokenList( LazyColumn( modifier = Modifier .fillMaxWidth() - .then(if (wrapHeight) Modifier.wrapContentHeight() else Modifier.fillMaxHeight()) + .then( + if (presentation.wrapHeight) Modifier.wrapContentHeight() + else Modifier.fillMaxHeight() + ) .verticalScrollStateGradient( scrollState = listState, color = CodeTheme.colors.background, isLongGradient = true, + showAtEndAlways = presentation.alwaysFadeEnd, + fadeSize = presentation.edgeFadeSize, ) .sheetResignmentBehavior(listState), state = listState @@ -117,7 +167,9 @@ fun TokenList( isEnabled = updatedIsEnabled, ) { onTokenSelected(item.token) } - HorizontalDivider(color = CodeTheme.colors.dividerVariant) + if (presentation.showDividers) { + HorizontalDivider(color = CodeTheme.colors.dividerVariant) + } } reserves?.let { diff --git a/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt b/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt index 53746789c5..d156b23ca7 100644 --- a/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt +++ b/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt @@ -202,74 +202,61 @@ fun Modifier.withTopBorder(color: Color = CodeTheme.colors.brandLight) = drawBeh ) } +// The height fed to [startY]/[endY] is the live draw size rather than a placement-captured one: +// a stale height and a live `size` disagree by a frame while the content is resizing (a sheet being +// dragged between detents), which lands the gradient short of the real edge. fun Modifier.drawWithGradient( color: Color, startY: ContentDrawScope.(Float) -> Float, endY: ContentDrawScope.(Float) -> Float = { Float.POSITIVE_INFINITY }, blendMode: BlendMode = BlendMode.SrcOver -) = this.composed { - var height by remember { - mutableStateOf(0.dp) +) = this + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + val colors = listOf(Color.Transparent, color) + drawContent() + drawRect( + brush = Brush.verticalGradient( + startY = startY(size.height), + endY = endY(size.height).takeIf { it != Float.POSITIVE_INFINITY } ?: size.height, + colors = colors, + ), + blendMode = blendMode + ) } - - val density = LocalDensity.current - - Modifier - .onPlaced { - height = with(density) { it.size.height.toDp() } - } - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - val colors = listOf(Color.Transparent, color) - drawContent() - drawRect( - brush = Brush.verticalGradient( - startY = startY(height.toPx()), - endY = endY(height.toPx()).takeIf { it != Float.POSITIVE_INFINITY } - ?: height.toPx(), - colors = colors, - ), - blendMode = blendMode - ) - } -} fun Modifier.drawWithGradient( brush: (Float, Float) -> Brush, startY: ContentDrawScope.(Float) -> Float, endY: ContentDrawScope.(Float) -> Float = { Float.POSITIVE_INFINITY }, blendMode: BlendMode = BlendMode.SrcOver -) = this.composed { - var height by remember { - mutableStateOf(0.dp) +) = this + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + drawRect( + brush = brush( + startY(size.height), + endY(size.height).takeIf { it != Float.POSITIVE_INFINITY } ?: size.height + ), + blendMode = blendMode + ) } - - val density = LocalDensity.current - - Modifier - .onPlaced { - height = with(density) { it.size.height.toDp() } - } - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - drawContent() - drawRect( - brush = brush( - startY(height.toPx()), - endY(height.toPx()).takeIf { it != Float.POSITIVE_INFINITY } ?: height.toPx() - ), - blendMode = blendMode - ) - } -} private val gradientSize @Composable get() = CodeTheme.dimens.staticGrid.x12 +/** + * Fades the list against [color] at whichever edge can still be scrolled toward. + * + * Pass [showAtStartAlways]/[showAtEndAlways] for content whose height changes underneath the list + * β€” a fade that's added and removed by scroll position blinks out whenever the viewport resizes. + * [fadeSize] overrides the default fade depth. + */ fun Modifier.verticalScrollStateGradient( scrollState: LazyListState, color: Color = Color.Unspecified, @@ -278,10 +265,11 @@ fun Modifier.verticalScrollStateGradient( showAtEnd: Boolean = true, showAtEndAlways: Boolean = false, isLongGradient: Boolean = false, + fadeSize: Dp? = null, ): Modifier = composed { val backgroundColor = color.takeOrElse { CodeTheme.colors.background } - val gradientSizePx = - with(LocalDensity.current) { gradientSize.toPx() } * if (isLongGradient) 1.5f else 1f + val gradientSizePx = with(LocalDensity.current) { (fadeSize ?: gradientSize).toPx() } * + if (fadeSize == null && isLongGradient) 1.5f else 1f this .addIf((showAtStart && !scrollState.isScrolledToStart()) || showAtStartAlways) { Modifier.drawWithGradient( diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt index 4f93a1da24..2e7de506b7 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt @@ -6,9 +6,12 @@ interface Sheet: NavKey interface WrapContentSheet: NavKey /** - * Opens at a half-height resting detent and can be dragged the rest of the way up, so the top of - * the screen underneath stays visible. Content shorter than the detent bottom-aligns as usual, so - * pair this with [WrapContentSheet] and a half-height floor if a stub sheet isn't wanted. + * Rests at half the screen height, so the top of the screen underneath stays visible. + * + * The sheet fills that height whatever the content's own height is, so content should size itself + * to what it is given rather than restating the fraction. It is draggable further up only while the + * content reports it has something below the fold β€” see + * [com.getcode.ui.utils.AllowSheetExpansionWhenScrollable]. */ interface HalfSheet: NavKey interface NonDismissableRoute: NavKey diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index 581216ac17..bdd60e3208 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -50,6 +51,7 @@ import com.getcode.navigation.scrim.LocalScrimController import com.getcode.navigation.scrim.ScrimOverlay import com.getcode.theme.CodeTheme import com.getcode.ui.core.noRippleClickable +import com.getcode.ui.utils.LocalSheetExpansionState import com.getcode.ui.utils.LocalSheetGesturesState import kotlinx.coroutines.launch @@ -62,7 +64,14 @@ private val Expanded = SheetDetent("expanded") { containerHeight, _ -> containerHeight * 0.925f } -/** Resting detent for a [com.getcode.navigation.HalfSheet]; still draggable up to [Expanded]. */ +/** + * Resting β€” and, for short content, only β€” detent for a [com.getcode.navigation.HalfSheet]. + * + * The sheet always fills this detent, so its content is measured against it and does not need to + * restate the fraction. [Expanded] joins the detent list only once the content reports that it + * overruns this one, so a sheet with nothing below the fold can't be dragged up into dead space β€” + * see [com.getcode.ui.utils.LocalSheetExpansionState]. + */ private val Half = SheetDetent("half") { containerHeight, _ -> containerHeight * 0.5f } @@ -129,10 +138,19 @@ internal class ModalBottomSheetScene constructor( val isHalfSheet = metadata[NavMetadataKeys.IsHalfSheet.key] as? Boolean ?: false val restingDetent = if (isHalfSheet) Half else Expanded + // Whether the sheet's content overruns [restingDetent]. Half sheets start out + // unexpandable and are handed [Expanded] only once their content says it needs it. + var contentOverflows by remember { mutableStateOf(false) } + // Kept stable: this backs a static local, so a fresh lambda each pass would + // needlessly invalidate the whole sheet subtree. + val setContentOverflows = remember { { overflows: Boolean -> + contentOverflows = overflows + } } + val sheetState = rememberBottomSheetState( initialDetent = SheetDetent.Hidden, detents = if (isHalfSheet) { - listOf(SheetDetent.Hidden, Half, Expanded) + listOf(SheetDetent.Hidden, Half) } else { listOf(SheetDetent.Hidden, Expanded) }, @@ -141,6 +159,20 @@ internal class ModalBottomSheetScene constructor( }, ) + // Offer the expanded detent only while there is something below the fold to expand into, + // so a sheet that already fits can't be dragged up into empty space. Never withdraw it + // while the sheet is sitting at (or heading toward) it. + LaunchedEffect(isHalfSheet, contentOverflows) { + if (!isHalfSheet) return@LaunchedEffect + val atExpanded = sheetState.currentDetent == Expanded || + sheetState.targetDetent == Expanded + sheetState.detents = if (contentOverflows || atExpanded) { + listOf(SheetDetent.Hidden, Half, Expanded) + } else { + listOf(SheetDetent.Hidden, Half) + } + } + // Animate the sheet in on first composition and after // same-route dismiss-replace (sheetGeneration increments). LaunchedEffect(navigator.sheetGeneration) { @@ -186,6 +218,7 @@ internal class ModalBottomSheetScene constructor( LocalSheetGesturesState provides { enabled -> allowDismiss = enabled && !navigator.sheetDragDisabled }, + LocalSheetExpansionState provides setContentOverflows, LocalScrimController provides scrim, ) { BackHandler(enabled = effectiveProperties.dismissOnBackPress) { diff --git a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt new file mode 100644 index 0000000000..1903beca80 --- /dev/null +++ b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt @@ -0,0 +1,38 @@ +package com.getcode.ui.utils + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Lets sheet content tell the host sheet whether it overruns the resting detent. + * + * A [com.getcode.navigation.HalfSheet] rests at half the screen, so content that fits in that half + * is already showing everything it has; dragging up would only reveal empty space. The host + * therefore withholds the expanded detent until content reports `true` here. + */ +val LocalSheetExpansionState = staticCompositionLocalOf<(Boolean) -> Unit> { { } } + +/** + * Reports the host sheet as expandable for as long as [scrollState] has content out of view. + * + * A list that can't scroll at the resting detent has nothing to gain from expanding. The report is + * reset when the content leaves composition so the next sheet starts from its own measurement. + */ +@Composable +fun AllowSheetExpansionWhenScrollable(scrollState: LazyListState) { + val setExpandable = LocalSheetExpansionState.current + + val hasContentOutOfView by remember(scrollState) { + derivedStateOf { scrollState.canScrollForward || scrollState.canScrollBackward } + } + + DisposableEffect(setExpandable, hasContentOutOfView) { + setExpandable(hasContentOutOfView) + onDispose { setExpandable(false) } + } +} diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index 68661de848..a7845e5495 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -45,6 +45,7 @@ internal val CodeDefaultColorScheme = ColorScheme( textMain = TextMain, textSecondary = TextSecondary, textTertiary = TextTertiary, + textPlaceholder = TextTertiary, border = BrandLight, divider = White10, dividerVariant = White05, @@ -150,6 +151,8 @@ class ColorScheme( textMain: Color, textSecondary: Color, textTertiary: Color, + /** Dimmed stand-in text β€” e.g. the untouched "$0" in amount entry. */ + textPlaceholder: Color, trackColor: Color, toggleUncheckedTrackColor: Color, cashBill: Color, @@ -207,6 +210,8 @@ class ColorScheme( private set var textTertiary by mutableStateOf(textTertiary) private set + var textPlaceholder by mutableStateOf(textPlaceholder) + private set var secondary by mutableStateOf(secondary) private set var tertiary by mutableStateOf(tertiary) @@ -277,6 +282,7 @@ class ColorScheme( textMain = other.textMain textSecondary = other.textSecondary textTertiary = other.textTertiary + textPlaceholder = other.textPlaceholder secondary = other.secondary tertiary = other.tertiary indicator = other.indicator @@ -323,6 +329,7 @@ class ColorScheme( textMain = textMain, textSecondary = textSecondary, textTertiary = textTertiary, + textPlaceholder = textPlaceholder, secondary = secondary, tertiary = tertiary, indicator = indicator, From c199a1f3c8b9896de8d2265df1c30b45d0612b81 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:15:29 -0400 Subject: [PATCH 5/9] refactor(ui): slot-based AmountWithKeypad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns AmountWithKeypad into a slot-based component so callers supply the accessory row rather than the component guessing at it, and gives the v2 layout the gap above the amount field that the spec calls for β€” including on small screens, where a bare Spacer under heightIn(max=) measured to the minimum and collapsed. --- .../flipcash/app/core/ui/AmountWithKeypad.kt | 190 ++++++++------ .../core/src/main/res/values/strings.xml | 6 + .../flipcash/app/tokens/SwapEntryScreen.kt | 59 ++++- .../com/flipcash/app/tokens/SwapFlowScreen.kt | 54 ++++ .../app/tokens/TokenBuyReceiptScreen.kt | 4 +- .../tokens/internal/TokenBuyReceiptScreen.kt | 21 +- ...inationSelector.kt => TokenSelectorRow.kt} | 46 +++- .../shared/amountentry/AmountEntryScreen.kt | 47 +++- .../flipcash/app/tokens/ui/SwapViewModel.kt | 242 +++++++++++++++--- 9 files changed, 526 insertions(+), 143 deletions(-) rename apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/{ConvertDestinationSelector.kt => TokenSelectorRow.kt} (61%) diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt index 690bf58a9c..abf278a57c 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/AmountWithKeypad.kt @@ -1,106 +1,51 @@ package com.flipcash.app.core.ui import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.runtime.Composable -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.getcode.theme.CodeTheme import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.ui.components.text.AmountArea import com.getcode.ui.theme.CodeKeyPad import com.getcode.utils.network.LocalNetworkObserver +/** + * Amount entry: an amount field stacked over a keypad, with an optional row between the two. + * + * The upper region and the strip above the keypad are slots, so a flow supplies whatever header + * it needs rather than this component collecting a flag per variant. [AmountEntryField] is the + * default header; [LargeAmountField] is the v2 (Get/Convert) one. + */ @Composable fun AmountWithKeypad( - amountAnimatedModel: AmountAnimatedInputUiModel, + onNumberPressed: (Int) -> Unit, + onBackspace: () -> Unit, modifier: Modifier = Modifier, - prefix: String = "", - placeholder: String = "", - currencyFlag: Int? = null, decimalPlaces: Int = 2, - hint: String = "", - isError: Boolean = false, - isClickable: Boolean = false, - /** - * v2 layout: instead of the centred amount with an "Enter up to" caption, the amount is - * top-anchored and left-aligned at display-extra-large, over an "$X available" line. - */ - largeHeader: Boolean = false, + onDecimal: () -> Unit = { }, /** Optional row rendered between the amount and the keypad (e.g. a Convert destination picker). */ accessory: (@Composable () -> Unit)? = null, - onNumberPressed: (Int) -> Unit, - onAmountClicked: () -> Unit = { }, - onBackspace: () -> Unit, - onDecimal: () -> Unit = { }, + amountField: @Composable BoxScope.() -> Unit, ) { - val networkObserver = LocalNetworkObserver.current - val networkState by networkObserver.state.collectAsStateWithLifecycle() - Column( modifier = modifier, ) { Box( modifier = Modifier .fillMaxWidth() - .weight(0.65f) - ) { - if (largeHeader) { - // The header carries no currency flag or chevron β€” currency is fixed for the flow. - AmountArea( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopStart) - // 24dp above the amount, per spec β€” AmountArea's own row contributes 8dp. - .padding(top = CodeTheme.dimens.grid.x4) - .padding(horizontal = CodeTheme.dimens.inset), - amountPrefix = prefix, - amountText = "", - placeholder = placeholder, - captionText = hint, - currencyResId = null, - isAltCaptionKinIcon = false, - isAltCaption = isError, - uiModel = amountAnimatedModel, - isAnimated = true, - isClickable = false, - networkState = networkState, - textStyle = CodeTheme.typography.displayExtraLarge, - horizontalAlignment = Alignment.Start, - contentColor = if (amountAnimatedModel.amountData.amount == 0.0) { - CodeTheme.colors.textPlaceholder - } else { - CodeTheme.colors.textMain - }, - decimalPlaces = decimalPlaces, - ) - } else { - AmountArea( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(horizontal = CodeTheme.dimens.inset), - amountPrefix = prefix, - amountText = "", - placeholder = placeholder, - captionText = hint, - currencyResId = currencyFlag, - isAltCaptionKinIcon = false, - isAltCaption = isError, - uiModel = amountAnimatedModel, - isAnimated = true, - isClickable = isClickable, - onClick = { onAmountClicked.invoke() }, - networkState = networkState, - textStyle = CodeTheme.typography.displayLarge, - decimalPlaces = decimalPlaces - ) - } - } + .weight(0.65f), + content = amountField, + ) accessory?.let { Box( @@ -123,3 +68,100 @@ fun AmountWithKeypad( ) } } + +/** + * The default amount field: centred in its region at display-large, with an optional currency + * flag that doubles as the currency picker affordance. + */ +@Composable +fun BoxScope.AmountEntryField( + amountAnimatedModel: AmountAnimatedInputUiModel, + prefix: String = "", + placeholder: String = "", + currencyFlag: Int? = null, + decimalPlaces: Int = 2, + hint: String = "", + isError: Boolean = false, + isClickable: Boolean = false, + onClick: () -> Unit = { }, +) { + val networkObserver = LocalNetworkObserver.current + val networkState by networkObserver.state.collectAsStateWithLifecycle() + + AmountArea( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .padding(horizontal = CodeTheme.dimens.inset), + amountPrefix = prefix, + amountText = "", + placeholder = placeholder, + captionText = hint, + currencyResId = currencyFlag, + isAltCaptionKinIcon = false, + isAltCaption = isError, + uiModel = amountAnimatedModel, + isAnimated = true, + isClickable = isClickable, + onClick = onClick, + networkState = networkState, + textStyle = CodeTheme.typography.displayLarge, + decimalPlaces = decimalPlaces, + ) +} + +/** + * v2 amount field: top-anchored and left-aligned at display-extra-large over an "$X available" + * line. Carries no currency flag or chevron β€” the currency is fixed for the flows that use it. + */ +@Composable +fun LargeAmountField( + amountAnimatedModel: AmountAnimatedInputUiModel, + modifier: Modifier = Modifier, + prefix: String = "", + placeholder: String = "", + decimalPlaces: Int = 2, + hint: String = "", + isError: Boolean = false, +) { + val networkObserver = LocalNetworkObserver.current + val networkState by networkObserver.state.collectAsStateWithLifecycle() + + Column(modifier = modifier.fillMaxSize()) { + // The gap below the app bar, so the amount isn't jammed against the chrome β€” AmountArea's + // own row contributes 8dp on top of this. height() rather than heightIn(max=): the latter + // only lifts the max constraint, and a bare Spacer has no intrinsic size, so it measures to + // the minimum and collapses to nothing. height() fixes both bounds, and still coerces into + // the incoming constraints β€” so on a screen too short to grant the weighted share it gives + // way rather than squeezing the 74sp digits. + Spacer( + modifier = Modifier + .weight(1f, fill = false) + .height(CodeTheme.dimens.grid.x8) + ) + AmountArea( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + amountPrefix = prefix, + amountText = "", + placeholder = placeholder, + captionText = hint, + currencyResId = null, + isAltCaptionKinIcon = false, + isAltCaption = isError, + uiModel = amountAnimatedModel, + isAnimated = true, + isClickable = false, + networkState = networkState, + textStyle = CodeTheme.typography.displayExtraLarge, + horizontalAlignment = Alignment.Start, + contentColor = if (amountAnimatedModel.amountData.amount == 0.0) { + CodeTheme.colors.textPlaceholder + } else { + CodeTheme.colors.textMain + }, + decimalPlaces = decimalPlaces, + ) + } +} diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 33a254094c..56d2b88bf1 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -579,6 +579,12 @@ You Convert %1$s available Confirm + + + Get + Get with + You Get + Confirm Review the above before confirming.\nOnce made, your transaction is irreversible. Yes 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 3740f20ff9..95237979c7 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 @@ -17,6 +17,7 @@ import com.flipcash.app.core.tokens.SwapStep import com.flipcash.app.core.verification.VerificationResult import com.flipcash.app.onramp.CoinbaseOnRampCompletion import com.flipcash.app.onramp.LocalCoinbaseOnRampController +import com.flipcash.app.tokens.internal.BuyFundingSelector import com.flipcash.app.tokens.internal.ConvertDestinationSelector import com.flipcash.app.tokens.internal.SwapEntryScreenContent import com.flipcash.app.tokens.ui.SwapViewModel @@ -28,6 +29,7 @@ import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.navigateForResult import com.getcode.opencode.model.financial.Fiat import com.getcode.ui.components.AppBarWithTitle +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -53,6 +55,9 @@ 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) is SwapPurpose.BalanceIncrease -> stringResource(R.string.title_amountToBuy) is SwapPurpose.BalanceDecrease -> stringResource(R.string.title_amountToSell) }, @@ -68,18 +73,31 @@ internal fun SwapEntryScreen( SwapEntryScreenContent( viewModel = viewModel, - largeHeader = purpose is SwapPurpose.Convert, - accessory = if (purpose is SwapPurpose.Convert) { - { - ConvertDestinationSelector( - destination = state.destinationTokenWithBalance, - onClick = { - viewModel.dispatchEvent(SwapViewModel.Event.SelectConvertDestination) - }, - ) + largeHeader = purpose is SwapPurpose.Convert || state.isGet, + accessory = when { + purpose is SwapPurpose.Convert -> { + { + ConvertDestinationSelector( + destination = state.destinationTokenWithBalance, + onClick = { + viewModel.dispatchEvent(SwapViewModel.Event.SelectConvertDestination) + }, + ) + } + } + // v2 Get picks what pays for the buy here, so the amount can be capped and the + // fee priced before confirming. v1 asks on a pushed step afterwards instead. + state.isGet -> { + { + BuyFundingSelector( + funding = state.fundingTokenWithBalance, + onClick = { + viewModel.dispatchEvent(SwapViewModel.Event.SelectBuyFundingSource) + }, + ) + } } - } else { - null + else -> null }, ) } @@ -99,6 +117,25 @@ internal fun SwapEntryScreen( .launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.navigateTo(SwapStep.FundingSelection) } + .launchIn(this) + } + + // v2 Get confirms straight from here, so the receipt push lives here too. Resolving the + // funding token is async and can fail (stale rates), in which case OnFundingTokenResolved + // never fires and its alert surfaces on this screen. Gated on isGet so v1 β€” where the token + // select screen owns this push and this screen is still in the stack β€” doesn't double-navigate. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .filter { viewModel.stateFlow.value.isGet } + .onEach { flowNavigator.navigateTo(SwapStep.BuyReceipt) } + .launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() 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 17403e5c09..898c5feb6c 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 @@ -120,6 +120,9 @@ private fun swapEntryProvider( annotatedEntry { ConvertDestinationSelectScreen() } + annotatedEntry { + BuyFundingSelectScreen() + } annotatedEntry { ConvertReceiptScreen() } annotatedEntry { PhantomConnectConfirmationScreen(depositFirstPurpose = depositFirstPurpose) @@ -189,6 +192,57 @@ private fun ConvertDestinationSelectScreen() { } } +/** + * Payment-source picker for a v2 Get. Selecting a currency re-points the entry cap and pops back to + * amount entry β€” unlike [SwapPurchaseTokenSelectScreen], which prices the buy and pushes a receipt. + */ +@Composable +private fun BuyFundingSelectScreen() { + val selectionViewModel = hiltViewModel() + val viewModel = flowSharedViewModel() + val flowNavigator = rememberFlowNavigator() + val state = viewModel.stateFlow.collectAsStateWithLifecycle().value + + val buy = state.purpose as? SwapPurpose.Buy ?: return + val current = state.fundingMint ?: return + + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + ) { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding( + top = CodeTheme.dimens.staticGrid.x7, + bottom = CodeTheme.dimens.staticGrid.x5, + ), + text = stringResource(R.string.title_selectCurrency), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + + TokenSelectScreen( + purpose = TokenPurpose.BuyFunding(target = buy.mint, current = current), + showTopBar = false, + presentation = TokenListPresentation.Sheet, + ) + } + + LaunchedEffect(selectionViewModel) { + selectionViewModel.eventFlow + .filterIsInstance() + .filter { it.fromUser } + .map { it.mint } + .onEach { + viewModel.dispatchEvent(SwapViewModel.Event.OnFundingSourceSelected(it)) + flowNavigator.back() + } + .launchIn(this) + } +} + @Composable private fun SwapPurchaseTokenSelectScreen(targetMint: Mint, amount: Fiat) { val selectionViewModel = hiltViewModel() 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 315e67288f..7154a204ee 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 @@ -32,7 +32,9 @@ internal fun BuyReceiptScreen() { horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithTitle( - title = stringResource(R.string.title_confirmPurchase), + title = stringResource( + if (state.isGet) R.string.title_get else R.string.title_confirmPurchase + ), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { flowNavigator.back() } ) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt index 2e170fcf60..9f9d85fa17 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt @@ -81,7 +81,9 @@ private fun TokenBuyReceiptScreen( .navigationBarsPadding() .imePadding() .padding(bottom = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.action_buy), + text = stringResource( + if (state.isGet) R.string.action_confirm else R.string.action_buy + ), buttonState = ButtonState.Filled, isLoading = state.buyProgress.loading, isSuccess = state.buyProgress.success, @@ -108,6 +110,7 @@ private fun TokenBuyReceiptScreen( desiredToken = state.tokenWithBalance?.token, purchaseAmount = state.confirmedEnteredAmount, feeAmount = state.feeAmount, + isGet = state.isGet, ) } } @@ -119,6 +122,10 @@ private fun BuyReceipt( desiredToken: Token?, purchaseAmount: Fiat?, feeAmount: Fiat, + // v2 reframes the buy as a conversion between two currencies the user holds, so the receipt + // reads "You Get / Amount to convert / Conversion fee" β€” the same wording Convert uses. The + // math is identical either way: "You Pay" is always purchase + fee. + isGet: Boolean, modifier: Modifier = Modifier, ) { // The funding token and confirmed amount resolve asynchronously after landing here, so @@ -155,7 +162,9 @@ private fun BuyReceipt( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stringResource(R.string.subtitle_youReceive), + text = stringResource( + if (isGet) R.string.subtitle_youGet else R.string.subtitle_youReceive + ), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) @@ -189,12 +198,16 @@ private fun BuyReceipt( ) { ReceiptLineItem( modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.label_amountToBuy), + label = stringResource( + if (isGet) R.string.label_amountToConvert else R.string.label_amountToBuy + ), amount = purchaseAmount.formatted() ) ReceiptLineItem( modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.label_exchangeFee), + label = stringResource( + if (isGet) R.string.label_conversionFee else R.string.label_exchangeFee + ), amount = feeAmount.formatted( extraPrefix = if (feeAmount.decimalValue < 0.01) "~ " else null, ) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt similarity index 61% rename from apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt rename to apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt index 87708b40ed..bdcf9be5ce 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/ConvertDestinationSelector.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt @@ -20,23 +20,27 @@ import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.theme.CodeTheme /** - * "Convert to [πŸͺ™ Currency βŒ„]" β€” the destination picker that sits between the entered amount and - * the keypad on the Convert amount screen. Tapping it pushes the currency list as a flow step. + * "Label [πŸͺ™ Currency βŒ„]" β€” the inline currency picker that sits between the entered amount and + * the keypad. Tapping the trailing chip pushes a currency list as a flow step. + * + * Convert and the v2 Get both pick a currency here; only the label and which side of the trade it + * names differ, so they share this row via [ConvertDestinationSelector] and [BuyFundingSelector]. */ @Composable -internal fun ConvertDestinationSelector( - destination: TokenWithBalance?, +private fun TokenSelectorRow( + label: String, + selected: TokenWithBalance?, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - destination ?: return + selected ?: return Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.label_convertTo), + text = label, style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) @@ -49,11 +53,11 @@ internal fun ConvertDestinationSelector( verticalAlignment = Alignment.CenterVertically, ) { TokenIconWithName( - token = destination.token, + token = selected.token, imageSize = CodeTheme.dimens.staticGrid.x6, textStyle = CodeTheme.typography.textMedium, spacing = CodeTheme.dimens.grid.x2, - displayName = { destination.displayName }, + displayName = { selected.displayName }, ) Icon( @@ -65,3 +69,29 @@ internal fun ConvertDestinationSelector( } } } + +/** "Convert to [πŸͺ™ Currency βŒ„]" β€” what a conversion lands in. */ +@Composable +internal fun ConvertDestinationSelector( + destination: TokenWithBalance?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) = TokenSelectorRow( + label = stringResource(R.string.label_convertTo), + selected = destination, + onClick = onClick, + modifier = modifier, +) + +/** "Get with [πŸͺ™ Currency βŒ„]" β€” what a v2 Get is paid from. */ +@Composable +internal fun BuyFundingSelector( + funding: TokenWithBalance?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) = TokenSelectorRow( + label = stringResource(R.string.label_getWith), + selected = funding, + onClick = onClick, + modifier = modifier, +) 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 62191b0e23..728b775813 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 @@ -9,7 +9,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.ui.AmountEntryField import com.flipcash.app.core.ui.AmountWithKeypad +import com.flipcash.app.core.ui.LargeAmountField import com.flipcash.app.core.ui.ConfirmationStyle import com.getcode.theme.CodeTheme import com.getcode.ui.components.SlideToConfirm @@ -73,28 +75,45 @@ fun AmountEntryScreen( } }, ) { padding -> + val hintText = when (val hint = config.hint) { + is AmountEntryHint.None -> "" + is AmountEntryHint.Info -> hint.text + is AmountEntryHint.Error -> hint.text + } + val isError = config.hint is AmountEntryHint.Error + AmountWithKeypad( modifier = Modifier .fillMaxSize() .padding(padding), - amountAnimatedModel = delegateState.amountAnimatedModel, - currencyFlag = delegateState.currency.selected?.resId, - prefix = delegateState.currency.selected?.symbol.orEmpty(), - placeholder = "0", - hint = when (val hint = config.hint) { - is AmountEntryHint.None -> "" - is AmountEntryHint.Info -> hint.text - is AmountEntryHint.Error -> hint.text - }, decimalPlaces = delegateState.currency.fractionUnits, - isClickable = config.canChangeCurrency, - onAmountClicked = onChangeCurrency, - isError = config.hint is AmountEntryHint.Error, - largeHeader = largeHeader, accessory = accessory, onNumberPressed = { controller.onNumber(it) }, onBackspace = { controller.onBackspace() }, onDecimal = { controller.onDecimal() }, - ) + ) { + if (largeHeader) { + LargeAmountField( + amountAnimatedModel = delegateState.amountAnimatedModel, + prefix = delegateState.currency.selected?.symbol.orEmpty(), + placeholder = "0", + hint = hintText, + isError = isError, + decimalPlaces = delegateState.currency.fractionUnits, + ) + } else { + AmountEntryField( + amountAnimatedModel = delegateState.amountAnimatedModel, + currencyFlag = delegateState.currency.selected?.resId, + prefix = delegateState.currency.selected?.symbol.orEmpty(), + placeholder = "0", + hint = hintText, + isError = isError, + decimalPlaces = delegateState.currency.fractionUnits, + isClickable = config.canChangeCurrency, + onClick = onChangeCurrency, + ) + } + } } } 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 ae866f4694..4d3933edc1 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 @@ -11,6 +11,8 @@ 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 @@ -54,6 +56,7 @@ import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.TokenWithLocalizedBalance +import com.getcode.opencode.model.financial.div import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee import com.getcode.opencode.model.financial.launchpadSellFee import com.getcode.opencode.model.financial.max @@ -115,6 +118,7 @@ class SwapViewModel @Inject constructor( private val phantomWalletController: PhantomWalletController, private val userFlags: UserFlagsCoordinator, private val usdcDepositSweep: UsdcDepositSweep, + private val featureFlags: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -126,6 +130,9 @@ class SwapViewModel @Inject constructor( val isAddingMoney = vmState.isAddingMoney val isAddingMoneyViaPhantom = isAddingMoney && vmState.addingMoneyFrom == FundingSource.Phantom val isConverting = vmState.purpose is SwapPurpose.Convert + // A v2 Get spends a currency the user already holds, so its ceiling reads like Convert's + // ("$X available") rather than v1's daily-limit sentence. + val statesBalanceAsCeiling = isConverting || vmState.isGet AmountEntryStyle( actionLabel = when { isAddingMoneyViaPhantom -> { @@ -152,12 +159,12 @@ class SwapViewModel @Inject constructor( // Convert's v2 header states the ceiling as a plain "$X available" line that simply // turns red once exceeded, rather than swapping in a separate over-limit sentence. infoHint = { - if (isConverting) resources.getString(R.string.subtitle_amountAvailable, it) + if (statesBalanceAsCeiling) resources.getString(R.string.subtitle_amountAvailable, it) else resources.getString(R.string.subtitle_buySellCashHint, it) }, overMaxHint = { when { - isConverting -> resources.getString(R.string.subtitle_amountAvailable, it) + statesBalanceAsCeiling -> resources.getString(R.string.subtitle_amountAvailable, it) vmState.purpose is SwapPurpose.BalanceIncrease -> resources.getString(R.string.subtitle_buyHintLimitExceeded, it) else -> resources.getString(R.string.subtitle_sellHintLimitExceeded, it) @@ -167,31 +174,58 @@ class SwapViewModel @Inject constructor( ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AmountEntryStyle(actionLabel = AmountEntryLabel.Plain(""))) + /** The slice of [State] the entry ceiling depends on, so the ceiling recomputes when it moves. */ + private data class MaxAmountInputs( + val purpose: SwapPurpose?, + val maxToAdd: Pair?, + val tokenBalance: Fiat, + val isAddingMoney: Boolean, + val fundingMint: Mint?, + val isGet: Boolean, + ) + private val maxAmountFlow: StateFlow = combine( - stateFlow.map { it.purpose }, - stateFlow.map { it.amountEntryState.maxToAdd }, - stateFlow.map { it.tokenBalance }, + stateFlow.map { + MaxAmountInputs( + purpose = it.purpose, + maxToAdd = it.amountEntryState.maxToAdd, + tokenBalance = it.tokenBalance, + isAddingMoney = it.isAddingMoney, + fundingMint = it.fundingMint, + isGet = it.isGet, + ) + }.distinctUntilChanged(), tokenCoordinator.tokenBalances.distinctUntilChanged(), exchange.observePreferredRate(), - ) { purpose, maxToAdd, tokenBalance, tokenBalances, rate -> - when (purpose) { + ) { inputs, tokenBalances, rate -> + when (inputs.purpose) { is SwapPurpose.Buy -> { - val limit = maxToAdd?.let { Fiat(it.first, it.second) } - if (!stateFlow.value.isAddingMoney) { + val limit = inputs.maxToAdd?.let { Fiat(it.first, it.second) } + if (!inputs.isAddingMoney) { // tokenBalances are USD-denominated; convert to the user's preferred // currency so the "Enter up to X" hint is localized and the over-max // comparison (which relabels the entered amount with max.currencyCode) // happens in the same currency the user is typing in. `limit` is already // in the selected currency, so both sides of min() now agree. - val maxTokenBalance = tokenBalances.maxOf { it.balance }.convertingTo(rate) + // + // A v2 Get picks its payment source *before* the amount, so the ceiling is + // that one balance. v1 defers the choice to a later step, so it can only cap + // at the largest balance the user holds. + val spendable = if (inputs.isGet && inputs.fundingMint != null) { + tokenBalances.firstOrNull { it.token.address == inputs.fundingMint } + ?.balance ?: Fiat.Zero + } else { + tokenBalances.maxOf { it.balance } + } + val maxTokenBalance = spendable.convertingTo(rate) return@combine limit?.let { min(maxTokenBalance, it) } ?: maxTokenBalance } limit } - is SwapPurpose.Sell -> tokenBalance + is SwapPurpose.Sell -> inputs.tokenBalance // Convert spends the *source* currency, so the ceiling is that balance β€” same as Sell. - is SwapPurpose.Convert -> tokenBalance + is SwapPurpose.Convert -> inputs.tokenBalance null -> null } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) @@ -235,6 +269,7 @@ class SwapViewModel @Inject constructor( val fundingTokenWithBalance: TokenWithBalance? = null, // Convert only: the currency the conversion lands in. `tokenWithBalance` is the source. val destinationTokenWithBalance: TokenWithBalance? = null, + val newUiEnabled: Boolean = false, ) { val sellFee: Double? get() { @@ -278,6 +313,32 @@ class SwapViewModel @Inject constructor( get() = purpose is SwapPurpose.Buy && purpose.fundingSource != FundingSource.Flexible val addingMoneyFrom: FundingSource? get() = (purpose as? SwapPurpose.Buy)?.fundingSource + + /** + * The v2 "Get" flow: a direct buy with the payment source picked inline on the amount + * screen rather than on a pushed step afterwards. Adding money from an external source + * (Coinbase/Phantom) keeps its own flow either way. + */ + val isGet: Boolean + get() = newUiEnabled && purpose is SwapPurpose.Buy && !isAddingMoney + + /** The currency a Get is paid from. Null until the default is seeded or one is picked. */ + val fundingMint: Mint? + get() = fundingTokenWithBalance?.token?.address + + /** + * Whether the buy collects an explicit fee on top of the entered amount. + * + * Paying with a currency always did β€” its pool's sell fee is grossed up into the debit. A + * Get paid from Dollars has no pool to skim, so v2 charges the flat house rate on top; the + * v1 reserves buy stays free. + */ + val chargesBuyFee: Boolean + get() = when (fundingMint) { + null -> false + Mint.usdf -> isGet + else -> purpose is SwapPurpose.Buy + } } sealed interface Event { @@ -340,6 +401,19 @@ 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. */ + data object SelectBuyFundingSource : Event + /** A payment source was picked (or defaulted); the token still needs resolving. */ + data class OnFundingSourceSelected(val mint: Mint) : Event + /** + * The picked payment source resolved to a token. Distinct from [OnFundingTokenResolved], + * which prices the buy and advances to the receipt β€” this only re-points the entry cap. + */ + data class OnFundingSourceResolved(val token: TokenWithBalance) : Event + data class OnNewUiChanged(val enabled: Boolean) : Event + // endregion + // region convert data object SelectConvertDestination : Event data class OnDestinationSelected(val mint: Mint) : Event @@ -447,16 +521,19 @@ class SwapViewModel @Inject constructor( * can't cover it β€” surface a modal (automatically, on landing the receipt) offering to drop to * the maximum affordable amount rather than letting the buy fail. * - * No-op for USDF funding (no fee), and sub-cent rounding is tolerated so applying the max - * doesn't immediately re-prompt. All comparisons are in USD ([Fiat.convertingToUsdIfNeeded]), - * the common denominator across native currencies. + * No-op when the funding side charges nothing β€” v1's USDF buy β€” and sub-cent rounding is + * tolerated so applying the max doesn't immediately re-prompt. All comparisons are in USD + * ([Fiat.convertingToUsdIfNeeded]), the common denominator across native currencies. */ private fun maybePromptInsufficientBalanceAfterFees( fundingToken: Token, payTotal: Fiat, rate: Rate, ) { - if (fundingToken.address == Mint.usdf) return + // A v2 Get from Dollars charges the house rate on top instead of skimming a pool, so it + // can overrun the balance just like a cross-currency buy. v1's USDF buy is still free. + val feeChargedOnTop = fundingToken.address == Mint.usdf && stateFlow.value.isGet + if (fundingToken.address == Mint.usdf && !feeChargedOnTop) return val balanceUsd = tokenCoordinator.balanceForToken(fundingToken).convertingToUsdIfNeeded(rate) val payUsd = payTotal.convertingToUsdIfNeeded(rate) @@ -477,8 +554,16 @@ class SwapViewModel @Inject constructor( // to amount entry preserves what the user typed. selectedAmount is passed through // unchanged. val balanceNative = balanceUsd.convertingTo(rate) - val maxReceive = balanceNative - - balanceNative.launchpadSellFee(fundingToken.launchpadMetadata?.sellFeeBps ?: 0) + val maxReceive = if (feeChargedOnTop) { + // Fee on top: pay = receive Γ— (1 + f), so the balance affords receive = + // balance / (1 + f). + balanceNative / (1.0 + DEFAULT_CONVERT_FEE_BPS / 10_000.0) + } else { + // Fee grossed up: pay = receive / (1 - f), so the balance affords receive = + // balance Γ— (1 - f). + balanceNative - + balanceNative.launchpadSellFee(fundingToken.launchpadMetadata?.sellFeeBps ?: 0) + } val maxFee = balanceNative - maxReceive dispatchEvent( Event.OnAmountAccepted( @@ -505,8 +590,16 @@ class SwapViewModel @Inject constructor( } ?: SendLimit.Zero val maxSendPerDay = sendLimit.maxPerDay.toFiat(enteredAmount.currencyCode) if (!stateFlow.value.isAddingMoney) { - val balances = tokenCoordinator.tokenBalances.firstOrNull().orEmpty().map { it.balance } - min((balances.maxOrNull() ?: Fiat.Zero), maxSendPerDay) + val held = tokenCoordinator.tokenBalances.firstOrNull().orEmpty() + // Mirrors maxAmountFlow: a v2 Get already knows which balance it's spending, + // so the limit is that one; v1 can only bound by the largest balance held. + val fundingMint = stateFlow.value.fundingMint + val spendable = if (stateFlow.value.isGet && fundingMint != null) { + held.firstOrNull { it.token.address == fundingMint }?.balance ?: Fiat.Zero + } else { + held.map { it.balance }.maxOrNull() ?: Fiat.Zero + } + min(spendable, maxSendPerDay) } else { maxSendPerDay } @@ -585,6 +678,49 @@ class SwapViewModel @Inject constructor( } init { + featureFlags.observe(FeatureFlag.NewUi) + .onEach { dispatchEvent(Event.OnNewUiChanged(it)) } + .launchIn(viewModelScope) + + // v2 Get seeds a payment source up front so the amount screen can cap entry and price the + // fee before anything is confirmed. Dollars is the house default; failing that, whichever + // held currency goes furthest. v1 leaves this null and asks after the amount instead. + eventFlow.filterIsInstance() + .map { it.purpose } + .filterIsInstance() + .filter { it.fundingSource == FundingSource.Flexible } + .filter { featureFlags.get(FeatureFlag.NewUi) } + .onEach { purpose -> + val spendable = tokenCoordinator.tokenBalances + .first { it.isNotEmpty() } + .filter { it.token.address != purpose.mint && it.balance.hasDisplayableValue } + + val default = spendable.firstOrNull { it.token.address == Mint.usdf } + ?: spendable.maxByOrNull { it.balance } + ?: return@onEach + + dispatchEvent(Event.OnFundingSourceSelected(default.token.address)) + } + .launchIn(viewModelScope) + + // Resolving a picked source only re-points the entry cap β€” it must not price the buy or + // advance to the receipt, which is what OnFundingTokenSelected does at confirm time. + eventFlow.filterIsInstance() + .map { it.mint } + .distinctUntilChanged() + .flatMapLatest { mint -> + combine( + tokenCoordinator.tokens, + tokenCoordinator.balanceForToken(mint), + ) { tokens, balance -> + val token = tokens.find { it.address == mint } ?: return@combine null + TokenWithBalance(token = token, balance = balance) + } + } + .filterNotNull() + .onEach { dispatchEvent(Event.OnFundingSourceResolved(it)) } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() .map { it.purpose } .flatMapLatest { purpose -> @@ -772,14 +908,22 @@ class SwapViewModel @Inject constructor( } !isAddingMoney -> { - // Direct buy β€” let the user choose which token funds it. - // The reserves-vs-cross-currency decision is deferred to - // the buyOrSwap flow, once a funding token is selected. - dispatchEvent( - Event.SelectFundingToken( - Fiat(delegateState.enteredAmount, rate.currency) + val fundingMint = stateFlow.value.fundingMint + if (stateFlow.value.isGet && fundingMint != null) { + // v2 Get: the payment source was picked on the amount screen, + // so confirming prices the buy straight away and lands on the + // receipt β€” no funding-token step in between. + dispatchEvent(Event.OnFundingTokenSelected(fundingMint)) + } else { + // Direct buy β€” let the user choose which token funds it. + // The reserves-vs-cross-currency decision is deferred to + // the buyOrSwap flow, once a funding token is selected. + dispatchEvent( + Event.SelectFundingToken( + Fiat(delegateState.enteredAmount, rate.currency) + ) ) - ) + } } else -> { @@ -1103,7 +1247,14 @@ class SwapViewModel @Inject constructor( ) val exchangeFee = if (token.address == Mint.usdf) { - 0.toFiat((rate.currency)) + // Dollars has no launchpad sale to skim, so a v2 Get pays the flat house rate + // *on top* of the entered amount β€” the same convention Convert-from-Dollars + // uses. The v1 reserves buy stays free. + if (stateFlow.value.isGet) { + nativeAmount.launchpadSellFee(DEFAULT_CONVERT_FEE_BPS) + } else { + 0.toFiat((rate.currency)) + } } else { // The pool's sell fee is grossed up on top of the entered amount, so the // fee is (amount / (1 - fee)) - amount. Uses the funding pool's own bps, @@ -1156,11 +1307,21 @@ class SwapViewModel @Inject constructor( // deduction nets back down to exactly what was entered. The fee is the FUNDING // pool's β€” that's the token being sold β€” and is sent as zero (server-enforced) in // the swap request below. USDF pays no pool fee. + // A v2 Get from Dollars is the exception: its fee is charged on top rather than + // skimmed on-chain, so the debit is entered + fee and the fee travels explicitly + // in the request (see ProceedWithPurchase). val amountFiat = verifiedFiatCalculator.compute( - amount = if (fundingToken.address == Mint.usdf) enteredFiat - else enteredFiat.grossingUpLaunchpadSellFee( + amount = if (fundingToken.address == Mint.usdf) { + if (stateFlow.value.isGet) { + enteredFiat + enteredFiat.launchpadSellFee(DEFAULT_CONVERT_FEE_BPS) + } else { + enteredFiat + } + } else { + enteredFiat.grossingUpLaunchpadSellFee( bps = fundingToken.launchpadMetadata?.sellFeeBps ?: 0, - ), + ) + }, token = fundingToken, balance = tokenCoordinator.balanceForToken(fundingToken).convertingToUsdIfNeeded(rate), rate = rate, @@ -1200,9 +1361,21 @@ class SwapViewModel @Inject constructor( } val result = if (fundingToken.token.address == Mint.usdf) { + // `amount` is the gross debit β€” the service nets the fee back out of it. v1 + // charges nothing, so its gross is the entered amount and the fee stays null; + // a v2 Get sends the house fee explicitly (mirrors ProceedWithConversion). + val rate = exchange.preferredRate transactionController.buy( owner = owner, amount = amount, + feeAmount = if (stateFlow.value.chargesBuyFee) { + LocalFiat.fromUsd( + usdf = stateFlow.value.feeAmount.convertingToUsdIfNeeded(rate), + rate = rate, + ) + } else { + null + }, of = targetToken, ) } else { @@ -1829,6 +2002,13 @@ class SwapViewModel @Inject constructor( is Event.OnFundingTokenSelected -> { state -> state } is Event.OnFundingTokenResolved -> { state -> state.copy(fundingTokenWithBalance = event.token) } + is Event.OnNewUiChanged -> { state -> state.copy(newUiEnabled = event.enabled) } + is Event.OnFundingSourceResolved -> { state -> + state.copy(fundingTokenWithBalance = event.token) + } + is Event.SelectBuyFundingSource -> { state -> state } + is Event.OnFundingSourceSelected -> { state -> state } + is Event.UpdateBuyState -> { state -> val entryState = state.buyProgress state.copy( From 967b6e8572493303d7229fecb806396f5c2c4c3a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:15:37 -0400 Subject: [PATCH 6/9] =?UTF-8?q?feat(tokens):=20v2=20"Get"=20refresh=20?= =?UTF-8?q?=E2=80=94=20inline=20funding=20source=20+=20conversion-style=20?= =?UTF-8?q?receipt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes Get as a conversion between two currencies the user holds: the funding source is picked inline on the amount-entry screen instead of on a separate step, and the receipt reads "You Get / Amount to convert / Conversion fee / You Pay". The accessory token chip cross-fades when the selection changes rather than popping. --- .../app/tokens/internal/TokenSelectorRow.kt | 35 +++++++++++++++---- .../app/tokens/ui/SwapViewModelErrorTest.kt | 4 +++ 2 files changed, 32 insertions(+), 7 deletions(-) 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 bdcf9be5ce..f5a0b7edf1 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 @@ -1,5 +1,11 @@ package com.flipcash.app.tokens.internal +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -52,13 +58,28 @@ private fun TokenSelectorRow( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.CenterVertically, ) { - TokenIconWithName( - token = selected.token, - imageSize = CodeTheme.dimens.staticGrid.x6, - textStyle = CodeTheme.typography.textMedium, - spacing = CodeTheme.dimens.grid.x2, - displayName = { selected.displayName }, - ) + // Cross-fade the chip when the picker returns a different currency, so the swap reads + // as the same control changing rather than a hard cut. Keyed on the mint, not the whole + // TokenWithBalance β€” a balance tick would otherwise re-run the transition. The size + // transform carries the width change, since currency names differ in length. + AnimatedContent( + targetState = selected, + contentKey = { it.token.address }, + transitionSpec = { + fadeIn(tween(durationMillis = 180, delayMillis = 60)) + .togetherWith(fadeOut(tween(durationMillis = 120))) + .using(SizeTransform(clip = false) { _, _ -> tween(durationMillis = 220) }) + }, + label = "selectedToken", + ) { token -> + TokenIconWithName( + token = token.token, + imageSize = CodeTheme.dimens.staticGrid.x6, + textStyle = CodeTheme.typography.textMedium, + spacing = CodeTheme.dimens.grid.x2, + displayName = { token.displayName }, + ) + } Icon( modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), 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 fb914d1f0b..cdb6566643 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,6 +3,7 @@ package com.flipcash.app.tokens.ui import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.app.featureflags.NoOpFeatureFlagController import com.flipcash.app.onramp.CoinbaseOnRampController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator @@ -113,6 +114,9 @@ class SwapViewModelErrorTest { dispatchers = dispatchers, userFlags = userFlagsCoordinator, usdcDepositSweep = usdcDepositSweep, + // These cases exercise the v1 buy/sell paths; the no-op controller reports every + // flag off, so NewUi stays false without stubbing a StateFlow per test. + featureFlags = NoOpFeatureFlagController, ) } From a33e6628a3285cb7fb4a58666195e5971546d349 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:14:09 -0400 Subject: [PATCH 7/9] refactor(tokens): one receipt screen behind Get and Convert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get and Convert had separate receipt screens that had converged on the same layout: the same bordered card, the same anchor rows, the same scaffold and bottom bar. Everything still differing between them was data β€” which side leads, the warning copy, the confirm label and its progress state β€” so it now arrives as data, and the fee math the two genuinely disagree on stays in its own adapter. Two knock-on changes: Convert picks up Get's shimmer, so pending token or amount data no longer blanks the screen instead of rendering it, and the line items keep Convert's spacing (Get's was 5dp tighter). The token name label under each amount is gone from both. Fee lines test hasDisplayableValue rather than a hardcoded 0.01, which was USD-shaped: a sub-unit fee in another currency now reads "~ Β₯0" instead of a bare "Β₯0". --- .../app/tokens/internal/SwapReceiptScreen.kt | 373 ++++++++++++++++++ .../tokens/internal/TokenBuyReceiptScreen.kt | 276 ------------- .../internal/TokenConvertReceiptScreen.kt | 218 ---------- 3 files changed, 373 insertions(+), 494 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapReceiptScreen.kt delete mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt delete mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapReceiptScreen.kt new file mode 100644 index 0000000000..623983a93b --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/SwapReceiptScreen.kt @@ -0,0 +1,373 @@ +package com.flipcash.app.tokens.internal + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.ui.ReceiptLineItem +import com.flipcash.app.core.ui.TokenBalanceRow +import com.flipcash.app.core.ui.TokenBalanceStyle +import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling +import com.flipcash.app.core.ui.shimmer +import com.flipcash.app.tokens.ui.SwapViewModel +import com.flipcash.features.tokens.R +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.plus +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.bolded +import com.getcode.view.LoadingSuccessState +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold + +/** + * One of the receipt's two anchor rows β€” a caption over a token logo and the amount it moves. + * + * [token] and [amount] are nullable because both resolve asynchronously: the funding token and the + * confirmed amount can each be briefly absent on first composition. A null of either renders the + * shimmer stand-in rather than collapsing the row. + */ +internal data class ReceiptAnchor( + val title: String, + val token: Token?, + val amount: Fiat?, +) + +/** A label/amount pair in the receipt's middle section (amount to convert, fees). */ +internal data class ReceiptLine( + val label: String, + val amount: String, +) + +/** + * The confirmation screen behind both Get/Buy and Convert. + * + * The two flows are the same screen: a bordered card holding an anchor row, an optional block of + * line items and a second anchor row, over a warning and a confirm button. They differ only in + * which side leads β€” Get reads "You Get / … / You Pay", Convert reads "You Convert / … / You + * Receive" β€” and in their copy, so all of that arrives as data. Each flow's own fee math stays in + * the adapter that owns it, since that is the one piece the two genuinely disagree on. + * + * [lineSpacing] is a parameter rather than a constant because v1's Buy receipt shipped a tighter + * gap between its fee lines than the v2 screens use, and v1 pixels are not ours to change here. + */ +@Composable +private fun SwapReceiptScreen( + top: ReceiptAnchor, + lines: List, + lineSpacing: Dp, + bottom: ReceiptAnchor, + warning: String, + confirmLabel: String, + progress: LoadingSuccessState, + onConfirm: () -> Unit, +) { + CodeScaffold( + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x5), + text = warning, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = confirmLabel, + buttonState = ButtonState.Filled, + isLoading = progress.loading, + isSuccess = progress.success, + onClick = onConfirm, + ) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x8), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.inset, + alignment = Alignment.CenterVertically + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = CodeTheme.dimens.border, + color = CodeTheme.colors.border, + shape = CodeTheme.shapes.medium + ) + .background(White05, CodeTheme.shapes.medium) + .padding( + horizontal = CodeTheme.dimens.grid.x4, + vertical = CodeTheme.dimens.inset + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Anchor(top) + + if (lines.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(lineSpacing), + ) { + lines.forEach { line -> + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = line.label, + amount = line.amount, + ) + } + } + } + + Anchor(bottom) + } + } + } +} + +@Composable +private fun Anchor(anchor: ReceiptAnchor) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = anchor.title, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + + val token = anchor.token + val amount = anchor.amount + if (token != null && amount != null) { + // Token logo, not the currency flag: a receipt names the token being moved, and a flag + // can't tell two currencies apart when they share one (or have none). + TokenBalanceRow( + tokenWithBalance = TokenWithBalance(token = token, balance = amount), + showName = false, + showLogo = true, + showFlag = false, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + styling = rememberTokenBalanceRowStyling( + balanceDisplayStyle = TokenBalanceStyle.Large( + textStyle = CodeTheme.typography.displaySmall.bolded() + ), + ), + contentPadding = PaddingValues(0.dp), + ) + } else { + AnchorPlaceholder() + } + } +} + +/** + * Shimmer stand-in for an anchor row while its token or amount is still resolving. Mirrors the + * row's layout: a circular logo followed by the balance text. + */ +@Composable +private fun AnchorPlaceholder(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(CodeTheme.dimens.staticGrid.x6) + .shimmer(CircleShape) + ) + Box( + Modifier + .width(CodeTheme.dimens.grid.x20) + .height(CodeTheme.dimens.grid.x6) + .shimmer() + ) + } +} + +/** + * A fee the currency can't render exactly reads as an approximation ("~ $0.00", "~ Β₯0") rather than + * as a precise figure it isn't. + * + * The test is whether anything is lost rounding to display precision, which is what the "~" claims. + * Two nearby properties answer different questions and each gets a case wrong: the fixed 0.01 + * threshold this replaces is USD-shaped and printed a bare "Β₯0" for any sub-yen fee, while + * [Fiat.hasDisplayableValue] asks only "would this format as non-zero" and so drops the "~" from a + * $0.007 fee that still renders as $0.01. + * + * Done in quarks because it is exact there: [Fiat] carries six decimal places, so one displayed + * unit is a whole number of them. Writing the same check as `rounded(n) != this` would round-trip + * through [Fiat]'s truncating Double constructor and report exact values as approximate β€” $2.01 is + * the first USD case, 1.001 the first for a three-decimal currency. + */ +private fun Fiat.formattedFee(): String { + val quarksPerDisplayedUnit = smallestUnit.quarks + val isApproximate = quarksPerDisplayedUnit > 0L && quarks % quarksPerDisplayedUnit != 0L + return formatted(extraPrefix = if (isApproximate) "~ " else null) +} + +@Composable +internal fun TokenBuyReceiptScreen(viewModel: SwapViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + TokenBuyReceiptScreen(state, viewModel::dispatchEvent) +} + +@Composable +private fun TokenBuyReceiptScreen( + state: SwapViewModel.State, + dispatchEvent: (SwapViewModel.Event) -> Unit, +) { + val purchaseAmount = state.confirmedEnteredAmount + val feeAmount = state.feeAmount + + // "You Pay" is always the purchase plus the fee, whichever framing the screen is wearing. + val totalPaid = purchaseAmount?.let { + if (!feeAmount.hasDisplayableValue) it else it + feeAmount + } + + // v2 reframes the buy as a conversion between two currencies the user holds, so the receipt + // reads "You Get / Amount to convert / Conversion fee" β€” the same wording Convert uses. + val isGet = state.isGet + + SwapReceiptScreen( + top = ReceiptAnchor( + title = stringResource( + if (isGet) R.string.subtitle_youGet else R.string.subtitle_youReceive + ), + token = state.tokenWithBalance?.token, + amount = purchaseAmount, + ), + lines = if (feeAmount.isPositive && purchaseAmount != null) { + listOf( + ReceiptLine( + label = stringResource( + if (isGet) R.string.label_amountToConvert else R.string.label_amountToBuy + ), + amount = purchaseAmount.formatted(), + ), + ReceiptLine( + label = stringResource( + if (isGet) R.string.label_conversionFee else R.string.label_exchangeFee + ), + amount = feeAmount.formattedFee(), + ), + ) + } else { + emptyList() + }, + // v1's fee lines sit a notch closer together. Gated, not unified: the Buy receipt still + // renders for v1 and for v2's Add Money, and neither is this change's to restyle. + lineSpacing = if (isGet) CodeTheme.dimens.grid.x3 else CodeTheme.dimens.grid.x2, + bottom = ReceiptAnchor( + title = stringResource(R.string.subtitle_youPay), + token = state.fundingTokenWithBalance?.token, + amount = totalPaid, + ), + warning = stringResource(R.string.label_buyWarning), + confirmLabel = stringResource( + if (isGet) R.string.action_confirm else R.string.action_buy + ), + progress = state.buyProgress, + onConfirm = { dispatchEvent(SwapViewModel.Event.OnBuyConfirmed) }, + ) +} + +@Composable +internal fun TokenConvertReceiptScreen(viewModel: SwapViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + TokenConvertReceiptScreen(state, viewModel::dispatchEvent) +} + +@Composable +private fun TokenConvertReceiptScreen( + state: SwapViewModel.State, + dispatchEvent: (SwapViewModel.Event) -> Unit, +) { + val feeAmount = state.feeAmount + + // Converting out of Dollars charges the fee on top of the entered amount, so the debit is + // entered + fee and the user receives the full entered amount. Every other direction takes the + // fee out of the sale, so the debit is what was entered and the receipt is net of the fee. + val totalDebited = if (state.isConvertingFromDollars) { + state.enteredAmount + feeAmount + } else { + state.enteredAmount + } + + SwapReceiptScreen( + top = ReceiptAnchor( + title = stringResource(R.string.subtitle_youConvert), + token = state.tokenWithBalance?.token, + amount = totalDebited, + ), + lines = listOf( + ReceiptLine( + label = stringResource(R.string.label_amountToConvert), + amount = state.enteredAmount.formatted(), + ), + ReceiptLine( + label = stringResource(R.string.label_conversionFee), + amount = feeAmount.formattedFee(), + ), + ), + lineSpacing = CodeTheme.dimens.grid.x3, + bottom = ReceiptAnchor( + title = stringResource(R.string.subtitle_youReceive), + token = state.destinationTokenWithBalance?.token, + amount = state.netTransferAmount, + ), + warning = stringResource(R.string.label_sellWarning), + confirmLabel = stringResource(R.string.action_confirmConversion), + progress = state.sellProgress, + onConfirm = { dispatchEvent(SwapViewModel.Event.OnConvertConfirmed) }, + ) +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt deleted file mode 100644 index 9f9d85fa17..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt +++ /dev/null @@ -1,276 +0,0 @@ -package com.flipcash.app.tokens.internal - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.ui.ReceiptLineItem -import com.flipcash.app.core.ui.TokenBalanceRow -import com.flipcash.app.core.ui.TokenBalanceStyle -import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling -import com.flipcash.app.core.ui.shimmer -import com.flipcash.app.tokens.ui.SwapViewModel -import com.flipcash.features.tokens.R -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.Token -import com.getcode.opencode.model.financial.TokenWithBalance -import com.getcode.opencode.model.financial.plus -import com.getcode.theme.CodeTheme -import com.getcode.theme.White05 -import com.getcode.theme.bolded -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton -import com.getcode.ui.theme.CodeScaffold - -@Composable -internal fun TokenBuyReceiptScreen(viewModel: SwapViewModel) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - TokenBuyReceiptScreen(state, viewModel::dispatchEvent) -} - -@Composable -private fun TokenBuyReceiptScreen( - state: SwapViewModel.State, - dispatchEvent: (SwapViewModel.Event) -> Unit, -) { - CodeScaffold( - bottomBar = { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x5), - text = stringResource(R.string.label_buyWarning), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - - CodeButton( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .imePadding() - .padding(bottom = CodeTheme.dimens.grid.x3), - text = stringResource( - if (state.isGet) R.string.action_confirm else R.string.action_buy - ), - buttonState = ButtonState.Filled, - isLoading = state.buyProgress.loading, - isSuccess = state.buyProgress.success, - ) { - dispatchEvent(SwapViewModel.Event.OnBuyConfirmed) - } - } - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x8), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.inset, - alignment = Alignment.CenterVertically - ) - ) { - BuyReceipt( - fundingToken = state.fundingTokenWithBalance?.token, - desiredToken = state.tokenWithBalance?.token, - purchaseAmount = state.confirmedEnteredAmount, - feeAmount = state.feeAmount, - isGet = state.isGet, - ) - } - } -} - -@Composable -private fun BuyReceipt( - fundingToken: Token?, - desiredToken: Token?, - purchaseAmount: Fiat?, - feeAmount: Fiat, - // v2 reframes the buy as a conversion between two currencies the user holds, so the receipt - // reads "You Get / Amount to convert / Conversion fee" β€” the same wording Convert uses. The - // math is identical either way: "You Pay" is always purchase + fee. - isGet: Boolean, - modifier: Modifier = Modifier, -) { - // The funding token and confirmed amount resolve asynchronously after landing here, so - // either can briefly be null on first composition. Render a shimmer in place of each token - // row until its data arrives rather than crashing or hiding the row entirely. - val feeAdjustedPurchaseAmount by remember(purchaseAmount, feeAmount) { - derivedStateOf { - purchaseAmount?.let { - if (!feeAmount.hasDisplayableValue) it else it + feeAmount - } - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .border( - width = CodeTheme.dimens.border, - color = CodeTheme.colors.border, - shape = CodeTheme.shapes.medium - ) - .background(White05, CodeTheme.shapes.medium) - .padding( - horizontal = CodeTheme.dimens.grid.x4, - vertical = CodeTheme.dimens.inset - ) - .then(modifier), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x6), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource( - if (isGet) R.string.subtitle_youGet else R.string.subtitle_youReceive - ), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - - if (desiredToken != null && purchaseAmount != null) { - TokenBalanceRow( - tokenWithBalance = TokenWithBalance( - token = desiredToken, - balance = purchaseAmount - ), - showName = false, - showLogo = true, - showFlag = false, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - styling = rememberTokenBalanceRowStyling( - balanceDisplayStyle = TokenBalanceStyle.Large( - textStyle = CodeTheme.typography.displaySmall.bolded() - ), - ), - contentPadding = PaddingValues(0.dp), - ) - } else { - TokenBalanceRowPlaceholder() - } - } - - if (feeAmount.isPositive && purchaseAmount != null) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - ReceiptLineItem( - modifier = Modifier.fillMaxWidth(), - label = stringResource( - if (isGet) R.string.label_amountToConvert else R.string.label_amountToBuy - ), - amount = purchaseAmount.formatted() - ) - ReceiptLineItem( - modifier = Modifier.fillMaxWidth(), - label = stringResource( - if (isGet) R.string.label_conversionFee else R.string.label_exchangeFee - ), - amount = feeAmount.formatted( - extraPrefix = if (feeAmount.decimalValue < 0.01) "~ " else null, - ) - ) - } - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.subtitle_youPay), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - if (fundingToken != null && feeAdjustedPurchaseAmount != null) { - TokenBalanceRow( - tokenWithBalance = TokenWithBalance( - token = fundingToken, - balance = feeAdjustedPurchaseAmount!! - ), - showName = false, - showLogo = true, - showFlag = false, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - styling = rememberTokenBalanceRowStyling( - balanceDisplayStyle = TokenBalanceStyle.Large( - textStyle = CodeTheme.typography.displaySmall.bolded() - ), - ), - contentPadding = PaddingValues(0.dp), - ) - } else { - TokenBalanceRowPlaceholder() - } - } - } -} - -/** - * Shimmer stand-in for a [TokenBalanceRow] shown with a logo + large balance, used while the - * token or amount is still resolving. Mirrors the row's layout: a circular logo followed by the - * balance text. - */ -@Composable -private fun TokenBalanceRowPlaceholder(modifier: Modifier = Modifier) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier - .size(CodeTheme.dimens.staticGrid.x6) - .shimmer(CircleShape) - ) - Box( - Modifier - .width(CodeTheme.dimens.grid.x20) - .height(CodeTheme.dimens.grid.x6) - .shimmer() - ) - } -} \ No newline at end of file diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt deleted file mode 100644 index c0f9c711cc..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenConvertReceiptScreen.kt +++ /dev/null @@ -1,218 +0,0 @@ -package com.flipcash.app.tokens.internal - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -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.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.ui.ReceiptLineItem -import com.flipcash.app.core.ui.TokenBalanceRow -import com.flipcash.app.core.ui.TokenBalanceStyle -import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling -import com.flipcash.app.tokens.ui.SwapViewModel -import com.flipcash.features.tokens.R -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.TokenWithBalance -import com.getcode.opencode.model.financial.plus -import com.getcode.theme.CodeTheme -import com.getcode.theme.White05 -import com.getcode.theme.bolded -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton -import com.getcode.ui.theme.CodeScaffold - -@Composable -internal fun TokenConvertReceiptScreen(viewModel: SwapViewModel) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - TokenConvertReceiptScreen(state, viewModel::dispatchEvent) -} - -@Composable -private fun TokenConvertReceiptScreen( - state: SwapViewModel.State, - dispatchEvent: (SwapViewModel.Event) -> Unit, -) { - val source = state.tokenWithBalance ?: return - val destination = state.destinationTokenWithBalance ?: return - - // Converting out of Dollars charges the fee on top of the entered amount, so the debit is - // entered + fee and the user receives the full entered amount. Every other direction takes the - // fee out of the sale, so the debit is what was entered and the receipt is net of the fee. - val totalDebited = if (state.isConvertingFromDollars) { - state.enteredAmount + state.feeAmount - } else { - state.enteredAmount - } - - CodeScaffold( - bottomBar = { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x5), - text = stringResource(R.string.label_sellWarning), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - - CodeButton( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .imePadding() - .padding(bottom = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.action_confirmConversion), - buttonState = ButtonState.Filled, - isLoading = state.sellProgress.loading, - isSuccess = state.sellProgress.success, - ) { - dispatchEvent(SwapViewModel.Event.OnConvertConfirmed) - } - } - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x8), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.inset, - alignment = Alignment.CenterVertically - ) - ) { - ConvertReceipt( - source = source, - destination = destination, - totalDebited = totalDebited, - amountToConvert = state.enteredAmount, - feeAmount = state.feeAmount, - amountReceived = state.netTransferAmount, - ) - } - } -} - -@Composable -private fun ConvertReceipt( - source: TokenWithBalance, - destination: TokenWithBalance, - totalDebited: Fiat, - amountToConvert: Fiat, - feeAmount: Fiat, - amountReceived: Fiat, - modifier: Modifier = Modifier, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .border( - width = CodeTheme.dimens.border, - color = CodeTheme.colors.border, - shape = CodeTheme.shapes.medium - ) - .background(White05, CodeTheme.shapes.medium) - .padding( - horizontal = CodeTheme.dimens.grid.x4, - vertical = CodeTheme.dimens.inset - ) - .then(modifier), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x6), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AmountWithCurrency( - title = stringResource(R.string.subtitle_youConvert), - tokenWithBalance = source.copy(balance = totalDebited), - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - ReceiptLineItem( - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.label_amountToConvert), - amount = amountToConvert.formatted(), - ) - - ReceiptLineItem( - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.label_conversionFee), - amount = feeAmount.formatted( - extraPrefix = if (feeAmount.decimalValue < 0.01) "~ " else null, - ), - ) - } - - AmountWithCurrency( - title = stringResource(R.string.subtitle_youReceive), - tokenWithBalance = destination.copy(balance = amountReceived), - ) - } -} - -/** A titled amount paired with the token it's denominated in β€” the receipt's two anchor rows. */ -@Composable -private fun AmountWithCurrency( - title: String, - tokenWithBalance: TokenWithBalance, -) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Text( - text = title, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - - // Token logo, not the currency flag: a receipt names the token being moved, and the - // withdrawal and Get receipts already read that way. Currencies with a shared flag - // (or none at all) are indistinguishable otherwise. - TokenBalanceRow( - tokenWithBalance = tokenWithBalance, - showName = false, - showLogo = true, - showFlag = false, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - styling = rememberTokenBalanceRowStyling( - balanceDisplayStyle = TokenBalanceStyle.Large( - textStyle = CodeTheme.typography.displaySmall.bolded() - ), - ), - contentPadding = PaddingValues(0.dp), - ) - - Text( - text = tokenWithBalance.displayName, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } -} From 6c5dcb3fb89b91888405c9171c7aa82f7fe552bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 08:14:09 -0400 Subject: [PATCH 8/9] fix(nav): predictive back runs the pop animation, not the push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppNavHost defaulted predictivePopTransitionSpec to transitionSpec β€” the *forward* spec β€” so dragging back out of a flow step slid the screen being returned to in from the right instead of the left. Every other host passes the parameter explicitly and so hid the bad default; FlowHost did not, which is why backing out of the Get/Convert receipts ran the wrong way. Default it to popTransitionSpec and thread it through FlowHost. Hosts that don't distinguish push from pop are unaffected: for them the two specs are the same lambda. --- .../kotlin/com/getcode/navigation/AppNavHost.kt | 6 +++++- .../kotlin/com/getcode/navigation/flow/FlowHost.kt | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt index d3fa53b9fa..dc77b04ea9 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/AppNavHost.kt @@ -54,8 +54,12 @@ fun AppNavHost( } }, popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = transitionSpec, + // Predictive back is a pop, so it must default to the pop spec. Defaulting to [transitionSpec] + // ran the *forward* animation backwards-in-time: the screen being returned to slid in from the + // right instead of the left. Hosts that don't distinguish the two are unaffected, since their + // [popTransitionSpec] is [transitionSpec]. predictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = { - transitionSpec() + popTransitionSpec() }, onBack: (() -> Unit)? = null, entryProvider: (key: NavKey) -> NavEntry, diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt index 3ccd04c989..13bb0ed33f 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowHost.kt @@ -110,6 +110,8 @@ fun FlowHost( DefaultFlowTransitionSpec, popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = DefaultFlowPopTransitionSpec, + predictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { popTransitionSpec() }, ) { val clampedResumeAt = resumeAt.coerceIn(0, steps.size) val initialStack = if (clampedResumeAt < steps.size) listOf(steps[clampedResumeAt]) else emptyList() @@ -125,6 +127,7 @@ fun FlowHost( sceneStrategies = sceneStrategies, transitionSpec = transitionSpec, popTransitionSpec = popTransitionSpec, + predictivePopTransitionSpec = predictivePopTransitionSpec, ) } @@ -146,6 +149,8 @@ fun FlowHost( DefaultFlowTransitionSpec, popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = DefaultFlowPopTransitionSpec, + predictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { popTransitionSpec() }, ) { FlowHostImpl( initialStack = initialStack, @@ -159,6 +164,7 @@ fun FlowHost( sceneStrategies = sceneStrategies, transitionSpec = transitionSpec, popTransitionSpec = popTransitionSpec, + predictivePopTransitionSpec = predictivePopTransitionSpec, ) } @@ -177,6 +183,8 @@ private fun FlowHostImpl( DefaultFlowTransitionSpec, popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = DefaultFlowPopTransitionSpec, + predictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { popTransitionSpec() }, ) { // Capture the outer flow entry's VM store owner before any override below. val flowOwner = checkNotNull(LocalViewModelStoreOwner.current) { @@ -337,6 +345,11 @@ private fun FlowHostImpl( sceneStrategies = sceneStrategies, transitionSpec = if (suppressTransition.value) noTransition else transitionSpec, popTransitionSpec = if (suppressTransition.value) noTransition else popTransitionSpec, + predictivePopTransitionSpec = if (suppressTransition.value) { + { noTransition() } + } else { + predictivePopTransitionSpec + }, onBack = { innerNavigator.navigateBack() }, decorators = decorators, entryProvider = entryProvider, From 9348a66ff59a40cd26ed35837fc4cbdc475279ce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 09:22:32 -0400 Subject: [PATCH 9/9] fix(tokens): recompute the net transfer on re-entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private `netTransferAmount` producer deferred to `State.confirmedNetTransferAmount` β€” its own output from a previous trip through the entry screen β€” while `enteredAmount` and `feeAmount` beside it recomputed from the live amount delegate. All three feed the same `OnAmountAccepted` event, so a second pass produced a receipt that mixed the new entry with the old total: enter $1, go back, enter $0.50, and the receipt paired a $0.50 debit and a $0.005 fee with a $0.99 "You Receive". The cache-first read is a leftover from before #829, when a single getter on `State` served both roles and the keypad lived on `State` too; there it was what pinned the number once accepted. The delegate split gave that freeze its own home in `State.netTransferAmount`, but the producer copy kept the prefix. `enteredAmount` and `feeAmount` were rewritten during that split and came out cache-free, which is the asymmetry behind the bug. Affects v1's Sell path the same way, so it is left ungated β€” a wrong number is not a v1 pixel worth preserving. --- .../com/flipcash/app/tokens/ui/SwapViewModel.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 4d3933edc1..63a177f07f 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 @@ -493,9 +493,19 @@ class SwapViewModel @Inject constructor( return enteredAmount.launchpadSellFee(bps) } - /** What the user actually receives: entered minus the fee, unless the fee rides on top. */ + /** + * What the user actually receives: entered minus the fee, unless the fee rides on top. + * + * Always recomputed from the live entry rather than read back from + * [State.confirmedNetTransferAmount], because this getter is what *produces* that snapshot -- + * [enteredAmount] and [feeAmount] feed the same event and recompute the same way. Deferring to + * the previous confirmation made a second trip through the entry screen re-confirm the first + * trip's total: enter $1, go back, enter $0.50, and the receipt paired a $0.50 debit and a + * $0.005 fee with a $0.99 "You Receive". Readers that need the snapshot after the entry screen + * is gone use [State.netTransferAmount], which is where the caching belongs. + */ private val netTransferAmount: Fiat - get() = stateFlow.value.confirmedNetTransferAmount ?: when { + get() = when { stateFlow.value.purpose is SwapPurpose.BalanceIncrease -> enteredAmount stateFlow.value.isConvertingFromDollars -> enteredAmount else -> Fiat(