Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-27 12:01:54 +05:00
commit 3c36f08490
15 changed files with 222 additions and 127 deletions

View file

@ -694,7 +694,7 @@
<string name="initial_message_tap_header">Lege die Karte oder Ring an</string>
<string name="initial_wallet_sync_banner_description">Deine Wallet ist eingerichtet und einsatzbereit!</string>
<string name="initial_wallet_sync_banner_title">Wallet erfolgreich importiert</string>
<string name="initial_wallet_sync_restore_progress">Wiederherstellung %d%</string>
<string name="initial_wallet_sync_restore_progress">Wiederherstellung %d%%</string>
<string name="key_invalidated_warning_description">Du hast deine biometrischen Daten aktualisiert, scanne deine Karte oder Ring, um einzutreten</string>
<string name="koinos_insufficient_balance_to_send_koin_description">Dein Guthaben sollte höher sein als der Gebührenwert, um eine Überweisung zu tätigen</string>
<string name="koinos_insufficient_balance_to_send_koin_title">Unzureichende Mittel</string>

View file

@ -689,7 +689,7 @@
<string name="initial_message_tap_header">カードまたはリングをタップ</string>
<string name="initial_wallet_sync_banner_description">ウォレットは同期され、準備ができています。\n一部のトークンが見つかりませんか</string>
<string name="initial_wallet_sync_banner_title">ウォレットのインポートが完了しました</string>
<string name="initial_wallet_sync_restore_progress">%d%を復元中</string>
<string name="initial_wallet_sync_restore_progress">%d%%を復元中</string>
<string name="key_invalidated_warning_description">生体認証を更新しました。カードまたはリングをスキャンして入ってください。</string>
<string name="koinos_insufficient_balance_to_send_koin_description">送金するには、残高が手数料額より高くなければなりません。</string>
<string name="koinos_insufficient_balance_to_send_koin_title">残高不足</string>

View file

