Updated on 2026-08-14
This commit is contained in:
parent
4383291669
commit
fec7986d80
16 changed files with 1322 additions and 587 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue