Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-20 14:33:21 +01:00
parent cf9e84b748
commit 44cae7ffd8
11 changed files with 1048 additions and 179 deletions

View file

@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.routing.AppRoute
@ -205,6 +206,7 @@ internal class SwapModel @Inject constructor(
)
private val amountDebouncer = Debouncer()
private val transferModeDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
private val performanceTracker = SwapQuotePerformanceTracker()
@ -682,13 +684,18 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
forceUpdate: Boolean = true,
): Boolean {
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
)
if (shouldTransferInsteadOfSwap) {
modelScope.launch {
transferModeDebouncer.debounce(
coroutineScope = modelScope,
waitMs = DEBOUNCE_AMOUNT_DELAY,
forceUpdate = forceUpdate,
) {
singleTaskScheduler.destroyTask()
swapPairsJobHolder.cancel()
updateTransferUIState(fromSwapCurrencyStatus, toSwapCurrencyStatus, fromTokenAmount)
@ -702,19 +709,28 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
) {
val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency
val selectedFee = getSelectedSwapFee()?.fee
val swapState = swapTransferInteractor.updateTransfer(
fromSwapCurrencyStatus,
toSwapCurrencyStatus,
fromTokenAmount,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = fromTokenAmount,
feePaidCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
)
when (swapState) {
is SwapState.EmptyAmountState -> setupEmptyAmountUiState(swapState, fromSwapCurrencyStatus)
is SwapState.Transfer -> {
dataState = dataState.copy(amount = fromTokenAmount)
dataState = dataState.copy(
amount = fromTokenAmount,
currentTransferState = swapState,
)
uiState = swapTransferStateBuilder.createTransferState(
actions = actions,
transferState = swapState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
)
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
@ -723,11 +739,36 @@ internal class SwapModel @Inject constructor(
}
}
private fun refreshTransferUIStateAfterFeeUpdate() {
private fun refreshTransferUIStateAfterFeeUpdateIfNeeded(
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
fee: Fee? = null,
) {
val from = dataState.fromSwapCurrencyStatus ?: return
val to = dataState.toSwapCurrencyStatus ?: return
if (!swapTransferInteractor.shouldTransferInsteadOfSwap(from.currency, to.currency)) return
// todo notification check should be triggered (will be implemented in [REDACTED_TASK_KEY])
val currentTransferState = dataState.currentTransferState ?: return
val amount = dataState.amount ?: return
modelScope.launch {
// The cached currentTransferState may have been built when the fee selector
// had not loaded yet (fee=null). Recompute it with the freshly-loaded fee so
// isFeeCoverage and sendingAmount reflect the actual fee, otherwise the fee
// coverage notification stays hidden on first Max click.
val refreshed = swapTransferInteractor.updateTransfer(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
fromTokenAmount = amount,
feePaidCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
) as? SwapState.Transfer ?: currentTransferState
dataState = dataState.copy(currentTransferState = refreshed)
uiState = swapTransferStateBuilder.updateTransferButtonEnableState(
transferState = refreshed,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
)
}
}
private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) {
@ -1243,12 +1284,13 @@ internal class SwapModel @Inject constructor(
showAlert()
return
}
val transferState = dataState.currentTransferState ?: return
uiState = swapTransferStateBuilder.createTransferInProgressState(uiState)
modelScope.launch(dispatchers.main) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
@ -1256,7 +1298,6 @@ internal class SwapModel @Inject constructor(
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
refreshTransferUIStateAfterFeeUpdate()
showAlert()
},
ifRight = { txHash ->
@ -1486,6 +1527,7 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
forceUpdate = forceQuotesUpdate,
)
if (isUpdatedToTransferMode) return@launch
if (toSwapCurrencyStatus.status.value.amount != null) {
@ -2224,21 +2266,22 @@ internal class SwapModel @Inject constructor(
override fun onResult(newState: FeeSelectorUM) {
state.value = newState
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
if (newState is FeeSelectorUM.Error) {
TangemLogger.e("loadFee: ${newState.error}, isHidden = true")
refreshTransferUIStateAfterFeeUpdateIfNeeded()
uiState = stateBuilder.createFeeErrorState(
uiStateHolder = uiState,
quoteModel = quoteState,
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
feeError = newState.error,
)
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
refreshTransferUIStateAfterFeeUpdate()
return
}
refreshTransferUIStateAfterFeeUpdate()
refreshTransferUIStateAfterFeeUpdateIfNeeded(
feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
@ -2249,6 +2292,7 @@ internal class SwapModel @Inject constructor(
)
if (shouldTransferInsteadOfSwap) return
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return
modelScope.launch(dispatchers.default) {

View file

@ -20,6 +20,7 @@ data class SwapProcessDataState(
val selectedPairProviders: List<SwapProvider> = emptyList(),
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),
val currentTransferState: SwapState.Transfer? = null,
// Amount from input
val amount: String? = null,

View file

@ -0,0 +1,175 @@
package com.tangem.feature.swap.ui.transfer
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
import javax.inject.Inject
internal class SwapTransferNotificationsFactory @Inject constructor() {
fun getNotifications(
transferState: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
): ImmutableList<NotificationUM> {
return buildList {
maybeAddRentExemptionError(transferState)
maybeAddDomainWarnings(
state = transferState,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = fee,
onReduceByAmount = onReduceByAmount,
onReduceToAmount = onReduceToAmount,
)
maybeAddNeedReserveToCreateAccountWarning(transferState)
}.toPersistentList()
}
private fun MutableList<NotificationUM>.maybeAddRentExemptionError(state: SwapState.Transfer) {
state.currencyCheck?.rentWarning?.let {
add(NotificationUM.Solana.RentInfo(it))
}
}
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
state: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
) {
val swapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus
val amount = state.fromTokenInfo.tokenAmount
val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO
val feeValue = fee?.amount?.value.orZero()
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
addExistentialWarningNotification(
existentialDeposit = state.currencyCheck?.existentialDeposit,
feeAmount = feeValue,
sendingAmount = amount.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
onReduceClick = { reduceBy, reduceByDiff, _ ->
onReduceByAmount(
amount.copy(value = amount.value.minus(reduceByDiff)),
reduceBy,
)
},
)
addValidateTransactionNotifications(
dustValue = state.currencyCheck?.dustValue.orZero(),
validationError = state.validationResult,
cryptoCurrency = swapCurrencyStatus.currency,
minAdaValue = state.minAdaValue,
onReduceClick = { reduceTo, _ ->
onReduceToAmount(amount.copy(value = reduceTo))
},
)
if (!isCardano) {
addDustWarningNotification(
dustValue = state.currencyCheck?.dustValue,
feeValue = feeValue,
sendingAmount = amount.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
)
}
addReserveAmountErrorNotification(
reserveAmount = state.currencyCheck?.reserveAmount,
sendingAmount = amount.value,
cryptoCurrency = swapCurrencyStatus.currency,
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
isAccountFunded = true,
)
addReduceAmountNotification(
cryptoCurrencyStatus = swapCurrencyStatus.status,
fromAmount = state.fromTokenInfo.tokenAmount,
balance = balance,
onReduceByAmount = onReduceByAmount,
)
addTransactionLimitErrorNotification(
currencyCheck = state.currencyCheck,
sendingAmount = amount.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
feeValue = feeValue,
onReduceClick = { reduceTo, _ ->
onReduceToAmount(amount.copy(value = reduceTo))
},
)
maybeAddFeeCoverageNotification(state = state, amount = amount)
}
private fun MutableList<NotificationUM>.maybeAddFeeCoverageNotification(
state: SwapState.Transfer,
amount: SwapAmount,
) {
addFeeCoverageNotification(
isFeeCoverage = state.isFeeCoverage,
enteredAmountValue = amount.value,
sendingValue = state.sendingAmount,
appCurrency = state.appCurrency,
cryptoCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus.status,
)
}
private fun MutableList<NotificationUM>.maybeAddNeedReserveToCreateAccountWarning(state: SwapState.Transfer) {
val status = state.toTokenInfo.swapCurrencyStatus.status.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = state.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
val currencyTo = state.toTokenInfo.swapCurrencyStatus.currency
if (amount < amountToCreateAccount) {
add(
SwapNotificationUM.Warning.NeedReserveToCreateAccount(
receiveAmount = status.amountToCreateAccount.parseBigDecimal(currencyTo.decimals),
receiveToken = currencyTo.symbol,
),
)
}
}
}
private fun MutableList<NotificationUM>.addReduceAmountNotification(
cryptoCurrencyStatus: CryptoCurrencyStatus,
fromAmount: SwapAmount,
balance: BigDecimal,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
) {
val isTezos = isTezos(cryptoCurrencyStatus.currency.network.rawId)
val threshold = getTezosThreshold()
val isTotalBalance = fromAmount.value >= balance && balance > threshold
if (isTezos && isTotalBalance) {
add(
SwapNotificationUM.Warning.ReduceAmount(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(),
onConfirmClick = {
val patchedAmount = fromAmount.copy(
value = fromAmount.value - threshold,
)
onReduceByAmount(patchedAmount, threshold)
},
),
)
}
}
}

View file

@ -1,12 +1,13 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
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.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
@ -24,13 +25,16 @@ 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.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
import javax.inject.Inject
internal class SwapTransferStateBuilder @Inject constructor() {
internal class SwapTransferStateBuilder @Inject constructor(
private val notificationsFactory: SwapTransferNotificationsFactory,
) {
private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -38,13 +42,24 @@ internal class SwapTransferStateBuilder @Inject constructor() {
actions: UiActions,
transferState: SwapState.Transfer,
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
): SwapStateHolder {
val fromTokenSwapInfo = transferState.fromTokenInfo
val toTokenSwapInfo = transferState.toTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
val amountTextFieldValue = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.amountTextFieldValue
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
tokenSwapInfo = fromTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
@ -54,6 +69,7 @@ internal class SwapTransferStateBuilder @Inject constructor() {
),
receiveCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
tokenSwapInfo = toTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
@ -69,12 +85,14 @@ internal class SwapTransferStateBuilder @Inject constructor() {
onClick = actions.onTransferClick,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
notifications = notifications,
)
}
@Suppress("LongParameterList")
private fun createSendSwapCardState(
actions: UiActions,
amountTextFieldValue: TextFieldValue?,
tokenSwapInfo: TokenSwapInfo,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
@ -83,7 +101,6 @@ internal class SwapTransferStateBuilder @Inject constructor() {
isInsufficientBalance: Boolean,
): SwapCardState {
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
val formattedSwapAmount = tokenSwapInfo.tokenAmount.formatToUIRepresentation()
return SwapCardState.SwapCardData(
type = createSendTransactionCardType(
@ -101,10 +118,7 @@ internal class SwapTransferStateBuilder @Inject constructor() {
appCurrency = appCurrency,
amount = tokenSwapInfo.amountFiat,
),
amountTextFieldValue = TextFieldValue(
text = formattedSwapAmount,
selection = TextRange(index = formattedSwapAmount.length),
),
amountTextFieldValue = amountTextFieldValue,
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHidden,
)
@ -190,6 +204,40 @@ internal class SwapTransferStateBuilder @Inject constructor() {
}
}
fun updateTransferButtonEnableState(
transferState: SwapState.Transfer,
actions: UiActions,
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
): SwapStateHolder {
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
return uiStateHolder.copy(
notifications = notifications,
swapButton = uiStateHolder.swapButton.copy(
isEnabled = getTransferButtonEnabled(notifications, fee),
),
)
}
private fun getTransferButtonEnabled(notifications: ImmutableList<NotificationUM>, fee: Fee?): Boolean {
return fee != null && notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
notification is SwapNotificationUM.Warning.NoAvailableTokensToSwap ||
notification is SwapNotificationUM.Warning.SwapNotSupported ||
notification is SwapNotificationUM.Warning.NeedReserveToCreateAccount ||
notification is SwapNotificationUM.Info.PermissionNeeded
}
}
fun createTransferInProgressState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(