@ -306,6 +306,7 @@
<string name="common_hour">hora</string>
<string name="common_import">Importar</string>
<string name="common_in_progress">Em andamento</string>
<string name="common_insufficient_balance">Saldo insuficiente</string>
<string name="common_later">Mais tarde</string>
<string name="common_learn_more">Saber mais</string>
<string name="common_left">%1$s falta</string>
@ -692,6 +693,9 @@
<string name="initial_message_scan_header">Toque para digitalizar</string>
<string name="initial_message_sign_header">Toque para assinar</string>
<string name="initial_message_tap_header">Toque no Cartão ou no Anel</string>
<string name="initial_wallet_sync_banner_description">Sua carteira está sincronizada e pronta. \n Faltam alguns tokens?</string>
<string name="initial_wallet_sync_banner_title">Carteira importada com sucesso.</string>
<string name="initial_wallet_sync_restore_progress">Restaurando %d%%</string>
<string name="key_invalidated_warning_description">Você atualizou seus dados biométricos. Escaneie seu cartão ou toque a campainha para entrar.</string>
<string name="koinos_insufficient_balance_to_send_koin_description">Seu saldo deve ser superior ao valor da taxa para efetuar uma transferência.</string>
<string name="koinos_insufficient_balance_to_send_koin_title">Saldo insuficiente</string>
@ -752,6 +756,7 @@
<string name="markets_search_result_title">Resultado</string>
<string name="markets_search_see_tokens_under_100k">Veja tokens com capitalização de mercado inferior a 100 mil dólares.</string>
<string name="markets_search_show_tokens">Mostrar tokens</string>
<string name="markets_search_title_placeholder">Criptomoedas, notícias e muito mais</string>
<string name="markets_search_token_no_result_title">Nenhum resultado</string>
<string name="markets_select_network">Selecione a rede</string>
<string name="markets_select_wallet">Selecione a carteira</string>
@ -824,9 +829,11 @@
<string name="markets_token_details_market_rating_description">Posição no ranking de criptomoedas entre todas as moedas com base na capitalização de mercado.</string>
<string name="markets_token_details_market_rating_full">Posicionamento de mercado</string>
<string name="markets_token_details_max_supply">Fornecimento máximo</string>
<string name="markets_token_details_max_supply_and_circulation_full">Circulação e Fornecimento Máximo</string>
<string name="markets_token_details_max_supply_description">O número máximo de moedas ou tokens que podem existir para uma determinada criptomoeda.</string>
<string name="markets_token_details_max_supply_full">Fornecimento máximo</string>
<string name="markets_token_details_metrics">Métricas</string>
<string name="markets_token_details_metrics_no_limited">Sem limite</string>
<string name="markets_token_details_official_links">Links oficiais</string>
<string name="markets_token_details_price_performance">Desempenho de preço</string>
<string name="markets_token_details_repository">Repositório</string>
@ -1101,6 +1108,12 @@
<string name="qr_scanner_camera_denied_settings_button">Configurações</string>
<string name="qr_scanner_camera_denied_text">Você não concedeu acesso à sua câmera.</string>
<string name="qr_scanner_camera_denied_title">Acesso à câmera negado</string>
<string name="qr_scanner_error_token_not_added_message">O token solicitado não foi adicionado à sua carteira. Adicione-o e tente novamente.</string>
<string name="qr_scanner_error_token_not_added_title">Token não adicionado</string>
<string name="qr_scanner_error_unrecognized_message">Desculpe, este código QR não pôde ser reconhecido.</string>
<string name="qr_scanner_error_unrecognized_title">Código QR não reconhecido</string>
<string name="qr_scanner_error_unsupported_network_message">Esta rede não é compatível com nenhum dos tokens adicionados. Adicione um token compatível para enviar criptomoedas.</string>
<string name="qr_scanner_error_unsupported_network_title">Nenhum token compatível encontrado</string>
<string name="receive_bottom_sheet_no_memo_required_message">Não é necessário memorando</string>
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) sobre %3$s rede</string>
<string name="receive_bottom_sheet_warning_message_compact">%1$s sobre %2$s rede</string>
@ -1246,6 +1259,11 @@
<string name="send_notification_transaction_limit_title">Limitação de transação</string>
<string name="send_optional_field">Opcional</string>
<string name="send_qrcode_scan_info">Alinhe o seu código QR com o quadrado para escaneá-lo. Certifique-se de escanear corretamente. %s endereço de rede.</string>
<string name="send_rate_fixed_info_description">Ao utilizar uma taxa fixa, o valor que você recebe fica garantido no momento da troca. Isso protege você de variações de preço durante a transação.</string>
<string name="send_rate_fixed_info_title">Taxa fixa</string>
<string name="send_rate_floating_info_description">Uma taxa flutuante significa que o valor final que você recebe pode variar ligeiramente com base nas condições de mercado entre o momento em que você inicia e conclui a operação de swap.</string>
<string name="send_rate_floating_info_title">Taxa flutuante</string>
<string name="send_rate_is_fixed">A taxa é fixa.</string>
<string name="send_recent_transactions">Recente</string>
<string name="send_recipient">Destinatário</string>
<string name="send_recipient_address_error">Endereço inválido</string>
@ -1289,6 +1307,7 @@
<string name="send_with_swap_recipient_get_amount">O destinatário recebe %s</string>
<string name="send_with_swap_remove_convert_alert_message">Tem certeza de que deseja cancelar a conversão? Seus dados anteriores serão apagados.</string>
<string name="send_with_swap_remove_convert_alert_title">Remover conversão</string>
<string name="send_with_swap_something_went_wrong">Algo deu errado. Tente novamente.</string>
<string name="send_with_swap_title">Enviar com troca</string>
<string name="sent_transaction_sent_title">Transação enviada</string>
<string name="settings_card_settings_footer">Prepare-se para escanear o Cartão ou Anel que deseja configurar.</string>
@ -1482,6 +1501,7 @@
<string name="swapping_high_price_impact_description">Trocar essa quantidade de tokens selecionados causará um impacto significativo no preço e reduzirá seu resultado.</string>
<string name="swapping_high_price_impact_title">Alto impacto nos preços</string>
<string name="swapping_insufficient_funds">Fundos insuficientes</string>
<string name="swapping_insufficient_funds_description">Não há fundos suficientes para concluir esta transação. Reduza o valor a receber ou adicione mais fundos.</string>
<string name="swapping_permission_header">Conceder permissão</string>
<string name="swapping_swap_action">Trocar</string>
<string name="swapping_swap_action_in_progress">Trocar...</string>
@ -1648,6 +1668,7 @@
<string name="token_details_token_type_subtitle_no_standard">Token em %%imagem%% %1$s rede</string>
<string name="token_details_unable_hide_alert_message">O %1$s (%2$sO token ) é a principal moeda da plataforma. %3$s rede e não pode ser ocultada enquanto você tiver outros tokens dessa rede na lista</string>
<string name="token_details_unable_hide_alert_title">Não foi possível ocultar %s</string>
<string name="token_market_metrics_no_data">N/A</string>
<string name="token_receive_show_qr_code_title">Mostrar código QR</string>
<string name="token_swap_changelly_promotion_message">Troque este token por outro em %1$s taxas de serviço a partir de fevereiro %2$s-%3$s.</string>
<string name="token_swap_changelly_promotion_title">Trocar com Changelly, %s tarifas</string>

