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,