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..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,67 +1,59 @@ 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, - modifier: Modifier = Modifier, - prefix: String = "", - placeholder: String = "", - currencyFlag: Int? = null, - decimalPlaces: Int = 2, - hint: String = "", - isError: Boolean = false, - isClickable: Boolean = false, onNumberPressed: (Int) -> Unit, - onAmountClicked: () -> Unit = { }, onBackspace: () -> Unit, + modifier: Modifier = Modifier, + decimalPlaces: Int = 2, onDecimal: () -> Unit = { }, + /** Optional row rendered between the amount and the keypad (e.g. a Convert destination picker). */ + accessory: (@Composable () -> Unit)? = null, + amountField: @Composable BoxScope.() -> Unit, ) { - val networkObserver = LocalNetworkObserver.current - val networkState by networkObserver.state.collectAsStateWithLifecycle() - Column( modifier = modifier, ) { Box( modifier = Modifier .fillMaxWidth() - .weight(0.65f) - ) { - AmountArea( + .weight(0.65f), + content = amountField, + ) + + accessory?.let { + Box( 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 - ) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + ) { it() } } CodeKeyPad( @@ -75,4 +67,101 @@ fun AmountWithKeypad( isDecimal = decimalPlaces > 0, ) } -} \ No newline at end of file +} + +/** + * 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-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/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..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,8 +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 @@ -27,6 +30,28 @@ 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, Sheet, WrapContentSheet, HalfSheet + + /** + * 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, WrapContentSheet, HalfSheet + + @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..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 @@ -17,6 +17,19 @@ 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 + + /** + * 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/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..56d2b88bf1 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -567,6 +567,24 @@ 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 + %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 e6d014f6b9..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,8 @@ 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 import com.flipcash.features.tokens.R @@ -27,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 @@ -51,6 +54,10 @@ 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) + // 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) }, @@ -64,7 +71,35 @@ internal fun SwapEntryScreen( } ) - SwapEntryScreenContent(viewModel) + SwapEntryScreenContent( + viewModel = viewModel, + 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 + }, + ) } LaunchedEffect(viewModel) { @@ -82,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() @@ -90,6 +144,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..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 @@ -1,11 +1,21 @@ 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.foundation.layout.navigationBarsPadding +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 @@ -14,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 @@ -26,7 +37,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 @@ -72,6 +86,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(), + ), ) } @@ -97,6 +117,13 @@ private fun swapEntryProvider( BuyReceiptScreen() } annotatedEntry { SellReceiptScreen() } + annotatedEntry { + ConvertDestinationSelectScreen() + } + annotatedEntry { + BuyFundingSelectScreen() + } + annotatedEntry { ConvertReceiptScreen() } annotatedEntry { PhantomConnectConfirmationScreen(depositFirstPurpose = depositFirstPurpose) } @@ -108,6 +135,114 @@ 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 + + // 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 + .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, + presentation = TokenListPresentation.Sheet, + ) + } + + LaunchedEffect(selectionViewModel) { + selectionViewModel.eventFlow + .filterIsInstance() + .filter { it.fromUser } + .map { it.mint } + .onEach { + viewModel.dispatchEvent(SwapViewModel.Event.OnDestinationSelected(it)) + flowNavigator.back() + } + .launchIn(this) + } +} + +/** + * 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/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..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 @@ -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 @@ -14,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 @@ -29,6 +31,7 @@ import kotlinx.coroutines.flow.onEach fun TokenSelectScreen( purpose: TokenPurpose, showTopBar: Boolean = purpose !is TokenPurpose.LaunchFunding, + presentation: TokenListPresentation = TokenListPresentation.Default, ) { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() @@ -43,7 +46,8 @@ fun TokenSelectScreen( CompositionLocalProvider(LocalFlowDismissStyle provides dismissStyle) { Column( - modifier = Modifier.fillMaxSize(), + modifier = if (presentation.wrapHeight) Modifier.fillMaxWidth() + else Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { if (showTopBar) { @@ -58,7 +62,7 @@ fun TokenSelectScreen( ) } - SelectTokenScreen(viewModel) + SelectTokenScreen(viewModel, presentation = presentation) } } @@ -95,6 +99,8 @@ fun TokenSelectScreen( is TokenPurpose.Tip -> Unit 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 0cd4ed73a2..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,8 @@ import com.getcode.navigation.core.LocalCodeNavigator @Composable internal fun SwapEntryScreenContent( viewModel: SwapViewModel, + largeHeader: Boolean = false, + accessory: (@Composable () -> Unit)? = null, ) { val navigator = LocalCodeNavigator.current @@ -16,5 +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/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 2e170fcf60..0000000000 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenBuyReceiptScreen.kt +++ /dev/null @@ -1,263 +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(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, - ) - } - } -} - -@Composable -private fun BuyReceipt( - fundingToken: Token?, - desiredToken: Token?, - purchaseAmount: Fiat?, - feeAmount: Fiat, - 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(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(R.string.label_amountToBuy), - amount = purchaseAmount.formatted() - ) - ReceiptLineItem( - modifier = Modifier.fillMaxWidth(), - label = stringResource(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/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt index 38a3031ff1..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,30 +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, + presentation: TokenListPresentation = TokenListPresentation.Default, ) { val state by tokenViewModel.stateFlow.collectAsStateWithLifecycle() - SelectTokenScreenContent(state, tokenViewModel::dispatchEvent) + SelectTokenScreenContent(state, presentation, tokenViewModel::dispatchEvent) } @Composable private fun SelectTokenScreenContent( state: SelectTokenViewModel.State, + presentation: TokenListPresentation = TokenListPresentation.Default, dispatch: (SelectTokenViewModel.Event) -> Unit, ) { val tokens = remember(state.tokens) { state.tokens } - TokenList( - modifier = Modifier.fillMaxSize(), - tokens = tokens, - selectedToken = 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 @@ -56,15 +72,34 @@ private fun SelectTokenScreenContent( is TokenPurpose.LaunchFunding -> TokenSelectionStyle.Chevron 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, + ) + } + + 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 is TokenPurpose.LaunchFunding -> false is TokenPurpose.Tip -> false + is TokenPurpose.ConvertDestination -> false + is TokenPurpose.BuyFunding -> false else -> true }, enableGreaterThanAmount = atLeast@{ _, amount -> @@ -82,7 +117,12 @@ private fun SelectTokenScreenContent( emptyState = { Box( modifier = Modifier - .fillParentMaxSize() + .then( + if (presentation.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/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 new file mode 100644 index 0000000000..f5a0b7edf1 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectorRow.kt @@ -0,0 +1,118 @@ +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 +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 + +/** + * "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 +private fun TokenSelectorRow( + label: String, + selected: TokenWithBalance?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + selected ?: return + + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + 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, + ) { + // 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), + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } +} + +/** "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/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..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 @@ -23,6 +25,10 @@ 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, ) { val delegateState by controller.state.collectAsStateWithLifecycle() val config by controller.config.collectAsStateWithLifecycle() @@ -69,26 +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, + 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/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/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index 7c02f5d148..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 @@ -142,6 +142,8 @@ class SelectTokenViewModel @Inject constructor( } is TokenPurpose.Swap, + is TokenPurpose.ConvertDestination, + is TokenPurpose.BuyFunding, is TokenPurpose.LaunchFunding, is TokenPurpose.Tip, TokenPurpose.Deposit, @@ -198,6 +200,17 @@ class SelectTokenViewModel @Inject constructor( false } } + + // 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 && 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 e7fa945866..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 @@ -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(), @@ -125,6 +129,10 @@ 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 + // 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 -> { @@ -148,39 +156,76 @@ 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 (statesBalanceAsCeiling) 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 { + 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) + } }, belowMinHint = { resources.getString(R.string.subtitle_buyHintBelowMinimum, it) }, ) }.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 -> inputs.tokenBalance null -> null } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) @@ -222,6 +267,9 @@ 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 newUiEnabled: Boolean = false, ) { val sellFee: Double? get() { @@ -233,6 +281,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 @@ -255,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 { @@ -317,6 +401,29 @@ 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 + 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,38 +470,80 @@ 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. + * + * 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 (stateFlow.value.purpose) { - is SwapPurpose.BalanceIncrease -> enteredAmount + get() = 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 * 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) @@ -415,8 +564,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( @@ -443,13 +600,22 @@ 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 } } is SwapPurpose.Sell -> stateFlow.value.tokenBalance + is SwapPurpose.Convert -> stateFlow.value.tokenBalance null -> Fiat.Zero } } @@ -522,6 +688,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 -> @@ -570,6 +779,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 +822,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) @@ -669,14 +918,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 -> { @@ -707,6 +964,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 +1139,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 } @@ -882,7 +1257,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, @@ -935,11 +1317,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, @@ -979,9 +1371,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 { @@ -1103,6 +1507,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 +1901,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,10 +1988,37 @@ 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) } + 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( 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 } 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..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 @@ -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 @@ -20,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 @@ -35,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?, @@ -51,6 +93,7 @@ fun TokenList( showSelections: Boolean = false, includeReserves: Boolean = true, pinFooter: 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, @@ -60,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 } @@ -76,11 +125,17 @@ fun TokenList( Box(modifier = modifier) { LazyColumn( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .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 @@ -112,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/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, ) } 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/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/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/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..2e7de506b7 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,16 @@ import androidx.navigation3.runtime.NavKey interface Sheet: NavKey interface WrapContentSheet: NavKey + +/** + * 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 interface NonDraggableRoute: NavKey interface SolitarySheet: NavKey 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, 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..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,6 +64,18 @@ private val Expanded = SheetDetent("expanded") { containerHeight, _ -> containerHeight * 0.925f } +/** + * 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 +} + /** An [OverlayScene] that renders an [entry] within an [UnstyledBottomSheet]. */ internal class ModalBottomSheetScene constructor( override val key: T, @@ -119,18 +133,50 @@ 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 + + // 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 = listOf(SheetDetent.Hidden, Expanded), + detents = if (isHalfSheet) { + listOf(SheetDetent.Hidden, Half) + } else { + listOf(SheetDetent.Hidden, Expanded) + }, confirmDetentChange = { detent -> detent != SheetDetent.Hidden || allowDismiss }, ) + // 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) { - sheetState.animateTo(Expanded) + sheetState.animateTo(restingDetent) } val composeScope = rememberCoroutineScope() @@ -172,6 +218,7 @@ internal class ModalBottomSheetScene constructor( LocalSheetGesturesState provides { enabled -> allowDismiss = enabled && !navigator.sheetDragDisabled }, + LocalSheetExpansionState provides setContentOverflows, LocalScrimController provides scrim, ) { BackHandler(enabled = effectiveProperties.dismissOnBackPress) { @@ -197,8 +244,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 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 0c530e6d78..a7845e5495 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,8 @@ internal val CodeDefaultColorScheme = ColorScheme( successText = Success, textMain = TextMain, textSecondary = TextSecondary, - textTertiary = White50, + 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, 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,