View file

@ -6,7 +6,7 @@
<string name="common_unlock">解鎖</string>
<string name="initial_wallet_sync_banner_description">您的錢包已同步並準備就緒。\n缺少某些代幣</string>
<string name="initial_wallet_sync_banner_title">錢包導入成功</string>
<string name="initial_wallet_sync_restore_progress">恢復%d%</string>
<string name="initial_wallet_sync_restore_progress">恢復%d%%</string>
<string name="markets_token_details_insight_day_timeline"></string>
<string name="markets_token_details_insight_month_timeline"></string>
<string name="markets_token_details_insight_week_timeline"></string>

View file

@ -696,7 +696,7 @@
<string name="initial_message_tap_header">Tap the card or ring</string>
<string name="initial_wallet_sync_banner_description">Your wallet is synced and ready.\nSome tokens missing?</string>
<string name="initial_wallet_sync_banner_title">Wallet successfully imported</string>
<string name="initial_wallet_sync_restore_progress">Restoring %d%</string>
<string name="initial_wallet_sync_restore_progress">Restoring %d%%</string>
<string name="key_invalidated_warning_description">You have updated biometrics, scan your card or ring to enter</string>
<string name="koinos_insufficient_balance_to_send_koin_description">Your balance should be higher than the fee value to make a transfer</string>
<string name="koinos_insufficient_balance_to_send_koin_title">Not enough balance</string>
@ -1500,6 +1500,7 @@
<string name="swapping_fee_estimation_error_text">Fee estimation error. Please send feedback to support.</string>
<string name="swapping_from_title">You swap</string>
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_high_price_impact_text">You may receive significantly less due to low liquidity. Try a smaller amount or another provider.</string>
<string name="swapping_high_price_impact_title">High price impact</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
<string name="swapping_insufficient_funds_description">Not enough funds to complete this transaction. Reduce the amount to receive or add more funds.</string>
@ -1509,6 +1510,8 @@
<string name="swapping_to_title">You receive</string>
<string name="swapping_token_list_title">Choose token</string>
<string name="swapping_token_not_available">not available</string>
<string name="swapping_trade_too_large_text">Not enough liquidity for this trade.\nReduce the amount or choose another provider.</string>
<string name="swapping_trade_too_large_title">Trade too large</string>
<string name="tangem_pay_beta_notification_subtitle">We would be happy to receive your feedback</string>
<string name="tangem_pay_beta_notification_title">Tangem Pay is now in beta</string>
<string name="tangem_pay_card_frozen">Card frozen</string>

View file

