Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -75,4 +67,101 @@ fun AmountWithKeypad(
isDecimal = decimalPlaces > 0,
)
}
}
}

/**
* 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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -58,6 +66,7 @@ data class TokenBalanceRowStyling(
val flagSize: Dp,
val selectionStyle: TokenSelectionStyle,
val disabledAlpha: Float,
val contentPadding: PaddingValues,
)

@Composable
Expand All @@ -68,14 +77,16 @@ 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,
balanceDisplayStyle = balanceDisplayStyle,
iconSize = iconSize,
flagSize = flagSize,
selectionStyle = selectionStyle,
disabledAlpha = disabledAlpha
disabledAlpha = disabledAlpha,
contentPadding = contentPadding,
)

@Composable
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}


Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading