diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 72e7efe111..5132421c98 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -694,7 +694,7 @@ Lege die Karte oder Ring an Deine Wallet ist eingerichtet und einsatzbereit! Wallet erfolgreich importiert - Wiederherstellung %d% + Wiederherstellung %d%% Du hast deine biometrischen Daten aktualisiert, scanne deine Karte oder Ring, um einzutreten Dein Guthaben sollte höher sein als der Gebührenwert, um eine Überweisung zu tätigen Unzureichende Mittel diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index e4a52817fd..6dadf3d9d5 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -689,7 +689,7 @@ カードまたはリングをタップ ウォレットは同期され、準備ができています。\n一部のトークンが見つかりませんか? ウォレットのインポートが完了しました - %d%を復元中 + %d%%を復元中 生体認証を更新しました。カードまたはリングをスキャンして入ってください。 送金するには、残高が手数料額より高くなければなりません。 残高不足 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index de83ee0e8f..c6642f4bfa 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -306,6 +306,7 @@ hora Importar Em andamento + Saldo insuficiente Mais tarde Saber mais %1$s falta @@ -692,6 +693,9 @@ Toque para digitalizar Toque para assinar Toque no Cartão ou no Anel + Sua carteira está sincronizada e pronta. \n Faltam alguns tokens? + Carteira importada com sucesso. + Restaurando %d%% Você atualizou seus dados biométricos. Escaneie seu cartão ou toque a campainha para entrar. Seu saldo deve ser superior ao valor da taxa para efetuar uma transferência. Saldo insuficiente @@ -752,6 +756,7 @@ Resultado Veja tokens com capitalização de mercado inferior a 100 mil dólares. Mostrar tokens + Criptomoedas, notícias e muito mais Nenhum resultado Selecione a rede Selecione a carteira @@ -824,9 +829,11 @@ Posição no ranking de criptomoedas entre todas as moedas com base na capitalização de mercado. Posicionamento de mercado Fornecimento máximo + Circulação e Fornecimento Máximo O número máximo de moedas ou tokens que podem existir para uma determinada criptomoeda. Fornecimento máximo Métricas + Sem limite Links oficiais Desempenho de preço Repositório @@ -1101,6 +1108,12 @@ Configurações Você não concedeu acesso à sua câmera. Acesso à câmera negado + O token solicitado não foi adicionado à sua carteira. Adicione-o e tente novamente. + Token não adicionado + Desculpe, este código QR não pôde ser reconhecido. + Código QR não reconhecido + Esta rede não é compatível com nenhum dos tokens adicionados. Adicione um token compatível para enviar criptomoedas. + Nenhum token compatível encontrado Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede @@ -1246,6 +1259,11 @@ Limitação de transação Opcional Alinhe o seu código QR com o quadrado para escaneá-lo. Certifique-se de escanear corretamente. %s endereço de rede. + 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. + Taxa fixa + 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. + Taxa flutuante + A taxa é fixa. Recente Destinatário Endereço inválido @@ -1289,6 +1307,7 @@ O destinatário recebe %s Tem certeza de que deseja cancelar a conversão? Seus dados anteriores serão apagados. Remover conversão + Algo deu errado. Tente novamente. Enviar com troca Transação enviada Prepare-se para escanear o Cartão ou Anel que deseja configurar. @@ -1482,6 +1501,7 @@ Trocar essa quantidade de tokens selecionados causará um impacto significativo no preço e reduzirá seu resultado. Alto impacto nos preços Fundos insuficientes + Não há fundos suficientes para concluir esta transação. Reduza o valor a receber ou adicione mais fundos. Conceder permissão Trocar Trocar... @@ -1648,6 +1668,7 @@ Token em %%imagem%% %1$s rede 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 Não foi possível ocultar %s + N/A Mostrar código QR Troque este token por outro em %1$s taxas de serviço a partir de fevereiro %2$s-%3$s. Trocar com Changelly, %s tarifas diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index be6b9c60c8..9a07214403 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -6,7 +6,7 @@ 解鎖 您的錢包已同步並準備就緒。\n缺少某些代幣? 錢包導入成功 - 恢復%d% + 恢復%d%% diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 1823294875..d4a0a1480f 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -696,7 +696,7 @@ Tap the card or ring Your wallet is synced and ready.\nSome tokens missing? Wallet successfully imported - Restoring %d% + Restoring %d%% You have updated biometrics, scan your card or ring to enter Your balance should be higher than the fee value to make a transfer Not enough balance @@ -1500,6 +1500,7 @@ Fee estimation error. Please send feedback to support. You swap Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome. + You may receive significantly less due to low liquidity. Try a smaller amount or another provider. High price impact Insufficient funds Not enough funds to complete this transaction. Reduce the amount to receive or add more funds. @@ -1509,6 +1510,8 @@ You receive Choose token not available + Not enough liquidity for this trade.\nReduce the amount or choose another provider. + Trade too large We would be happy to receive your feedback Tangem Pay is now in beta Card frozen diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt index 726604b2b1..2a200563fa 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt @@ -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" } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 14b10c2aad..2a22cca812 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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 { + private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map { 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 = "∞" } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index c735c821d4..fa1c9bdf73 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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, + ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 789816698c..53cf08c449 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 5bf13c6e4d..6586b11550 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -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.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.maybeAddDomainWarnings( quoteModel: SwapState.QuotesLoadedState, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index f3d9a7bc7d..0afbd2da4b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -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? diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index e158fd5f3b..96a0293a82 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -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( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index e65fefdf6c..6ba8b6a6e4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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): Boolean { + private fun getSwapButtonEnabled(notifications: ImmutableList, 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/" diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 2fdde2b04a..10d12201f0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -346,6 +346,7 @@ private fun SwapNotifications(notifications: List) { 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 = {}, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index b29e4b8257..1886f3effd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -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, ) }