@ -16,7 +16,6 @@ object SwapTokenScreenTestTags {
const val TOKEN_ICON = "SWAP_TOKEN_SCREEN_TOKEN_ICON"
const val SELECT_TOKEN_ICON = "SWAP_TOKEN_SCREEN_SELECT_TOKEN_ICON"
const val RECEIVE_FIAT_AMOUNT = "SWAP_TOKEN_SCREEN_RECEIVE_FIAT_AMOUNT"
const val RECEIVE_FIAT_AMOUNT_WITH_PRICE_IMPACT_WARNING = "SWAP_TOKEN_SCREEN_RECEIVE_FIAT_AMOUNT_WITH_PRICE_IMPACT"
const val RECEIVE_FIAT_AMOUNT_INFORMATION_ICON = "SWAP_TOKEN_SCREEN_PRICE_IMPACT_INFORMATION_ICON"
const val SWAP_FIAT_AMOUNT = "SWAP_TOKEN_SCREEN_SWAP_FIAT_AMOUNT"
}

View file

@ -17,6 +17,7 @@ import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldS
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
@ -67,11 +68,12 @@ import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.coroutineScope
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
@ -1289,12 +1291,14 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private suspend fun createEmptyAmountState(): SwapState {
val appCurrency = getSelectedAppCurrencyUseCase.unwrap()
return SwapState.EmptyAmountState(
zeroAmountEquivalent = BigDecimal.ZERO.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
zeroAmountEquivalent = stringReference(
BigDecimal.ZERO.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
)
}
@ -1523,7 +1527,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
tokenAmount = amount,
amountFiat = rates[fromToken.currency.id]?.multiply(amount.value)
amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
account = fromAccount,
@ -1687,7 +1691,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val rates = getQuotes(feeCurrencyId)
return rates[feeCurrencyId]?.let { rate ->
fees.map { fee ->
rate.multiply(fee).format {
rate.fiatRate.multiply(fee).format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
@ -1855,7 +1859,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
tokenAmount = amount,
amountFiat = rates[fromToken.currency.id]?.multiply(amount.value)
amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
account = fromAccount,
@ -1963,21 +1967,21 @@ internal class SwapInteractorImpl @AssistedInject constructor(
tokenAmount = fromTokenAmount,
account = fromAccount,
cryptoCurrencyStatus = fromTokenStatus,
amountFiat = rates[fromToken.id]?.multiply(fromTokenAmount.value)
amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value)
?: BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = toTokenAmount,
cryptoCurrencyStatus = toTokenStatus,
account = toAccount,
amountFiat = rates[toToken.id]?.multiply(toTokenAmount.value)
amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value)
?: BigDecimal.ZERO,
),
priceImpact = calculatePriceImpact(
fromTokenAmount = fromTokenAmount.value,
fromRate = rates[fromToken.id]?.toDouble() ?: 0.0,
fromQuoteStatus = rates[fromToken.id],
toTokenAmount = toTokenAmount.value,
toRate = rates[toToken.id]?.toDouble() ?: 0.0,
toRate = rates[toToken.id]?.fiatRate,
),
swapDataModel = swapData,
swapProvider = provider,
@ -2446,17 +2450,42 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private fun calculatePriceImpact(
fromTokenAmount: BigDecimal,
fromRate: Double,
fromQuoteStatus: QuoteStatus.Data?,
toTokenAmount: BigDecimal,
toRate: Double,
toRate: BigDecimal?,
): PriceImpact {
val fromTokenFiatValue = fromTokenAmount.multiply(fromRate.toBigDecimal())
val toTokenFiatValue = toTokenAmount.multiply(toRate.toBigDecimal())
val value = (BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP)).toFloat()
return PriceImpact.Value(value)
if (fromQuoteStatus == null) return PriceImpact.Empty
val fromTokenFiatValue = fromTokenAmount.multiply(fromQuoteStatus.fiatRate)
val toTokenFiatValue = toTokenAmount.multiply(toRate.orZero())
val value = BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP)
val fromAmountUSD = if (fromQuoteStatus.fiatRateUSD != BigDecimal.ZERO) {
fromTokenAmount.multiply(fromQuoteStatus.fiatRateUSD)
} else {
PRICE_IMPACT_AMOUNT_MIN_THRESHOLD
}
val amountSignificance = when {
fromAmountUSD < PRICE_IMPACT_AMOUNT_MIN_THRESHOLD -> PriceImpact.AmountSignificance.LOW
fromAmountUSD > PRICE_IMPACT_AMOUNT_MAX_THRESHOLD -> PriceImpact.AmountSignificance.HIGH
else -> PriceImpact.AmountSignificance.MEDIUM
}
val type = when {
value < PRICE_IMPACT_LOW_THRESHOLD -> PriceImpact.Type.LOW
value in PRICE_IMPACT_LOW_THRESHOLD..PRICE_IMPACT_HIGH_THRESHOLD -> PriceImpact.Type.MEDIUM
else -> PriceImpact.Type.HIGH
}
return PriceImpact(
value = value,
amountSignificance = amountSignificance,
type = type,
)
}
private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map<CryptoCurrency.ID, BigDecimal> {
private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map<CryptoCurrency.ID, QuoteStatus.Data> {
val set = ids.mapNotNullTo(destination = hashSetOf(), transform = CryptoCurrency.ID::rawCurrencyId)
.getQuotesOrEmpty()
@ -2465,7 +2494,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data }
?: return@mapNotNull null
id to (found.value as QuoteStatus.Data).fiatRate
id to found.value as QuoteStatus.Data
}
.toMap()
}
@ -2523,6 +2552,10 @@ internal class SwapInteractorImpl @AssistedInject constructor(
companion object {
private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12%
private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5%
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 50.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD
private val PRICE_IMPACT_LOW_THRESHOLD = 0.1.toBigDecimal() // 10%
private val PRICE_IMPACT_HIGH_THRESHOLD = 0.5.toBigDecimal() // 50%
private const val INFINITY_SYMBOL = ""
}

View file

@ -1,6 +1,8 @@
package com.tangem.feature.swap.domain.models.ui
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
@ -36,7 +38,7 @@ sealed interface SwapState {
val swapProvider: SwapProvider,
) : SwapState
data class EmptyAmountState(val zeroAmountEquivalent: String) : SwapState
data class EmptyAmountState(val zeroAmountEquivalent: TextReference) : SwapState
data class SwapError(
val fromTokenInfo: TokenSwapInfo,
@ -45,18 +47,31 @@ sealed interface SwapState {
) : SwapState
}
sealed class PriceImpact {
@Immutable
data class PriceImpact(
val value: BigDecimal,
val amountSignificance: AmountSignificance,
val type: Type,
) {
abstract val value: Float
enum class Type {
NONE, LOW, MEDIUM, HIGH
}
data class Empty(override val value: Float = 0f) : PriceImpact()
enum class AmountSignificance {
LOW, MEDIUM, HIGH
}
data class Value(override val value: Float) : PriceImpact()
fun getIntPercentValue() = (value * HUNDRED_PERCENTS).toInt()
fun shouldDisableButton(): Boolean {
return type == Type.HIGH && amountSignificance == AmountSignificance.HIGH
}
companion object {
private const val HUNDRED_PERCENTS = 100
val Empty = PriceImpact(
value = BigDecimal.ZERO,
amountSignificance = AmountSignificance.LOW,
type = Type.NONE,
)
}
}

View file

@ -1700,7 +1700,7 @@ internal class SwapModel @Inject constructor(
onReceiveCardWarningClick = {
val selectedProvider = dataState.selectedProvider ?: return@UiActions
val currencySymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return@UiActions
val isPriceImpact = uiState.priceImpact is PriceImpact.Value
val isPriceImpact = uiState.priceImpact.type != PriceImpact.Type.NONE
showSwapInfoAlert(isPriceImpact, currencySymbol, selectedProvider)
},
onLinkClick = urlOpener::openUrl,

View file

@ -118,6 +118,7 @@ internal class SwapNotificationsFactory(
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee)
maybeAddTransactionInProgressWarning(quoteModel)
maybeAddPriceImpactNotification(quoteModel.priceImpact)
}
return warnings.toPersistentList()
}
@ -143,6 +144,18 @@ internal class SwapNotificationsFactory(
}
}
private fun MutableList<NotificationUM>.maybeAddPriceImpactNotification(priceImpact: PriceImpact) {
if (priceImpact.amountSignificance == PriceImpact.AmountSignificance.LOW) return
val notification = when (priceImpact.type) {
PriceImpact.Type.HIGH -> SwapNotificationUM.Warning.TradeTooHigh
PriceImpact.Type.MEDIUM -> SwapNotificationUM.Warning.HighPriceImpact
else -> return
}
add(notification)
}
@Suppress("LongMethod")
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
quoteModel: SwapState.QuotesLoadedState,

