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

View file

@ -0,0 +1,302 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRouter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class StateBuilderUpdateSwapAmountTest {
private val actions: UiActions = mockk(relaxed = true)
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private val appRouter: AppRouter = mockk()
private val appCurrency = AppCurrency.Default
private val userWalletId = UserWalletId("aabbccdd")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private lateinit var sut: StateBuilder
@BeforeEach
fun setup() {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns appCurrency
every { isAccountsModeProvider() } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
swapFeatureToggles = swapFeatureToggles,
appRouter = appRouter,
)
}
private fun readyState(fromStatus: SwapCurrencyStatus): SwapStateHolder = sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = stringReference("$0.00")),
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = buildSwapCurrencyStatus(coldWallet),
)
private val SwapStateHolder.sendCard: SwapCardState.SwapCardData
get() = sendCardData as SwapCardState.SwapCardData
@Test
fun `GIVEN crypto input WHEN updateSwapAmount THEN field shows crypto value and equivalent is fiat`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet) // fiatRate = 2000
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
val field = result.sendCard.amountField!!
assertThat(field.value).isEqualTo("0.5")
assertThat(field.isFiatValue).isFalse()
assertThat(field.cryptoAmount.value).isEqualTo(BigDecimal("0.5"))
assertThat(field.isValuePasted).isFalse()
// 0.5 * 2000 = 1000 fiat
assertThat(result.sendCard.amountEquivalent).isEqualTo(
stringReference(
BigDecimal("1000.00").format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
},
),
)
}
@Test
fun `GIVEN fiat input WHEN updateSwapAmount THEN field marked fiat and equivalent is crypto`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet) // fiatRate = 2000
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5", // crypto authoritative amount
fieldValue = "1000", // displayed fiat value
isFiatValue = true,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
val field = result.sendCard.amountField!!
assertThat(field.value).isEqualTo("1000")
assertThat(field.isFiatValue).isTrue()
assertThat(field.cryptoAmount.value).isEqualTo(BigDecimal("0.5"))
// equivalent line shows the crypto amount when entering fiat
assertThat(result.sendCard.amountEquivalent).isEqualTo(
stringReference(BigDecimal("0.5").format { crypto(fromStatus.currency) }),
)
}
@Test
fun `GIVEN fiat input but fiat rate unavailable WHEN updateSwapAmount THEN field falls back to crypto display`() {
// Arrange
val fromStatus = buildSwapCurrencyStatusNoFiatRate(coldWallet)
val base = readyState(buildSwapCurrencyStatus(coldWallet))
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = true,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
val field = result.sendCard.amountField!!
// isFiatValue collapses to false because the rate is unavailable
assertThat(field.isFiatValue).isFalse()
assertThat(field.isFiatUnavailable).isTrue()
}
@Test
fun `GIVEN amount below min WHEN updateSwapAmount THEN send card reports WrongAmount error`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = BigDecimal("1"),
isPastedAmount = false,
)
// Assert
val inputtable = result.sendCard.type as TransactionCardType.Inputtable
assertThat(inputtable.inputError).isEqualTo(TransactionCardType.InputError.WrongAmount)
}
@Test
fun `GIVEN amount at or above min WHEN updateSwapAmount THEN send card has no input error`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "2",
fieldValue = "2",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = BigDecimal("1"),
isPastedAmount = false,
)
// Assert
val inputtable = result.sendCard.type as TransactionCardType.Inputtable
assertThat(inputtable.inputError).isEqualTo(TransactionCardType.InputError.Empty)
}
@Test
fun `GIVEN pasted amount WHEN updateSwapAmount THEN field flags value as pasted`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = true,
)
// Assert
assertThat(result.sendCard.amountField!!.isValuePasted).isTrue()
}
@Test
fun `GIVEN send card is not SwapCardData WHEN updateSwapAmount THEN uiState returned unchanged`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val loadingState = sut.createInitialLoadingState() // send card is Empty, not SwapCardData
// Act
val result = sut.updateSwapAmount(
uiState = loadingState,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
assertThat(result).isSameInstanceAs(loadingState)
}
@Test
fun `GIVEN ready state WHEN createQuotesEmptyAmountState THEN receive amount resets to zero and button disabled`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = sut.updateSwapAmount(
uiState = readyState(fromStatus),
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
val zeroEquivalent = stringReference("$0.00")
// Act
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = base,
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = zeroEquivalent),
fromSwapCurrencyStatus = fromStatus,
)
// Assert
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
assertThat(receiveCard.amountField!!.value).isEqualTo("0")
assertThat(result.sendCard.amountEquivalent).isEqualTo(zeroEquivalent)
assertThat(receiveCard.amountEquivalent).isEqualTo(zeroEquivalent)
assertThat(result.swapButton.isEnabled).isFalse()
assertThat(result.isInsufficientFunds).isFalse()
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN receive card is not SwapCardData WHEN createQuotesEmptyAmountState THEN uiState returned unchanged`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
// createInitialReadyState builds SwapCardData send + SwapCardData receive, but loading state has Empty cards
val loadingState = sut.createInitialLoadingState()
// Act
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = loadingState,
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = stringReference("$0.00")),
fromSwapCurrencyStatus = fromStatus,
)
// Assert
assertThat(result).isSameInstanceAs(loadingState)
}
private fun buildSwapCurrencyStatusNoFiatRate(userWallet: UserWallet): SwapCurrencyStatus {
val status = buildSwapCurrencyStatus(userWallet)
every { status.status.value.fiatRate } returns null
return status
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.feature.swap.ui
import com.google.common.truth.Truth.assertThat
import com.tangem.feature.swap.models.UiActions
import org.junit.jupiter.api.Test
// PER_METHOD (the JUnit5 default): a fresh instance per test, so the recorded-call fields never leak.
internal class SwapAmountScreenClickIntentsTest {
private var changedValue: String? = null
private var maxClicked = false
private var currencyChangeIsFiat: Boolean? = null
// Real UiActions: the three wired callbacks record their invocation, the rest are no-ops.
private val actions = UiActions(
onAmountChanged = { changedValue = it },
onCurrencyChange = { currencyChangeIsFiat = it },
onAmountSelected = {},
onSwapClick = {},
onTransferClick = {},
onChangeCardsClicked = {},
onBackClicked = {},
onMaxAmountSelected = { maxClicked = true },
onPredefinedPercentSelected = {},
onReduceToAmount = {},
onReduceByAmount = { _, _ -> },
onApproveClick = {},
onApproveTypeSelect = {},
onRetryClick = {},
onProviderClick = {},
onProviderSelect = {},
onProviderFilterSelect = {},
openTokenDetailsScreen = {},
onSelectTokenClick = {},
onSuccess = {},
onLinkClick = {},
onReceiveCardWarningClick = {},
onSwapUIModeChange = {},
onSwapTypeMenuOpened = {},
)
private val sut = SwapAmountScreenClickIntents(actions)
@Test
fun `GIVEN value WHEN onAmountValueChange THEN delegates to onAmountChanged`() {
// Act
sut.onAmountValueChange("12.34")
// Assert
assertThat(changedValue).isEqualTo("12.34")
}
@Test
fun `GIVEN max clicked WHEN onMaxValueClick THEN delegates to onMaxAmountSelected`() {
// Act
sut.onMaxValueClick()
// Assert
assertThat(maxClicked).isTrue()
}
@Test
fun `GIVEN fiat toggle WHEN onCurrencyChangeClick THEN delegates to onCurrencyChange`() {
// Act
sut.onCurrencyChangeClick(isFiat = true)
// Assert
assertThat(currencyChangeIsFiat).isTrue()
}
@Test
fun `GIVEN paste dismiss and next WHEN invoked THEN they are no-ops and do not delegate`() {
// Act
sut.onAmountPasteTriggerDismiss()
sut.onAmountNext()
// Assert — none of the wired callbacks fired
assertThat(changedValue).isNull()
assertThat(maxClicked).isFalse()
assertThat(currencyChangeIsFiat).isNull()
}
}

View file

@ -1,13 +1,16 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
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.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
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.models.AmountFieldModel
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.TextReference
@ -77,9 +80,31 @@ internal class SwapTransferStateBuilderTest {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val fromIcon = iconConverter.convert(fromCurrencyStatus.status)
private val toIcon = iconConverter.convert(toCurrencyStatus.status)
private val initialAmountTextFieldValue = TextFieldValue(
text = "0.5",
selection = TextRange(index = 3),
private val initialAmountValue = "0.5"
private val initialAmountField = AmountFieldModel(
value = initialAmountValue,
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "",
value = BigDecimal("0.5"),
decimals = 18,
),
fiatAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "$",
value = BigDecimal("0.5"),
decimals = 2,
type = com.tangem.domain.tokens.model.AmountType.FiatType("USD"),
),
isFiatValue = false,
fiatValue = "",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
)
@Test
@ -104,7 +129,8 @@ internal class SwapTransferStateBuilderTest {
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
@ -154,7 +180,8 @@ internal class SwapTransferStateBuilderTest {
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
)
@ -197,7 +224,8 @@ internal class SwapTransferStateBuilderTest {
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
@ -243,7 +271,8 @@ internal class SwapTransferStateBuilderTest {
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
@ -684,8 +713,8 @@ internal class SwapTransferStateBuilderTest {
) {
val sendCard = result.sendCardData as SwapCardState.SwapCardData
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
assertThat(sendCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
assertThat(receiveCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
assertThat(receiveCard.amountField?.value).isEqualTo(initialAmountValue)
assertThat(sendCard.currencyIconState).isEqualTo(fromIcon)
assertThat(receiveCard.currencyIconState).isEqualTo(toIcon)
assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
@ -742,8 +771,8 @@ internal class SwapTransferStateBuilderTest {
private fun baseStateHolder(): SwapStateHolder = SwapStateHolder(
sendCardData = SwapCardState.SwapCardData(
appCurrency = AppCurrency.Default,
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
@ -752,7 +781,7 @@ internal class SwapTransferStateBuilderTest {
currencyIconState = fromIcon,
tokenSymbol = stringReference(""),
amountEquivalent = TextReference.EMPTY,
amountTextFieldValue = initialAmountTextFieldValue,
amountField = initialAmountField,
balance = "",
isBalanceHidden = false,
),