Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-11 17:10:30 +05:00
parent 4383291669
commit fec7986d80
16 changed files with 1322 additions and 587 deletions

View file

@ -35,9 +35,9 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
@ -121,8 +121,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
import javax.inject.Inject
@ -208,10 +206,6 @@ internal class SwapModel @Inject constructor(
appRouter = appRouter,
)
private val inputNumberFormatter = InputNumberFormatter(
NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat ?: error("NumberFormat is not DecimalFormat"),
)
private val amountDebouncer = Debouncer()
private val transferModeDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
@ -231,6 +225,9 @@ internal class SwapModel @Inject constructor(
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
/** Whether the user is currently entering a fiat amount in the "from" card (vs crypto). */
private val isFiatInput = mutableStateOf(false)
private var userCountry: UserCountry? = null
private val isUserResolvableError: (SwapState) -> Boolean = { swapState ->
@ -515,6 +512,7 @@ internal class SwapModel @Inject constructor(
dataState = if (isFromDirection) {
// Reset amount if from token is changed
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
lastReducedBalanceBy.value = BigDecimal.ZERO
SwapProcessDataState(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -590,6 +588,7 @@ internal class SwapModel @Inject constructor(
isAmountChangedByUser = true
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
lastReducedBalanceBy.value = BigDecimal.ZERO
dataState = SwapProcessDataState(
@ -878,8 +877,28 @@ internal class SwapModel @Inject constructor(
isSilent: Boolean = false,
updateFeeBlock: Boolean = true,
) {
dataState = dataState.copy(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
singleTaskScheduler.cancelTask()
if (amount.isBlank()) return
if (amount.isBlank()) {
uiState = stateBuilder.createQuotesEmptyAmountState(
uiStateHolder = uiState,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
emptyAmountState = SwapState.EmptyAmountState(
zeroAmountEquivalent = stringReference(
BigDecimal.ZERO.format {
fiat(
fiatCurrencyCode = selectedAppCurrencyFlow.value.code,
fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol,
)
},
),
),
)
return
}
if (!isSilent) {
uiState = stateBuilder.createQuotesLoadingState(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -966,10 +985,6 @@ internal class SwapModel @Inject constructor(
task = {
uiState = stateBuilder.createSilentLoadState(uiState)
runCatching(dispatchers.default) {
dataState = dataState.copy(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
swapInteractor.findBestQuote(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -1612,29 +1627,78 @@ internal class SwapModel @Inject constructor(
.saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder)
}
private fun onAmountChanged(
value: String,
/**
* Handles raw input from the amount text field. [value] is expressed in the currently active
* input currency (crypto or fiat). The crypto equivalent is always derived and used downstream.
*/
private fun onAmountChanged(value: String) {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
val cryptoDecimals = fromSwapCurrencyStatus.currency.decimals
val cryptoValue = if (isFiatInput.value && fiatRate != null) {
value.toCryptoFromFiat(fiatRate, cryptoDecimals)
} else {
value
}
updateAmount(
cryptoValue = cryptoValue,
fieldValue = value,
forceQuotesUpdate = false,
reduceBalanceBy = BigDecimal.ZERO,
isPastedAmount = false,
)
}
/**
* Applies a crypto amount produced programmatically (max / percent / reduce). The visible field
* value is converted to the active input currency for display, while quotes still use crypto.
*/
private fun applyCryptoAmount(
cryptoValue: String,
forceQuotesUpdate: Boolean = false,
reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
) {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val fiatRate = fromSwapCurrencyStatus?.status?.value?.fiatRate
val fieldValue = if (fromSwapCurrencyStatus != null && isFiatInput.value && fiatRate != null) {
cryptoValue.toFiatFromCrypto(fiatRate)
} else {
cryptoValue
}
updateAmount(
cryptoValue = cryptoValue,
fieldValue = fieldValue,
forceQuotesUpdate = forceQuotesUpdate,
reduceBalanceBy = reduceBalanceBy,
isPastedAmount = true,
)
}
private fun updateAmount(
cryptoValue: String,
fieldValue: String,
forceQuotesUpdate: Boolean,
reduceBalanceBy: BigDecimal,
isPastedAmount: Boolean,
) {
modelScope.launch {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
if (fromSwapCurrencyStatus != null) {
val decimals = fromSwapCurrencyStatus.currency.decimals
val cutValue = cutAmountWithDecimals(decimals, value)
val minTxAmount = getMinimumTransactionAmountSyncUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
).getOrNull()
lastAmount.value = cutValue
lastAmount.value = cryptoValue
lastReducedBalanceBy.value = reduceBalanceBy
uiState = stateBuilder.updateSwapAmount(
uiState = uiState,
amountFormatted = inputNumberFormatter.formatWithThousands(cutValue, decimals),
amountRaw = lastAmount.value,
fieldValue = fieldValue,
isFiatValue = isFiatInput.value,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
minTxAmount = minTxAmount,
isPastedAmount = isPastedAmount,
)
if (toSwapCurrencyStatus != null) {
@ -1663,10 +1727,43 @@ internal class SwapModel @Inject constructor(
}
}
/**
* Switches the "from" amount field between crypto and fiat entry. The stored crypto amount stays
* authoritative; only the displayed value and equivalent are recomputed (no quote reload).
*/
private fun onCurrencyChange(isFiat: Boolean) {
if (isFiat == isFiatInput.value) return
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
if (isFiat && fiatRate == null) return
isFiatInput.value = isFiat
val cryptoValue = lastAmount.value
val fieldValue = when {
cryptoValue.isEmpty() -> ""
isFiat && fiatRate != null -> cryptoValue.toFiatFromCrypto(fiatRate)
else -> cryptoValue
}
modelScope.launch {
val minTxAmount = getMinimumTransactionAmountSyncUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
).getOrNull()
uiState = stateBuilder.updateSwapAmount(
uiState = uiState,
amountRaw = cryptoValue,
fieldValue = fieldValue,
isFiatValue = isFiat,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
minTxAmount = minTxAmount,
isPastedAmount = false,
)
}
}
private fun onMaxAmountClicked() {
dataState.fromSwapCurrencyStatus?.let { fromCurrency ->
val balance = swapInteractor.getTokenBalance(fromCurrency.status)
onAmountChanged(balance.formatToUIRepresentation())
applyCryptoAmount(balance.formatToUIRepresentation())
}
}
@ -1682,7 +1779,7 @@ internal class SwapModel @Inject constructor(
decimals = fromCurrency.status.currency.decimals,
percent = percent,
)
onAmountChanged(
applyCryptoAmount(
SwapAmount(
value = newValue,
decimals = fromCurrency.status.currency.decimals,
@ -1691,8 +1788,8 @@ internal class SwapModel @Inject constructor(
}
private fun onReduceAmountClicked(newAmount: SwapAmount, reduceBalanceBy: BigDecimal = BigDecimal.ZERO) {
onAmountChanged(
value = newAmount.formatToUIRepresentation(),
applyCryptoAmount(
cryptoValue = newAmount.formatToUIRepresentation(),
forceQuotesUpdate = true,
reduceBalanceBy = reduceBalanceBy,
)
@ -1704,8 +1801,16 @@ internal class SwapModel @Inject constructor(
}
}
private fun cutAmountWithDecimals(maxDecimals: Int, amount: String): String {
return inputNumberFormatter.getValidatedNumberWithFixedDecimals(amount, maxDecimals)
private fun String.toCryptoFromFiat(fiatRate: BigDecimal, cryptoDecimals: Int): String {
return parseToBigDecimal(cryptoDecimals)
.divide(fiatRate, cryptoDecimals, RoundingMode.DOWN)
.parseBigDecimal(cryptoDecimals)
}
private fun String.toFiatFromCrypto(fiatRate: BigDecimal): String {
return parseToBigDecimal(FIAT_DECIMALS)
.multiply(fiatRate)
.parseBigDecimal(FIAT_DECIMALS)
}
private fun showAlert(message: TextReference = resourceReference(R.string.common_unknown_error)) {
@ -1805,6 +1910,7 @@ internal class SwapModel @Inject constructor(
private fun createUiActions(): UiActions {
return UiActions(
onAmountChanged = { onAmountChanged(it) },
onCurrencyChange = { onCurrencyChange(it) },
onSwapClick = {
onSwapClick()
val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol
@ -2106,6 +2212,7 @@ internal class SwapModel @Inject constructor(
lastReducedBalanceBy.value = BigDecimal.ZERO
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
uiState = stateBuilder.createSwapNotSupportedState(
uiStateHolder = uiState,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -2697,6 +2804,7 @@ internal class SwapModel @Inject constructor(
private companion object {
const val INITIAL_AMOUNT = ""
const val FIAT_DECIMALS = 2
const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L

View file

@ -2,13 +2,14 @@ package com.tangem.feature.swap.models
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.predefined.PredefinedPercentButtonUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.ProviderState
@ -58,15 +59,16 @@ sealed class SwapCardState {
val currencyIconState: CurrencyIconState,
val tokenSymbol: TextReference,
val amountEquivalent: TextReference?,
val amountTextFieldValue: TextFieldValue?,
val balance: String,
val isBalanceHidden: Boolean,
val appCurrency: AppCurrency,
val amountField: AmountFieldModel? = null,
) : SwapCardState()
data class Empty(
override val type: TransactionCardType,
val amountEquivalent: TextReference,
val amountTextFieldValue: TextFieldValue?,
val amountField: AmountFieldModel? = null,
) : SwapCardState()
data class Loading(
@ -99,11 +101,12 @@ sealed interface TransactionCardType {
val inputError: InputError
data class Inputtable(
val onAmountChanged: ((String) -> Unit),
val onFocusChanged: ((Boolean) -> Unit),
override val inputError: InputError,
override val accountTitleUM: AccountTitleUM,
val isEnabled: Boolean,
/** Switches the input field between crypto and fiat entry. Argument is the new `isFiatValue`. */
val onCurrencyChange: (Boolean) -> Unit = {},
) : TransactionCardType
data class ReadOnly(

View file

@ -10,6 +10,7 @@ import java.math.BigDecimal
internal data class UiActions(
val onAmountChanged: (String) -> Unit,
val onCurrencyChange: (Boolean) -> Unit,
val onAmountSelected: (Boolean) -> Unit,
val onSwapClick: () -> Unit,
val onTransferClick: () -> Unit,

View file

@ -1,108 +0,0 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.ParagraphIntrinsics
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.res.TangemTheme
@Suppress("MagicNumber", "LongMethod")
@Composable
internal fun AutoSizeTextField(
textFieldValue: TextFieldValue,
focusRequester: FocusRequester,
isEnabled: Boolean,
onAmountChange: (String) -> Unit,
onFocusChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
LaunchedEffect(isEnabled) {
if (!isEnabled) {
focusManager.clearFocus()
}
}
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
var shrunkFontSize = TangemTheme.typography.h2.fontSize
val calculateIntrinsics = @Composable {
ParagraphIntrinsics(
text = textFieldValue.text,
style = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
fontSize = shrunkFontSize,
),
density = LocalDensity.current,
fontFamilyResolver = createFontFamilyResolver(LocalContext.current),
)
}
var intrinsics = calculateIntrinsics()
with(LocalDensity.current) {
while (intrinsics.maxIntrinsicWidth > maxWidth.toPx()) {
shrunkFontSize *= 0.9f
intrinsics = calculateIntrinsics()
}
}
val customTextSelectionColors = TextSelectionColors(
handleColor = Color.Transparent,
backgroundColor = TangemTheme.colors.text.secondary.copy(alpha = 0.4f),
)
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
BasicTextField(
value = textFieldValue,
onValueChange = {
onAmountChange.invoke(it.text)
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { onFocusChange(it.hasFocus) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal,
),
enabled = isEnabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
decorationBox = { innerTextField ->
if (textFieldValue.text.isBlank()) {
Text(
text = "0",
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
)
}
innerTextField()
},
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
fontSize = shrunkFontSize,
),
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
)
}
}
}

View file

@ -1,13 +1,17 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
@ -29,6 +33,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
@ -70,6 +75,10 @@ internal class StateBuilder(
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val amountScreenClickIntents by lazy(LazyThreadSafetyMode.NONE) {
SwapAmountScreenClickIntents(actions)
}
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
SwapNotificationsFactory(
actions = actions,
@ -196,7 +205,7 @@ internal class StateBuilder(
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.copy(
type = TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
@ -274,7 +283,7 @@ internal class StateBuilder(
): SwapCardState {
val cardType = if (isFromCard) {
TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true),
@ -295,17 +304,17 @@ internal class StateBuilder(
)
} else if (shouldResetAmount) {
copy(
amountTextFieldValue = if (isFromCard) {
null
} else {
TextFieldValue("0".appendApproximateSign())
},
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
type = cardType,
amountField = if (isFromCard) {
emptyAmountField(swapCurrencyStatus)
} else {
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
},
)
} else {
copy(
@ -314,10 +323,66 @@ internal class StateBuilder(
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
type = cardType,
amountField = if (isFromCard) {
if (amountField == null) {
emptyAmountField(swapCurrencyStatus)
} else {
buildAmountField(
amountRaw = amountField.cryptoAmount.value?.toPlainString().orEmpty(),
fieldValue = amountField.value,
isFiatValue = amountField.isFiatValue,
fromSwapCurrencyStatus = swapCurrencyStatus,
isPastedAmount = false,
)
}
} else {
amountField
},
)
}
}
private fun emptyAmountField(fromSwapCurrencyStatus: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
amountRaw = "",
fieldValue = "",
isFiatValue = false,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
isPastedAmount = false,
)
/**
* Builds the read-only receive card [AmountFieldModel] from a display [value]. Reuses the existing
* converter path; the read-only UI only reads [AmountFieldModel.value].
*/
private fun displayAmountField(value: String, status: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
amountRaw = "",
fieldValue = value,
isFiatValue = false,
fromSwapCurrencyStatus = status,
isPastedAmount = false,
)
/**
* Builds a status-free placeholder [AmountFieldModel] for the static [SwapCardState.Empty] card,
* which has no [SwapCurrencyStatus]. The Empty card UI only reads [AmountFieldModel.value].
*/
private fun placeholderAmountField(value: String): AmountFieldModel = AmountFieldModel(
value = value,
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
fiatAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
isFiatValue = false,
fiatValue = "",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
)
private fun createCardState(
swapCurrencyStatus: SwapCurrencyStatus?,
emptyAmountState: SwapState.EmptyAmountState,
@ -330,7 +395,7 @@ internal class StateBuilder(
SwapCardState.SwapCardData(
type = if (isFromCard) {
TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true),
@ -342,16 +407,17 @@ internal class StateBuilder(
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, false),
)
},
amountTextFieldValue = if (isFromCard) {
null
} else {
TextFieldValue("0".appendApproximateSign())
},
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
amountField = if (isFromCard) {
emptyAmountField(swapCurrencyStatus)
} else {
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
},
appCurrency = appCurrencyProvider(),
)
}
}
@ -366,7 +432,7 @@ internal class StateBuilder(
),
),
),
amountTextFieldValue = TextFieldValue(text = if (isFromCard) "0" else "0".appendApproximateSign()),
amountField = placeholderAmountField(value = if (isFromCard) "0" else "0".appendApproximateSign()),
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
)
@ -381,27 +447,25 @@ internal class StateBuilder(
type = TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
),
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountField = displayAmountField(value = "0", status = fromSwapCurrencyStatus),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
),
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
swapButton = SwapButton(
@ -429,7 +493,7 @@ internal class StateBuilder(
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.copy(
type = TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
@ -440,7 +504,7 @@ internal class StateBuilder(
type = TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
),
amountTextFieldValue = null,
amountField = null,
amountEquivalent = null,
),
notifications = persistentListOf(),
@ -512,12 +576,13 @@ internal class StateBuilder(
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = sendInput,
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = uiStateHolder.sendCardData.amountEquivalent,
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
amountField = uiStateHolder.sendCardData.amountField,
appCurrency = appCurrencyProvider(),
),
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
@ -525,10 +590,11 @@ internal class StateBuilder(
onWarningClick = actions.onReceiveCardWarningClick,
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
),
amountTextFieldValue = TextFieldValue(
quoteModel.toTokenInfo.tokenAmount
amountField = displayAmountField(
value = quoteModel.toTokenInfo.tokenAmount
.formatToUIRepresentation()
.appendApproximateSign(),
status = toSwapCurrencyStatus,
),
amountEquivalent = if (priceImpact.type.ordinal > PriceImpact.Type.LOW.ordinal) {
combinedReference(
@ -554,6 +620,7 @@ internal class StateBuilder(
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
isInsufficientFunds = isInsufficientFundsCondition(quoteModel),
notifications = notifications,
@ -733,19 +800,18 @@ internal class StateBuilder(
val receiveCardData = toSwapCurrencyStatus?.status?.let { toToken ->
SwapCardState.SwapCardData(
type = type,
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toToken.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
)
} ?: SwapCardState.Empty(
type = type,
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
amountTextFieldValue = null,
amountField = null,
)
return uiStateHolder.copy(
receiveCardData = receiveCardData,
@ -813,11 +879,10 @@ internal class StateBuilder(
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.copy(
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
),
receiveCardData = uiStateHolder.receiveCardData.copy(
amountTextFieldValue = TextFieldValue("0"),
amountField = uiStateHolder.receiveCardData.amountField?.copy(value = "0"),
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
),
notifications = persistentListOf(),
@ -854,19 +919,58 @@ internal class StateBuilder(
)
}
/**
* Builds the shared [AmountFieldModel] that carries the "from" card input state.
*
* @param amountRaw authoritative crypto amount (ungrouped) drives [AmountFieldModel.cryptoAmount].
* @param fieldValue value currently shown in the input field, expressed in the active currency.
* @param isFiatValue whether the active input currency is fiat.
*/
private fun buildAmountField(
amountRaw: String,
fieldValue: String,
isFiatValue: Boolean,
isPastedAmount: Boolean,
fromSwapCurrencyStatus: SwapCurrencyStatus,
): AmountFieldModel {
val appCurrency = appCurrencyProvider()
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
val cryptoDecimal = amountRaw.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val fiatValue = fiatRate?.multiply(cryptoDecimal).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
return AmountFieldConverter(
clickIntents = amountScreenClickIntents,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
appCurrency = appCurrency,
).convert(value = amountRaw).copy(
value = fieldValue,
isFiatValue = isFiatValue,
fiatValue = fiatValue,
isValuePasted = isPastedAmount,
)
}
@Suppress("LongParameterList")
fun updateSwapAmount(
uiState: SwapStateHolder,
amountFormatted: String,
amountRaw: String,
fieldValue: String,
isFiatValue: Boolean,
fromSwapCurrencyStatus: SwapCurrencyStatus,
minTxAmount: BigDecimal?,
isPastedAmount: Boolean,
): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
val amountToSend = amountRaw.parseBigDecimalOrNull()
val currency = fromSwapCurrencyStatus.currency
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
val isFiatUnavailable = fiatRate == null
val isFiatEffective = isFiatValue && !isFiatUnavailable
val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) {
val minAmountFormatted = minTxAmount.format {
crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true)
crypto(cryptoCurrency = currency, ignoreSymbolPosition = true)
}
(uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy(
inputError = TransactionCardType.InputError.WrongAmount,
@ -880,17 +984,22 @@ internal class StateBuilder(
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
) ?: uiState.sendCardData.type
}
// The secondary line shows the opposite currency: crypto when entering fiat, fiat otherwise.
val amountEquivalent = if (isFiatEffective) {
stringReference(amountToSend.orZero().format { crypto(currency) })
} else {
getFormattedFiatAmount(fiatRate?.let { amountToSend?.multiply(it).orZero() })
}
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
amountTextFieldValue = TextFieldValue(
text = amountFormatted,
selection = TextRange(amountFormatted.length),
),
amountEquivalent = getFormattedFiatAmount(
fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate ->
amountToSend?.multiply(fiatRate).orZero()
},
amountField = buildAmountField(
amountRaw = amountRaw,
fieldValue = fieldValue,
isFiatValue = isFiatEffective,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
isPastedAmount = isPastedAmount,
),
amountEquivalent = amountEquivalent,
type = sendInput,
),
)

View file

@ -0,0 +1,27 @@
package com.tangem.feature.swap.ui
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.feature.swap.models.UiActions
/**
* Adapter that exposes legacy swap [UiActions] through the shared [AmountScreenClickIntents] contract
* so the from-card amount field can be constructed by the common `AmountFieldConverter`.
*
* Only the callbacks that the swap amount field actually wires are mapped to real actions; the rest
* are no-ops, because swap-v1 overrides the corresponding [com.tangem.common.ui.amountScreen.models.AmountFieldModel]
* fields (keyboardActions / onValuePastedTriggerDismiss) after conversion to preserve its existing behaviour.
*/
internal class SwapAmountScreenClickIntents(
private val actions: UiActions,
) : AmountScreenClickIntents {
override fun onAmountValueChange(value: String) = actions.onAmountChanged(value)
override fun onAmountPasteTriggerDismiss() = Unit
override fun onMaxValueClick() = actions.onMaxAmountSelected()
override fun onCurrencyChangeClick(isFiat: Boolean) = actions.onCurrencyChange(isFiat)
override fun onAmountNext() = Unit
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -14,13 +15,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -35,9 +39,13 @@ import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_16
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.SwapCardState
@ -110,9 +118,7 @@ private fun TransactionCardData(
)
Content(
type = cardState.type,
amountEquivalent = cardState.amountEquivalent,
textFieldValue = cardState.amountTextFieldValue,
cardData = cardState,
priceImpact = priceImpact,
)
}
@ -177,7 +183,7 @@ private fun TransactionCardEmpty(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
text = cardState.amountField?.value.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -323,12 +329,9 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
@Suppress("LongMethod")
@Composable
private fun Content(
type: TransactionCardType,
amountEquivalent: TextReference?,
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
) {
private fun Content(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
val type = cardData.type
val amountEquivalent = cardData.amountEquivalent
Row(
modifier = Modifier
.padding(
@ -349,9 +352,10 @@ private fun Content(
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
when (type) {
is TransactionCardType.ReadOnly -> {
if (textFieldValue != null) {
val value = cardData.amountField?.value
if (value != null) {
Text(
text = textFieldValue.text,
text = value,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -371,77 +375,25 @@ private fun Content(
}
}
is TransactionCardType.Inputtable -> {
val focusRequester = remember { FocusRequester() }
AutoSizeTextField(
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
focusRequester = focusRequester,
textFieldValue = textFieldValue ?: TextFieldValue(),
isEnabled = type.isEnabled,
onAmountChange = { type.onAmountChanged(it) },
onFocusChange = type.onFocusChanged,
)
LaunchedEffect(type.isEnabled) {
if (type.isEnabled) {
focusRequester.requestFocus()
} else {
focusRequester.freeFocus()
}
}
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
}
}
SpacerH4()
if (amountEquivalent != null) {
if (type is TransactionCardType.ReadOnly) {
Row(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
}
if (type.shouldShowWarning) {
SpacerW4()
IconButton(
onClick = {
type.onWarningClick?.invoke()
},
modifier = Modifier.size(size = TangemTheme.dimens.size20),
) {
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
modifier = Modifier
.align(Alignment.CenterVertically)
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
)
}
}
}
} else {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
when (type) {
is TransactionCardType.ReadOnly -> ReceiveAmountEquivalent(
amountEquivalent = amountEquivalent,
type = type,
priceImpact = priceImpact,
)
is TransactionCardType.Inputtable -> SwapAmountEquivalent(
amountEquivalent = amountEquivalent,
isFiatValue = cardData.amountField?.isFiatValue == true,
isFiatUnavailable = cardData.amountField?.isFiatUnavailable == true,
onCurrencyChange = type.onCurrencyChange,
)
}
} else {
RectangleShimmer(
@ -457,6 +409,147 @@ private fun Content(
}
}
@Composable
internal fun AmountInputField(
cardData: SwapCardState.SwapCardData,
type: TransactionCardType.Inputtable,
modifier: Modifier = Modifier,
) {
val amountField = cardData.amountField ?: return
val focusRequester = remember { FocusRequester() }
val activeAmount = if (amountField.isFiatValue) {
amountField.fiatAmount
} else {
amountField.cryptoAmount
}
AmountTextField(
value = amountField.value,
decimals = activeAmount.decimals,
onValueChange = amountField.onValueChange,
textStyle = TangemTheme.typography.h2.copy(color = TangemTheme.colors.text.primary1),
isEnabled = type.isEnabled,
isAutoResize = true,
visualTransformation = AmountVisualTransformation(
currencyCode = cardData.appCurrency.code.takeIf { amountField.isFiatValue },
symbol = activeAmount.currencySymbol.takeIf { amountField.isFiatValue },
decimals = activeAmount.decimals,
symbolColor = TangemTheme.colors.text.disabled,
),
isValuePasted = amountField.isValuePasted,
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
backgroundColor = TangemTheme.colors.background.primary,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
modifier = modifier
.focusRequester(focusRequester)
.onFocusChanged { type.onFocusChanged(it.hasFocus) }
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
LaunchedEffect(type.isEnabled) {
if (type.isEnabled) {
focusRequester.requestFocus()
} else {
focusRequester.freeFocus()
}
}
}
@Composable
private fun ReceiveAmountEquivalent(
amountEquivalent: TextReference,
type: TransactionCardType.ReadOnly,
priceImpact: PriceImpact,
) {
Row(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
}
if (type.shouldShowWarning) {
SpacerW4()
IconButton(
onClick = { type.onWarningClick?.invoke() },
modifier = Modifier.size(size = TangemTheme.dimens.size20),
) {
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
modifier = Modifier
.align(Alignment.CenterVertically)
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
)
}
}
}
}
private const val CURRENCY_TOGGLE_ROTATED_DEGREE = 180f
private const val CURRENCY_TOGGLE_INITIAL_DEGREE = 0f
@Composable
private fun SwapAmountEquivalent(
amountEquivalent: TextReference,
isFiatValue: Boolean,
isFiatUnavailable: Boolean,
onCurrencyChange: (Boolean) -> Unit,
) {
val rowModifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.then(
if (isFiatUnavailable) {
Modifier
} else {
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onCurrencyChange(!isFiatValue) },
)
},
)
Row(
modifier = rowModifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
if (!isFiatUnavailable) {
val iconRotation by animateFloatAsState(
targetValue = if (isFiatValue) CURRENCY_TOGGLE_ROTATED_DEGREE else CURRENCY_TOGGLE_INITIAL_DEGREE,
label = "Currency toggle icon rotation",
)
Icon(
imageVector = Icons.ic_arrow_swap_horizontal_16,
contentDescription = null,
tint = TangemTheme.colors3.icon.tertiary,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.graphicsLayer { rotationZ = iconRotation },
)
}
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
}
}
@Suppress("MagicNumber")
@Composable
fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) {

View file

@ -13,14 +13,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -104,8 +101,7 @@ private fun SimpleTransactionCardData(
)
SimpleContent(
type = cardState.type,
textFieldValue = cardState.amountTextFieldValue,
cardData = cardState,
priceImpact = priceImpact,
)
}
@ -170,7 +166,7 @@ private fun SimpleTransactionCardEmpty(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
text = cardState.amountField?.value.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -308,7 +304,8 @@ private fun SimpleHeader(type: TransactionCardType, balance: String, modifier: M
@Suppress("LongMethod")
@Composable
private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, textFieldValue: TextFieldValue?) {
private fun SimpleContent(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
val type = cardData.type
Row(
modifier = Modifier
.padding(
@ -326,9 +323,10 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
when (type) {
is TransactionCardType.ReadOnly -> {
if (textFieldValue != null) {
val value = cardData.amountField?.value
if (value != null) {
Text(
text = textFieldValue.text,
text = value,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -348,16 +346,7 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
}
}
is TransactionCardType.Inputtable -> {
val focusRequester = remember { FocusRequester() }
AutoSizeTextField(
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
focusRequester = focusRequester,
textFieldValue = textFieldValue ?: TextFieldValue(),
isEnabled = type.isEnabled,
onAmountChange = { type.onAmountChanged(it) },
onFocusChange = type.onFocusChanged,
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
}
}
SpacerH4()

View file

@ -1,22 +1,30 @@
package com.tangem.feature.swap.ui.preview
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.presentation.R
import java.math.BigDecimal
internal object SwapTransactionCardPreview {
val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Account(
@ -26,12 +34,33 @@ internal object SwapTransactionCardPreview {
),
isEnabled = true,
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "123123123.123123",
isBalanceHidden = false,
appCurrency = AppCurrency.Default,
amountField = AmountFieldModel(
value = "100",
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
fiatAmount = Amount(
currencySymbol = "$",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType("USD"),
),
isFiatValue = false,
fiatValue = "$100.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
),
)
val receiveCard = SwapCardState.SwapCardData(
@ -42,12 +71,33 @@ internal object SwapTransactionCardPreview {
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "33333",
isBalanceHidden = false,
appCurrency = AppCurrency.Default,
amountField = AmountFieldModel(
value = "100",
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
fiatAmount = Amount(
currencySymbol = "$",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType("USD"),
),
isFiatValue = false,
fiatValue = "$100.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
),
)
val emptyReadOnlyCard = SwapCardState.Empty(
@ -55,24 +105,22 @@ internal object SwapTransactionCardPreview {
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
amountField = null,
)
val emptyInputtableCard = SwapCardState.Empty(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)),
isEnabled = false,
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
amountField = null,
)
val loadingCard = SwapCardState.Loading(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),

View file

@ -1,11 +1,16 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
@ -26,12 +31,14 @@ import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
import javax.inject.Inject
@ -54,7 +61,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
val fromTokenSwapInfo = transferState.fromTokenInfo
val toTokenSwapInfo = transferState.toTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
val amountTextFieldValue = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.amountTextFieldValue
val prevSendCard = uiStateHolder.sendCardData as? SwapCardState.SwapCardData
val prevAmountField = prevSendCard?.amountField
val displayValue = prevAmountField?.value.orEmpty()
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
@ -66,17 +75,18 @@ internal class SwapTransferStateBuilder @Inject constructor(
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
displayValue = displayValue,
tokenSwapInfo = fromTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
isBalanceHidden = transferState.isBalanceHidden,
isInsufficientBalance = isInsufficientBalance,
prevAmountField = prevAmountField,
),
receiveCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
displayValue = displayValue,
tokenSwapInfo = toTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
@ -99,15 +109,17 @@ internal class SwapTransferStateBuilder @Inject constructor(
@Suppress("LongParameterList")
private fun createSendSwapCardState(
actions: UiActions,
amountTextFieldValue: TextFieldValue?,
displayValue: String,
tokenSwapInfo: TokenSwapInfo,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
isFromCard: Boolean,
isBalanceHidden: Boolean,
isInsufficientBalance: Boolean,
prevAmountField: AmountFieldModel? = null,
): SwapCardState {
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
val currency = swapCurrencyStatus.currency
return SwapCardState.SwapCardData(
type = createSendTransactionCardType(
@ -120,14 +132,89 @@ internal class SwapTransferStateBuilder @Inject constructor(
currencyIconState = iconConverter.convert(
value = swapCurrencyStatus.status,
),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
tokenSymbol = stringReference(currency.symbol),
amountEquivalent = getFormattedFiatAmount(
appCurrency = appCurrency,
amount = tokenSwapInfo.amountFiat,
),
amountTextFieldValue = amountTextFieldValue,
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHidden,
appCurrency = appCurrency,
amountField = if (isFromCard) {
buildAmountField(
actions = actions,
prevAmountField = prevAmountField,
swapCurrencyStatus = swapCurrencyStatus,
appCurrency = appCurrency,
)
} else {
// Read-only receive card mirrors the same display value the "from" card shows in transfer mode.
displayAmountField(
actions = actions,
value = displayValue,
swapCurrencyStatus = swapCurrencyStatus,
appCurrency = appCurrency,
)
},
)
}
/**
* Builds the read-only receive card [AmountFieldModel] in transfer mode from a display [value].
* The read-only UI only reads [AmountFieldModel.value].
*/
private fun displayAmountField(
actions: UiActions,
value: String,
swapCurrencyStatus: SwapCurrencyStatus,
appCurrency: AppCurrency,
): AmountFieldModel = AmountFieldConverter(
clickIntents = SwapAmountScreenClickIntents(actions),
cryptoCurrencyStatus = swapCurrencyStatus.status,
appCurrency = appCurrency,
).convert(value = "").copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
onValuePastedTriggerDismiss = {},
)
/**
* Rebuilds the "from" card [AmountFieldModel] in transfer mode, preserving the previously entered
* value and the crypto/fiat toggle while refreshing currency-derived fields against the latest status.
*/
private fun buildAmountField(
actions: UiActions,
prevAmountField: AmountFieldModel?,
swapCurrencyStatus: SwapCurrencyStatus,
appCurrency: AppCurrency,
): AmountFieldModel {
val fiatRate = swapCurrencyStatus.status.value.fiatRate
val isFiatValue = prevAmountField?.isFiatValue == true && fiatRate != null
val cryptoDecimal = prevAmountField?.cryptoAmount?.value.orZero()
val fiatDecimal = fiatRate?.multiply(cryptoDecimal)
// The converter is the single source for cryptoAmount / fiatAmount construction (FIAT_DECIMALS = 2).
// The previously entered value + crypto/fiat toggle display are restored afterwards via copy(...),
// keeping the resulting AmountFieldModel field-for-field equivalent to the prior hand-rolled builder.
return AmountFieldConverter(
clickIntents = SwapAmountScreenClickIntents(actions),
cryptoCurrencyStatus = swapCurrencyStatus.status,
appCurrency = appCurrency,
).convert(value = cryptoDecimal.toPlainString()).copy(
value = prevAmountField?.value.orEmpty(),
isFiatValue = isFiatValue,
fiatValue = fiatDecimal?.format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}.orEmpty(),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
onValuePastedTriggerDismiss = {},
)
}
@ -149,7 +236,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
)
}
TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = if (isInsufficientBalance) {
TransactionCardType.InputError.InsufficientFunds