Updated on 2026-08-14

This commit is contained in:
Tangem 2024-03-19 18:11:56 +00:00
commit b76e1bbc45
219 changed files with 4585 additions and 5271 deletions

View file

@ -20,6 +20,7 @@ data class SwapStateHolder(
val alert: SwapWarning.GenericWarning? = null,
val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED,
val providerState: ProviderState,
val reduceAmountIgnore: Boolean, // ignore warning about reducing XTZ amount by 0.01
val fee: FeeItemState = FeeItemState.Empty,
val permissionState: SwapPermissionState = SwapPermissionState.Empty,
@ -108,7 +109,6 @@ sealed interface SwapWarning {
val title: TextReference? = null,
val message: TextReference? = null,
val type: GenericWarningType = GenericWarningType.OTHER,
val shouldWrapMessage: Boolean = false,
val onClick: () -> Unit,
) : SwapWarning
data class GeneralError(val notificationConfig: NotificationConfig) : SwapWarning
@ -116,6 +116,8 @@ sealed interface SwapWarning {
data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class GeneralInformational(val notificationConfig: NotificationConfig) : SwapWarning
data class TransactionInProgressWarning(val title: TextReference, val description: TextReference) : SwapWarning
data class NeedReserveToCreateAccount(val notificationConfig: NotificationConfig) : SwapWarning
data class ReduceAmount(val notificationConfig: NotificationConfig) : SwapWarning
}
enum class GenericWarningType {

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.models
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TxFee
data class UiActions(
@ -13,6 +14,8 @@ data class UiActions(
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
val onReduceAmount: (SwapAmount) -> Unit,
val onReduceAmountIgnoreClick: () -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onChangeApproveType: (ApproveType) -> Unit,
// region new actions

View file

@ -88,6 +88,7 @@ internal class StateBuilder(
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
providerState = ProviderState.Empty(),
shouldShowMaxAmount = false,
reduceAmountIgnore = false,
priceImpact = PriceImpact.Empty(),
)
}
@ -213,7 +214,11 @@ internal class StateBuilder(
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = getWarningsForSuccessState(quoteModel, fromToken)
val warnings = getWarningsForSuccessState(
quoteModel = quoteModel,
fromToken = fromToken,
ignoreAmountReduce = uiStateHolder.reduceAmountIgnore,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
@ -311,41 +316,28 @@ internal class StateBuilder(
private fun getWarningsForSuccessState(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
ignoreAmountReduce: Boolean,
): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>()
addDomainWarnings(quoteModel, warnings)
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol),
),
)
}
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
)
else -> Unit
}
addUnableCoverFeeWarning(quoteModel, fromToken, warnings)
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
) {
warnings.add(SwapWarning.InsufficientFunds)
}
maybeAddDomainWarnings(quoteModel, warnings, ignoreAmountReduce)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings)
return warnings
}
private fun maybeAddTransactionInProgressWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
warnings.add(
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.swapping_pending_transaction_title),
description = resourceReference(R.string.swapping_pending_transaction_subtitle),
title = resourceReference(R.string.warning_express_approval_in_progress_title),
description = resourceReference(R.string.warning_express_approval_in_progress_message),
),
)
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
@ -361,10 +353,13 @@ internal class StateBuilder(
),
)
}
return warnings
}
private fun addDomainWarnings(quoteModel: SwapState.QuotesLoadedState, warnings: MutableList<SwapWarning>) {
private fun maybeAddDomainWarnings(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
ignoreAmountReduce: Boolean,
) {
quoteModel.warnings.forEach {
when (it) {
is Warning.ExistentialDepositWarning -> {
@ -390,7 +385,7 @@ internal class StateBuilder(
NotificationConfig(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(
R.string.send_notification_invalid_minimum_amount_text,
R.string.warning_express_dust_message,
wrappedList(
it.dustValue.toPlainString(),
it.dustValue.toPlainString(),
@ -401,11 +396,84 @@ internal class StateBuilder(
),
)
}
is Warning.ReduceAmountWarning -> {
if (!ignoreAmountReduce) {
warnings.add(
SwapWarning.ReduceAmount(
notificationConfig = createReduceAmountNotificationConfig(
amount = it.tezosFeeThreshold.toPlainString(),
onConfirmClick = {
val fromAmount = quoteModel.fromTokenInfo.tokenAmount
val patchedAmount = fromAmount.copy(
value = fromAmount.value - it.tezosFeeThreshold,
)
actions.onReduceAmount(patchedAmount)
},
onDismissClick = actions.onReduceAmountIgnoreClick,
),
),
)
}
}
}
}
}
private fun addUnableCoverFeeWarning(
private fun maybeAddNeedReserveToCreateAccountWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
warnings.add(
SwapWarning.NeedReserveToCreateAccount(
notificationConfig = createActivateAccountNotificationConfig(
status.amountToCreateAccount,
quoteModel.toTokenInfo.cryptoCurrencyStatus.currency.name,
),
),
)
}
}
}
private fun maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
fromToken: CryptoCurrency,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol),
),
)
}
}
private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
)
else -> Unit
}
}
private fun maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
warnings: MutableList<SwapWarning>,
@ -428,7 +496,29 @@ internal class StateBuilder(
}
}
private fun maybeAddInsufficientFundsWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
) {
warnings.add(SwapWarning.InsufficientFunds)
}
}
private fun getSwapButtonEnabled(quoteModel: SwapState.QuotesLoadedState): Boolean {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
return false
}
}
val preparedSwapConfigState = quoteModel.preparedSwapConfigState
// check has has outgoing transaction
if (preparedSwapConfigState.hasOutgoingTransaction) return false
@ -587,7 +677,7 @@ internal class StateBuilder(
subtitle = if (dataError is DataError.UnknownError) {
resourceReference(R.string.common_unknown_error)
} else {
resourceReference(R.string.generic_error_code, wrappedList(dataError.code.toString()))
resourceReference(R.string.express_error_code, wrappedList(dataError.code.toString()))
},
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
@ -774,7 +864,7 @@ internal class StateBuilder(
SwapWarning.GeneralWarning(
notificationConfig = NotificationConfig(
title = TextReference.Res(R.string.warning_express_refresh_required_title),
subtitle = TextReference.Res(R.string.generic_error_code, wrappedList(code)),
subtitle = TextReference.Res(R.string.express_error_code, wrappedList(code)),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = TextReference.Res(R.string.warning_button_refresh),
@ -823,8 +913,8 @@ internal class StateBuilder(
warnings.add(
0,
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.swapping_pending_transaction_title),
description = resourceReference(R.string.swapping_pending_transaction_subtitle),
title = resourceReference(R.string.warning_express_approval_in_progress_title),
description = resourceReference(R.string.warning_express_approval_in_progress_message),
),
)
return uiState.copy(
@ -928,17 +1018,11 @@ internal class StateBuilder(
fun clearAlert(uiState: SwapStateHolder): SwapStateHolder = uiState.copy(alert = null)
fun addWarning(
uiState: SwapStateHolder,
message: TextReference?,
shouldWrapMessage: Boolean = false,
onClick: () -> Unit,
): SwapStateHolder {
fun addWarning(uiState: SwapStateHolder, message: TextReference?, onClick: () -> Unit): SwapStateHolder {
val renewWarnings = uiState.warnings.filterNot { it is SwapWarning.GenericWarning }.toMutableList()
renewWarnings.add(
SwapWarning.GenericWarning(
message = message,
shouldWrapMessage = shouldWrapMessage,
onClick = onClick,
),
)
@ -1220,6 +1304,35 @@ internal class StateBuilder(
)
}
private fun createActivateAccountNotificationConfig(amount: BigDecimal, token: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
formatArgs = wrappedList("$amount $token"),
),
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
iconResId = R.drawable.img_attention_20,
)
}
private fun createReduceAmountNotificationConfig(
amount: String,
onConfirmClick: () -> Unit,
onDismissClick: () -> Unit,
): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig(
primaryText = resourceReference(R.string.xtz_withdrawal_message_reduce, wrappedList(amount)),
onPrimaryClick = onConfirmClick,
secondaryText = resourceReference(R.string.xtz_withdrawal_message_ignore),
onSecondaryClick = onDismissClick,
),
)
}
private fun createUnableToCoverFeeNotificationConfig(
fromToken: CryptoCurrency,
feeCurrency: CryptoCurrency?,

View file

@ -337,14 +337,9 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
)
}
is SwapWarning.GenericWarning -> {
val message = warning.message?.let {
if (warning.shouldWrapMessage) {
String.format(stringResource(id = R.string.swapping_error_wrapper), it.resolveReference())
} else {
it.resolveReference()
}
} ?: stringResource(id = R.string.common_unknown_error)
RefreshableWaringCard(
val message = warning.message?.resolveReference()
?: stringResource(id = R.string.common_unknown_error)
RefreshableWarningCard(
title = stringResource(id = R.string.common_warning),
description = message,
onClick = warning.onClick,
@ -377,6 +372,16 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
iconTint = TangemTheme.colors.icon.accent,
)
}
is SwapWarning.NeedReserveToCreateAccount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.ReduceAmount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.TransactionInProgressWarning -> {
CardWithIcon(
title = warning.title.resolveReference(),
@ -499,6 +504,7 @@ private val state = SwapStateHolder(
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty(),
shouldShowMaxAmount = true,
reduceAmountIgnore = false,
tosState = TosState(
tosLink = LegalState(
title = stringReference("Terms of Use"),

View file

@ -27,6 +27,7 @@ import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.*
@ -812,6 +813,10 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun onReduceAmountClicked(newAmount: SwapAmount) {
onAmountChanged(newAmount.formatToUIRepresentation())
}
@Suppress("UnusedPrivateMember")
private fun onAmountSelected(selected: Boolean) {
if (selected) {
@ -874,7 +879,14 @@ internal class SwapViewModel @Inject constructor(
}
onSearchEntered("")
},
onMaxAmountSelected = { onMaxAmountClicked() },
onMaxAmountSelected = ::onMaxAmountClicked,
onReduceAmount = ::onReduceAmountClicked,
onReduceAmountIgnoreClick = {
uiState = uiState.copy(
reduceAmountIgnore = true,
warnings = uiState.warnings.filter { it !is SwapWarning.ReduceAmount },
)
},
openPermissionBottomSheet = {
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked)
@ -1153,11 +1165,11 @@ internal class SwapViewModel @Inject constructor(
)
}
companion object {
private const val loggingTag = "SwapViewModel"
private const val INITIAL_AMOUNT = ""
private const val UPDATE_DELAY = 10000L
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
private const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
private companion object {
const val loggingTag = "SwapViewModel"
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
}
}