View file

@ -1,6 +1,7 @@
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.bottomsheet.permission.state.GiveTxPermissionState
@ -47,7 +48,7 @@ sealed class SwapCardState {
data class SwapCardData(
@DrawableRes val networkIconRes: Int?,
val type: TransactionCardType,
val amountEquivalent: String?,
val amountEquivalent: TextReference?,
val token: CryptoCurrencyStatus?,
val coinId: String?,
val amountTextFieldValue: TextFieldValue?,
@ -61,7 +62,7 @@ sealed class SwapCardState {
data class Empty(
val type: TransactionCardType,
val amountEquivalent: String?,
val amountEquivalent: TextReference?,
val amountTextFieldValue: TextFieldValue?,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
@ -75,6 +76,7 @@ data class SwapButton(
val onClick: () -> Unit,
)
@Immutable
sealed interface TransactionCardType {
val accountTitleUM: AccountTitleUM?

View file

@ -194,6 +194,17 @@ internal object SwapNotificationUM {
onClick = onConfirmClick,
),
)
data object HighPriceImpact : Warning(
title = resourceReference(R.string.swapping_high_price_impact_title),
subtitle = resourceReference(R.string.swapping_high_price_impact_text),
)
data object TradeTooHigh : Warning(
title = resourceReference(R.string.swapping_trade_too_large_title),
subtitle = resourceReference(R.string.swapping_trade_too_large_text),
iconResId = R.drawable.ic_alert_circle_24,
)
}
sealed class Info(

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.ui
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
@ -12,10 +13,8 @@ import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.anyDecimals
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.core.ui.format.bigdecimal.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
@ -37,6 +36,7 @@ import com.tangem.feature.swap.models.states.*
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.StringsSigns.TILDE_SIGN
import kotlinx.collections.immutable.ImmutableList
@ -127,7 +127,7 @@ internal class StateBuilder(
onSuccess = actions.onSuccess,
providerState = ProviderState.Empty(),
shouldShowMaxAmount = false,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
isInsufficientFunds = false,
)
}
@ -141,7 +141,7 @@ internal class StateBuilder(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable),
amountTextFieldValue = null,
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = fromToken,
tokenIconUrl = fromToken.currency.iconUrl,
coinId = fromToken.currency.network.backendId,
@ -154,7 +154,7 @@ internal class StateBuilder(
),
receiveCardData = SwapCardState.Empty(
type = TransactionCardType.ReadOnly(),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
amountTextFieldValue = TextFieldValue(
text = "0",
),
@ -169,7 +169,7 @@ internal class StateBuilder(
onClick = { },
),
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -187,7 +187,7 @@ internal class StateBuilder(
accountTitleUM = getFromCardAccountTitle(fromAccount),
),
amountTextFieldValue = null,
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = fromToken,
tokenIconUrl = fromToken.currency.iconUrl,
coinId = fromToken.currency.network.backendId,
@ -205,7 +205,7 @@ internal class StateBuilder(
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = toToken,
tokenIconUrl = toToken.currency.iconUrl,
coinId = toToken.currency.network.backendId,
@ -226,7 +226,7 @@ internal class StateBuilder(
),
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
providerState = ProviderState.Empty(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -294,7 +294,7 @@ internal class StateBuilder(
providerState = ProviderState.Loading(),
permissionState = uiStateHolder.permissionState,
changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toToken),
)
}
@ -360,6 +360,7 @@ internal class StateBuilder(
)
}
}
val priceImpact = quoteModel.priceImpact
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = sendInput,
@ -386,7 +387,26 @@ internal class StateBuilder(
.formatToUIRepresentation()
.appendApproximateSign(),
),
amountEquivalent = getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat),
amountEquivalent = combinedReference(
getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat),
stringReference(StringsSigns.WHITE_SPACE),
styledStringReference(
priceImpact.value.format {
percent(withoutSign = false)
},
spanStyleReference = {
SpanStyle(
color = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
PriceImpact.Type.LOW,
PriceImpact.Type.NONE,
-> TangemTheme.colors.text.tertiary
},
)
},
),
),
token = toCurrencyStatus,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = toCurrencyStatus.currency.network.backendId,
@ -410,7 +430,7 @@ internal class StateBuilder(
fee = feeState,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = getSwapButtonEnabled(notifications),
isEnabled = getSwapButtonEnabled(notifications, priceImpact),
isHoldToConfirm = isHoldToConfirmEnabled,
onClick = actions.onSwapClick,
),
@ -424,11 +444,7 @@ internal class StateBuilder(
onProviderClick = actions.onProviderClick,
needApplyFCARestrictions = needApplyFCARestrictions,
),
priceImpact = if (quoteModel.priceImpact.value > PRICE_IMPACT_THRESHOLD) {
quoteModel.priceImpact
} else {
PriceImpact.Empty()
},
priceImpact = quoteModel.priceImpact,
tosState = createTosState(swapProvider),
shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrencyStatus.currency),
)
@ -462,7 +478,7 @@ internal class StateBuilder(
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
}
private fun getSwapButtonEnabled(notifications: ImmutableList<NotificationUM>): Boolean {
private fun getSwapButtonEnabled(notifications: ImmutableList<NotificationUM>, priceImpact: PriceImpact): Boolean {
return notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressError ||
@ -471,7 +487,7 @@ internal class StateBuilder(
notification is SwapNotificationUM.Warning.SwapNotSupported ||
notification is SwapNotificationUM.Warning.NeedReserveToCreateAccount ||
notification is SwapNotificationUM.Info.PermissionNeeded
}
} && !priceImpact.shouldDisableButton()
}
@Suppress("LongParameterList")
@ -510,7 +526,7 @@ internal class StateBuilder(
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = toToken,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = toToken.currency.network.backendId,
@ -523,7 +539,7 @@ internal class StateBuilder(
)
} ?: SwapCardState.Empty(
type = type,
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
amountTextFieldValue = TextFieldValue(
text = "0",
),
@ -546,7 +562,7 @@ internal class StateBuilder(
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
providerState = providerState,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
tosState = createTosState(swapProvider),
)
}
@ -644,7 +660,7 @@ internal class StateBuilder(
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
providerState = ProviderState.Empty(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -852,8 +868,8 @@ internal class StateBuilder(
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
toTokenFiatAmount = stringReference(toFiatAmount),
fromTokenFiatAmount = fromFiatAmount,
toTokenFiatAmount = toFiatAmount,
fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency),
toTokenIconState = iconStateConverter.convert(toCryptoCurrency),
onExploreButtonClick = onExploreClick,
@ -892,8 +908,8 @@ internal class StateBuilder(
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
toTokenFiatAmount = stringReference(toFiatAmount),
fromTokenFiatAmount = fromFiatAmount,
toTokenFiatAmount = toFiatAmount,
fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency),
toTokenIconState = iconStateConverter.convert(toCryptoCurrency),
onExploreButtonClick = onExploreClick,
@ -1267,15 +1283,17 @@ internal class StateBuilder(
return amount.format { crypto(symbol, currency.decimals) }
}
private fun getFormattedFiatAmount(amount: BigDecimal?): String {
private fun getFormattedFiatAmount(amount: BigDecimal?): TextReference {
val appCurrency = appCurrencyProvider()
return amount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
return stringReference(
amount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
)
}
private fun SwapAmount.getFormattedCryptoAmount(token: CryptoCurrency): String {
@ -1339,7 +1357,6 @@ internal class StateBuilder(
const val ADDRESS_MIN_LENGTH = 11
const val ADDRESS_FIRST_PART_LENGTH = 7
const val ADDRESS_SECOND_PART_LENGTH = 4
private const val PRICE_IMPACT_THRESHOLD = 0.1
private const val MAX_DECIMALS_TO_SHOW = 8
private const val IF_ZERO_DECIMALS_TO_SHOW = 2
private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/"

View file

@ -346,6 +346,7 @@ private fun SwapNotifications(notifications: List<NotificationUM>) {
Notification(
config = notification.config,
iconTint = when (notification) {
is SwapNotificationUM.Warning.TradeTooHigh -> TangemTheme.colors.icon.warning
is SwapNotificationUM.Error.UnableToCoverFeeWarning,
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Error.ExceedsBalance,
@ -412,7 +413,7 @@ private val sendCard = SwapCardState.SwapCardData(
accountTitleUM = null,
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
@ -427,7 +428,7 @@ private val sendCard = SwapCardState.SwapCardData(
private val receiveCard = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
@ -466,7 +467,7 @@ private val state = SwapStateHolder(
permissionState = GiveTxPermissionState.InProgress,
blockchainId = "POLYGON",
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
shouldShowMaxAmount = true,
isInsufficientFunds = false,
onSuccess = {},

View file

@ -26,9 +26,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -41,8 +39,7 @@ import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
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.test.SwapTokenScreenTestTags
@ -59,7 +56,7 @@ fun TransactionCard(
balance: String,
tokenIconUrl: String,
tokenCurrency: String,
amountEquivalent: String?,
amountEquivalent: TextReference?,
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
modifier: Modifier = Modifier,
@ -129,7 +126,7 @@ fun TransactionCard(
@Composable
fun TransactionCardEmpty(
type: TransactionCardType,
amountEquivalent: String?,
amountEquivalent: TextReference?,
textFieldValue: TextFieldValue?,
modifier: Modifier = Modifier,
onChangeTokenClick: (() -> Unit)? = null,
@ -157,7 +154,7 @@ fun TransactionCardEmpty(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -250,7 +247,7 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
@Composable
private fun Content(
type: TransactionCardType,
amountEquivalent: String?,
amountEquivalent: TextReference?,
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
) {
@ -320,27 +317,13 @@ private fun Content(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
verticalAlignment = Alignment.CenterVertically,
) {
if (priceImpact is PriceImpact.Value) {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = makePriceImpactBalanceWarning(
amountEquivalent,
priceImpact.getIntPercentValue(),
),
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(
SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_WITH_PRICE_IMPACT_WARNING,
),
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
} else {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
}
}
if (type.shouldShowWarning) {
SpacerW4()
@ -353,10 +336,10 @@ private fun Content(
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = if (priceImpact is PriceImpact.Value) {
TangemTheme.colors.text.attention
} else {
TangemTheme.colors.text.tertiary
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)
@ -368,7 +351,7 @@ private fun Content(
} else {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount,
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
@ -530,19 +513,6 @@ fun ChangeTokenSelector() {
}
}
@Composable
private fun makePriceImpactBalanceWarning(value: String, priceImpactPercents: Int): AnnotatedString {
val fullValue = "$value (-$priceImpactPercents%)"
return buildAnnotatedString {
append(fullValue)
addStyle(
style = SpanStyle(color = TangemTheme.colors.text.attention),
start = value.length,
end = fullValue.length,
)
}
}
// region preview
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@ -602,14 +572,14 @@ private fun TransactionCardPreview() {
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -625,14 +595,24 @@ private fun TransactionCardPreviewWithPriceImpact() {
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = "1 000 000",
amountEquivalent = combinedReference(
stringReference("1 000 000 $"),
styledStringReference(
" (-15%)",
{ SpanStyle(color = TangemTheme.colors.text.attention) },
),
),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue("1000000.0000000000000000000000000"),
priceImpact = PriceImpact.Value(0.15F),
priceImpact = PriceImpact(
value = 0.15F.toBigDecimal(),
type = PriceImpact.Type.MEDIUM,
amountSignificance = PriceImpact.AmountSignificance.HIGH,
),
)
}
@ -647,14 +627,14 @@ private fun TransactionCardPreviewWithoutPriceImpact() {
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}