Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-23 19:32:54 +07:00
parent b4ae0c1547
commit e43f438c1a
32 changed files with 971 additions and 331 deletions

View file

@ -62,6 +62,7 @@ fun AmountFieldV2(
onValuePastedTriggerDismiss: () -> Unit,
onCurrencyChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
reserveSpaceForError: Boolean = true,
) {
val decimalFormat = rememberDecimalFormat()
@ -120,12 +121,13 @@ fun AmountFieldV2(
AmountSecondary(
amountUM = amountUM,
onCurrencyChange = onCurrencyChange,
reserveSpaceForError = reserveSpaceForError,
)
}
}
@Composable
private fun AmountSecondary(amountUM: AmountState, onCurrencyChange: (Boolean) -> Unit) {
private fun AmountSecondary(amountUM: AmountState, onCurrencyChange: (Boolean) -> Unit, reserveSpaceForError: Boolean) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -144,29 +146,33 @@ private fun AmountSecondary(amountUM: AmountState, onCurrencyChange: (Boolean) -
AmountFieldCurrencyInfo(
amountUM = amountUM,
onCurrencyChange = onCurrencyChange,
modifier = if (reserveSpaceForError) Modifier.padding(bottom = 16.dp) else Modifier,
)
AmountFieldError(
isError = amountUM.amountTextField.isError,
isWarning = amountUM.amountTextField.isWarning,
error = amountUM.amountTextField.error,
reserveSpaceForError = reserveSpaceForError,
modifier = Modifier
.align(BottomCenter)
.padding(top = 24.dp),
.align(BottomCenter),
)
}
}
}
@Composable
private fun BoxScope.AmountFieldCurrencyInfo(amountUM: AmountState.Data, onCurrencyChange: (Boolean) -> Unit) {
private fun BoxScope.AmountFieldCurrencyInfo(
amountUM: AmountState.Data,
onCurrencyChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val isFiatAvailable = amountUM.amountTextField.fiatAmount.value != null
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
modifier = modifier
.align(TopCenter)
.padding(bottom = 16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@ -255,13 +261,14 @@ private fun AmountFieldError(
isError: Boolean,
isWarning: Boolean,
error: TextReference,
reserveSpaceForError: Boolean,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isError || isWarning,
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier,
modifier = if (reserveSpaceForError) modifier.padding(top = 24.dp) else modifier,
label = "Error field appearance animation",
) {
val errorText = remember(this, error) { error }
@ -271,7 +278,13 @@ private fun AmountFieldError(
style = TangemTheme.typography.caption2,
color = color,
textAlign = TextAlign.Center,
modifier = Modifier.testTag(SendScreenTestTags.AMOUNT_ERROR_TEXT),
modifier = if (reserveSpaceForError) {
Modifier.testTag(SendScreenTestTags.AMOUNT_ERROR_TEXT)
} else {
Modifier
.padding(top = 24.dp)
.testTag(SendScreenTestTags.AMOUNT_ERROR_TEXT)
},
)
}
}
@ -324,6 +337,7 @@ private fun AmountFieldV2_Preview(@PreviewParameter(AmountFieldV2PreviewProvider
onValueChange = {},
onValuePastedTriggerDismiss = { },
onCurrencyChange = {},
reserveSpaceForError = false,
modifier = Modifier.background(TangemTheme.colors.background.action),
)
}

View file

@ -40,13 +40,13 @@ internal fun String.checkExceedBalance(
maxEnterAmount: EnterAmountBoundary,
amountTextField: AmountFieldModel,
): Boolean {
val currencyCryptoAmount = maxEnterAmount.amount ?: BigDecimal.ZERO
val currencyFiatAmount = maxEnterAmount.fiatAmount ?: BigDecimal.ZERO
val fiatDecimal = parseToBigDecimal(amountTextField.fiatAmount.decimals)
val cryptoDecimal = parseToBigDecimal(amountTextField.cryptoAmount.decimals)
return if (amountTextField.isFiatValue) {
val currencyFiatAmount = maxEnterAmount.fiatAmount ?: return false
fiatDecimal > currencyFiatAmount
} else {
val currencyCryptoAmount = maxEnterAmount.amount ?: return false
cryptoDecimal > currencyCryptoAmount
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="10dp"
android:height="10dp"
android:viewportWidth="10"
android:viewportHeight="10">
<path
android:pathData="M7.834,5.222C7.834,4.946 7.61,4.722 7.334,4.722H2.667C2.391,4.722 2.167,4.946 2.167,5.222V7.834C2.167,8.11 2.391,8.334 2.667,8.334H7.334C7.61,8.333 7.834,8.11 7.834,7.834V5.222ZM6.444,3.111C6.444,2.313 5.798,1.667 5,1.667C4.202,1.667 3.556,2.313 3.556,3.111V3.722H6.444V3.111ZM7.444,3.727C8.221,3.784 8.834,4.431 8.834,5.222V7.834C8.834,8.662 8.162,9.333 7.334,9.334H2.667C1.839,9.334 1.167,8.662 1.167,7.834V5.222C1.167,4.431 1.779,3.784 2.556,3.727V3.111C2.556,1.761 3.65,0.667 5,0.667C6.35,0.667 7.444,1.761 7.444,3.111V3.727Z"
android:fillColor="#919191"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="10dp"
android:height="11dp"
android:viewportWidth="10"
android:viewportHeight="11">
<path
android:pathData="M3.639,4.008C4.256,3.907 4.849,4.154 5.411,4.716C5.825,5.129 6.112,5.177 6.314,5.144C6.562,5.103 6.848,4.92 7.198,4.569C7.393,4.374 7.71,4.374 7.905,4.569C8.1,4.765 8.1,5.081 7.905,5.276C7.522,5.659 7.048,6.037 6.476,6.131C5.859,6.231 5.266,5.984 4.704,5.423C4.29,5.009 4.003,4.962 3.8,4.995C3.552,5.036 3.267,5.219 2.916,5.569C2.721,5.764 2.404,5.764 2.209,5.569C2.014,5.374 2.014,5.057 2.209,4.862C2.592,4.479 3.066,4.101 3.639,4.008Z"
android:fillColor="#919191"/>
<path
android:pathData="M5,0C7.761,0 10,2.239 10,5C10,7.761 7.761,10 5,10C2.239,10 0,7.761 0,5C0,2.239 2.239,0 5,0ZM5,1C2.791,1 1,2.791 1,5C1,7.209 2.791,9 5,9C7.209,9 9,7.209 9,5C9,2.791 7.209,1 5,1Z"
android:fillColor="#919191"
android:fillType="evenOdd"/>
</vector>

View file

@ -15,6 +15,8 @@ import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.request.ExchangeSentRequestBody
import com.tangem.datasource.api.express.models.request.PairsRequestBody
import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails
import com.tangem.datasource.api.express.models.response.RateType
import com.tangem.datasource.api.express.models.response.SwapPairProvider
import com.tangem.datasource.api.express.models.response.TxDetails
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
@ -98,8 +100,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
}
val mappedProviders = pair.providers
.filterNot { it.hasOnlyFixedRateType() }
.mapNotNull { expressProviders[it.providerId] }
.mapNotNull { it.withExpressProvider(expressProviders) }
.filterYieldSupplyProvider(statusFrom)
if (statusFrom != null && statusTo != null && mappedProviders.isNotEmpty()) {
@ -157,8 +158,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
val currencyStatusTo = createSendWithSwapCryptoCurrencyStatus(statusToDeferred.await())
val mappedProvider = pair.providers
.filterNot { it.hasOnlyFixedRateType() }
.mapNotNull { mappedProviders[it.providerId] }
.mapNotNull { it.withExpressProvider(mappedProviders) }
.filterYieldSupplyProvider(currencyStatusFrom)
if (currencyStatusFrom != null && currencyStatusTo != null && mappedProvider.isNotEmpty()) {
@ -443,6 +443,18 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
}
}
private fun SwapPairProvider.withExpressProvider(
expressProviders: Map<String, ExpressProvider>,
): ExpressProvider? {
val provider = expressProviders[providerId] ?: return null
return provider.copy(rateTypes = rateTypes.map { it.toExpressRateType() })
}
private fun RateType.toExpressRateType(): ExpressRateType = when (this) {
RateType.FLOAT -> ExpressRateType.Float
RateType.FIXED -> ExpressRateType.Fixed
}
private fun CryptoCurrency.getContractAddress(): String {
return when (this) {
is CryptoCurrency.Token -> this.contractAddress

View file

@ -8,7 +8,7 @@ import java.math.BigDecimal
*
* @property provider swap provider
* @property toTokenAmount amount of token you want to receive
* @property fromTokenAmount amount of from-token required (for fixed rate quotes)
* @property fromTokenAmount amount of from-token required (only set for fixed rate quotes)
* @property allowanceContract whether swap occurs via third token
*/
data class SwapQuoteModel(

View file

@ -10,6 +10,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencies
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.swap.models.SwapRateMode
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import kotlinx.collections.immutable.ImmutableList
@ -44,6 +45,7 @@ internal sealed class SwapAmountUM {
// selected swap route
val swapRateType: ExpressRateType,
val swapRateMode: SwapRateMode,
// swap models
val swapCurrencies: SwapCurrencies,

View file

@ -8,6 +8,7 @@ internal interface SwapAmountClickIntents : AmountScreenClickIntents {
fun onExpandEditField(selectedAmountType: SwapAmountType)
fun onInfoClick()
fun onSelectTokenClick()
fun onRateClick()
fun onSeparatorClick()
fun onProviderClick()
}

View file

@ -16,17 +16,18 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.datasource.local.swap.SwapBestRateAnimationStore
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.notifications.ShouldShowNotificationUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.swap.models.*
import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection
import com.tangem.domain.swap.usecase.GetSwapQuoteUseCase
@ -172,17 +173,43 @@ internal class SwapAmountModel @Inject constructor(
secondaryMaximumAmountBoundary = secondaryMaximumAmountBoundary,
secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary,
isNeedApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
isBalanceHidden = params.isBalanceHidingFlow.value,
primaryMaximumAmountBoundary = primaryMaximumAmountBoundary,
primaryMinimumAmountBoundary = primaryMinimumAmountBoundary,
),
)
}
override fun onExpandEditField(selectedAmountType: SwapAmountType) {
val content = uiState.value as? SwapAmountUM.Content ?: return
val newSwapRateType = when (selectedAmountType) {
SwapAmountType.To -> {
if (content.swapRateMode != SwapRateMode.FLOAT_ONLY) {
ExpressRateType.Fixed
} else {
return
}
}
SwapAmountType.From -> {
if (content.swapRateMode == SwapRateMode.FIXED_ONLY) {
ExpressRateType.Fixed
} else if (content.swapRateMode == SwapRateMode.FLOAT_AND_FIXED) {
ExpressRateType.Float
} else {
return
}
}
}
uiState.update { amountUM ->
if (amountUM !is SwapAmountUM.Content) return
if (amountUM !is SwapAmountUM.Content) return@update amountUM
amountUM.copy(
selectedAmountType = selectedAmountType,
swapRateType = newSwapRateType,
)
}
startLoadingQuotesTask(isSilentReload = false)
}
override fun onInfoClick() {
@ -290,6 +317,10 @@ internal class SwapAmountModel @Inject constructor(
}
}
override fun onRateClick() {
// TODO [REDACTED_TASK_KEY]
}
override fun onSeparatorClick() {
val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return
@ -335,17 +366,9 @@ internal class SwapAmountModel @Inject constructor(
private fun subscribeOnBalanceHiddenUpdates() {
params.isBalanceHidingFlow.onEach { isHidden ->
val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1
uiState.transformerUpdate(
SwapAmountBalanceHiddenTransformer(
isBalanceHidden = isHidden,
isSingleWallet = isOnlyOneWallet,
userWallet = userWallet,
appCurrency = appCurrency,
swapDirection = swapDirection,
clickIntents = this,
isAccountsMode = params.isAccountModeFlow.value,
account = params.accountFlow.value,
),
)
}.launchIn(modelScope)
@ -525,16 +548,14 @@ internal class SwapAmountModel @Inject constructor(
private fun initPairs(swapCurrencies: SwapCurrencies, secondaryCryptoCurrency: CryptoCurrency?) {
modelScope.launch {
val swapCryptoCurrency = selectInitialPairUseCase(
val secondaryCurrency = selectInitialPairUseCase(
primaryCryptoCurrency = primaryCryptoCurrency,
secondaryCryptoCurrency = secondaryCryptoCurrency,
userWallet = userWallet,
swapCurrencies = swapCurrencies,
swapDirection = params.swapDirection,
)
val secondaryStatus = swapCryptoCurrency?.currencyStatus
val secondaryStatus = secondaryCurrency?.currencyStatus
val primaryStatus = (uiState.value as? SwapAmountUM.Content)?.primaryCryptoCurrencyStatus
if (secondaryStatus != null && primaryStatus != null) {
initCurrencies(primaryStatus, secondaryStatus)
@ -553,8 +574,14 @@ internal class SwapAmountModel @Inject constructor(
isSingleWallet = isOnlyOneWallet,
isAccountsMode = params.isAccountModeFlow.value,
account = params.accountFlow.value,
providers = secondaryCurrency.providers,
),
)
val currentState = uiState.value as? SwapAmountUM.Content
if (currentState != null && currentState.swapRateMode != SwapRateMode.FLOAT_ONLY) {
computeAndSetSecondaryAmount(currentState)
}
startLoadingQuotesTask(isSilentReload = false)
} else {
@Suppress("NullableToStringCall")
@ -592,7 +619,11 @@ internal class SwapAmountModel @Inject constructor(
fiatRate = secondaryStatus.value.fiatRate,
fiatAmount = secondaryStatus.value.fiatAmount,
)
secondaryMaximumAmountBoundary = MaxEnterAmountConverter().convert(secondaryStatus)
secondaryMaximumAmountBoundary = EnterAmountBoundary(
amount = null,
fiatRate = secondaryStatus.value.fiatRate,
fiatAmount = null,
)
}
}
@ -606,18 +637,39 @@ internal class SwapAmountModel @Inject constructor(
onReverse = { state.secondaryCryptoCurrencyStatus.currency to state.primaryCryptoCurrencyStatus.currency },
)
val fromAmount = when (state.swapDirection) {
SwapDirection.Direct -> state.primaryAmount.amountField
SwapDirection.Reverse -> state.secondaryAmount.amountField
} as? AmountState.Data
val amountField = state.swapDirection.withSwapDirection(
onDirect = {
if (state.selectedAmountType == SwapAmountType.From) {
state.primaryAmount.amountField
} else {
state.secondaryAmount.amountField
}
},
onReverse = {
if (state.selectedAmountType == SwapAmountType.From) {
state.secondaryAmount.amountField
} else {
state.primaryAmount.amountField
}
},
) as? AmountState.Data
val fromAmountValue = fromAmount?.amountTextField?.cryptoAmount?.value.orZero()
val amountValue = amountField?.amountTextField?.cryptoAmount?.value.orZero()
val isAmountScreen = params is SwapAmountComponentParams.AmountParams
val isAmountError = fromAmount?.amountTextField?.isError == true || fromAmountValue.isNullOrZero()
val isAmountError = amountField?.amountTextField?.isError == true || amountValue.isNullOrZero()
if (isAmountScreen && isAmountError) {
uiState.transformerUpdate(SwapQuoteEmptyStateTransformer); return
}
val rateType = when (state.selectedAmountType) {
SwapAmountType.To -> ExpressRateType.Fixed
SwapAmountType.From -> if (state.swapRateMode == SwapRateMode.FIXED_ONLY) {
ExpressRateType.Fixed
} else {
ExpressRateType.Float
}
}
if (!isSilentReload) uiState.transformerUpdate(SwapQuoteLoadingStateTransformer)
modelScope.launch {
@ -625,6 +677,7 @@ internal class SwapAmountModel @Inject constructor(
.asSequence()
.filter { swapCurrencyStatus -> swapCurrencyStatus.currencyStatus.currency.id == toCryptoCurrency.id }
.flatMap(SwapCryptoCurrency::providers)
.filter { provider -> provider.rateTypes.contains(rateType) }
.toList()
.map { provider ->
async {
@ -632,9 +685,9 @@ internal class SwapAmountModel @Inject constructor(
userWallet = userWallet,
fromCryptoCurrency = fromCryptoCurrency,
toCryptoCurrency = toCryptoCurrency,
amount = fromAmountValue,
amountType = SwapAmountType.From,
rateType = ExpressRateType.Float,
amount = amountValue,
amountType = state.selectedAmountType,
rateType = rateType,
provider = provider,
).fold(
ifLeft = { error ->
@ -650,7 +703,7 @@ internal class SwapAmountModel @Inject constructor(
swapDirection = swapDirection,
allowanceContract = quote.allowanceContract,
isApprovalNeeded = checkAllowance(state, quote),
fromAmount = fromAmountValue,
fromAmount = amountValue,
).convert(
SwapQuoteUMConverter.Data(
quote = quote,
@ -669,12 +722,48 @@ internal class SwapAmountModel @Inject constructor(
secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary,
isSilentReload = isSilentReload,
isNeedApplyFcaRestrictions = userCountry.needApplyFCARestrictions(),
isBalanceHidden = params.isBalanceHidingFlow.value,
primaryMaximumAmountBoundary = primaryMaximumAmountBoundary,
primaryMinimumAmountBoundary = primaryMinimumAmountBoundary,
),
)
feeSelectorReloadTrigger.triggerUpdate()
}
}
/**
* Compute secondary amount from primary amount using fiat rate conversion
* secondaryAmount = primaryAmount × primaryFiatRate / secondaryFiatRate
*
*/
private fun computeAndSetSecondaryAmount(state: SwapAmountUM.Content) {
val secondaryStatus = state.secondaryCryptoCurrencyStatus ?: return
val primaryFiatRate = state.primaryCryptoCurrencyStatus.value.fiatRate ?: return
val secondaryFiatRate = secondaryStatus.value.fiatRate ?: return
if (secondaryFiatRate <= BigDecimal.ZERO) return
val primaryAmountField = (state.primaryAmount as? SwapAmountFieldUM.Content)
?.amountField as? AmountState.Data ?: return
val primaryAmount = primaryAmountField.amountTextField.cryptoAmount.value ?: return
if (primaryAmount <= BigDecimal.ZERO) return
val secondaryAmount = primaryAmount
.multiply(primaryFiatRate)
.divide(secondaryFiatRate, secondaryStatus.currency.decimals, java.math.RoundingMode.HALF_UP)
val secondaryValue = secondaryAmount.parseBigDecimal(secondaryStatus.currency.decimals)
uiState.transformerUpdate(
SwapAmountValueChangeTransformer(
primaryMaximumAmountBoundary = primaryMaximumAmountBoundary,
secondaryMaximumAmountBoundary = secondaryMaximumAmountBoundary,
primaryMinimumAmountBoundary = primaryMinimumAmountBoundary,
secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary,
value = secondaryValue,
),
)
}
private fun startLoadingQuotesTask(isSilentReload: Boolean) {
quoteTaskScheduler.cancelTask()
loadQuotes(isSilentReload = isSilentReload)

View file

@ -58,9 +58,9 @@ internal object SwapAmountQuoteUtils {
): SwapAmountUM {
if (this !is SwapAmountUM.Content) return this
val updatedAmountField = if (
selectedAmountType == SwapAmountType.From && swapDirection == SwapDirection.Direct
) {
val isPrimaryFieldEdited = selectedAmountType == SwapAmountType.From && swapDirection == SwapDirection.Direct
val updatedAmountField = if (isPrimaryFieldEdited) {
val amountFieldUM = primaryAmount as? SwapAmountFieldUM.Content ?: return this
amountFieldUM.onPrimaryAmount(primaryCryptoCurrencyStatus)
} else {
@ -68,9 +68,9 @@ internal object SwapAmountQuoteUtils {
val amountFieldUM = secondaryAmount as? SwapAmountFieldUM.Content ?: return this
amountFieldUM.onSecondaryAmount(secondaryCryptoCurrencyStatus)
}
return copy(
primaryAmount = updatedAmountField,
primaryAmount = if (isPrimaryFieldEdited) updatedAmountField else primaryAmount,
secondaryAmount = if (isPrimaryFieldEdited) secondaryAmount else updatedAmountField,
)
}
}

View file

@ -5,12 +5,8 @@ import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter
import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -19,7 +15,7 @@ import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.utils.StringsSigns.DOT
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SwapAmountFieldConverter(
@ -36,24 +32,34 @@ internal class SwapAmountFieldConverter(
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
private val maxEnterAmountConverter = MaxEnterAmountConverter()
fun convert(selectedType: SwapAmountType, cryptoCurrencyStatus: CryptoCurrencyStatus): SwapAmountFieldUM {
fun convert(
swapAmountType: SwapAmountType,
cryptoCurrencyStatus: CryptoCurrencyStatus,
isSelected: Boolean,
isAmountEmpty: Boolean = true,
displayAmount: BigDecimal? = null,
): SwapAmountFieldUM {
val walletTitle = if (isSingleWallet) {
resourceReference(R.string.send_from_title)
} else {
resourceReference(R.string.send_from_wallet_name, wrappedList(userWallet.name))
}
val subtitles = computeSubtitles(
swapAmountType = swapAmountType,
cryptoCurrencyStatus = cryptoCurrencyStatus,
isEntering = isSelected,
isAmountEmpty = isAmountEmpty,
displayAmount = displayAmount,
)
return SwapAmountFieldUM.Content(
amountType = selectedType,
amountType = swapAmountType,
title = stringReference(cryptoCurrencyStatus.currency.name),
subtitleLeft = getSubtitleLeft(selectedType = selectedType, cryptoCurrencyStatus = cryptoCurrencyStatus),
subtitleEllipsisLeft = getSubtitleEllipsisLeft(
selectedType = selectedType,
cryptoCurrencyStatus = cryptoCurrencyStatus,
),
subtitleRight = getSubtitleRight(selectedType = selectedType, cryptoCurrencyStatus = cryptoCurrencyStatus),
subtitleEllipsisRight = TextEllipsis.OffsetEnd(appCurrency.symbol.length),
subtitleLeft = subtitles.subtitleLeft,
subtitleEllipsisLeft = subtitles.subtitleEllipsisLeft,
subtitleRight = subtitles.subtitleRight,
subtitleEllipsisRight = subtitles.subtitleEllipsisRight,
priceImpact = null,
isClickEnabled = selectedType.isViewingField(),
isClickEnabled = true,
amountField = AmountStateConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
@ -65,8 +71,8 @@ internal class SwapAmountFieldConverter(
isAccountsMode = isAccountsMode,
walletTitle = walletTitle,
prefixText = when {
selectedType.isEnteringField() -> resourceReference(R.string.common_from)
selectedType.isViewingField() -> resourceReference(R.string.common_to)
swapAmountType.isEnteringField() -> resourceReference(R.string.common_from)
swapAmountType.isViewingField() -> resourceReference(R.string.common_to)
else -> TextReference.Companion.EMPTY
},
).convert(account),
@ -79,44 +85,29 @@ internal class SwapAmountFieldConverter(
)
}
private fun getSubtitleLeft(selectedType: SwapAmountType, cryptoCurrencyStatus: CryptoCurrencyStatus) = when {
selectedType.isEnteringField() -> combinedReference(
stringReference(
cryptoCurrencyStatus.value.amount.format {
crypto(cryptoCurrency = cryptoCurrencyStatus.currency)
},
),
).orMaskWithStars(isBalanceHidden)
selectedType.isViewingField() -> resourceReference(R.string.send_with_swap_recipient_get_amount)
else -> TextReference.Companion.EMPTY
private fun computeSubtitles(
swapAmountType: SwapAmountType,
cryptoCurrencyStatus: CryptoCurrencyStatus,
isEntering: Boolean,
isAmountEmpty: Boolean,
displayAmount: BigDecimal?,
): SwapSubtitleResult = when (swapAmountType) {
SwapAmountType.From -> SwapFromSubtitleConverter.convert(
cryptoCurrencyStatus = cryptoCurrencyStatus,
isBalanceHidden = isBalanceHidden,
isEntering = isEntering,
isAmountEmpty = isAmountEmpty,
displayAmount = displayAmount,
)
SwapAmountType.To -> SwapToSubtitleConverter.convert(
cryptoCurrencyStatus = cryptoCurrencyStatus,
isBalanceHidden = isBalanceHidden,
isEntering = isEntering,
isAmountEmpty = isAmountEmpty,
displayAmount = displayAmount,
)
}
private fun getSubtitleRight(selectedType: SwapAmountType, cryptoCurrencyStatus: CryptoCurrencyStatus) = when {
selectedType.isEnteringField() -> if (isBalanceHidden) {
TextReference.EMPTY
} else {
combinedReference(
stringReference(value = " $DOT "),
stringReference(
cryptoCurrencyStatus.value.fiatAmount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
)
}
else -> TextReference.EMPTY
}
private fun getSubtitleEllipsisLeft(selectedType: SwapAmountType, cryptoCurrencyStatus: CryptoCurrencyStatus) =
when {
selectedType.isEnteringField() -> TextEllipsis.OffsetEnd(cryptoCurrencyStatus.currency.symbol.length)
selectedType.isViewingField() -> TextEllipsis.End
else -> TextEllipsis.End
}
private fun SwapAmountType.isEnteringField(): Boolean {
return this == SwapAmountType.From && swapDirection == SwapDirection.Direct ||
this == SwapAmountType.To && swapDirection == SwapDirection.Reverse

View file

@ -0,0 +1,63 @@
package com.tangem.features.swap.v2.impl.amount.model.converter
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import java.math.BigDecimal
/**
* Updates all subtitle fields on an existing [SwapAmountFieldUM.Content] field.
*
* Delegates to [SwapFromSubtitleConverter] / [SwapToSubtitleConverter] based on the field's
* [SwapAmountFieldUM.Content.amountType]. All other field properties are preserved.
*
* `isEntering` is derived internally: the field is "entering" when its amountType matches
* [selectedAmountType], so callers cannot accidentally pass a wrong value.
*
* @param selectedAmountType the currently selected amount type from [SwapAmountUM.Content]
* @param isBalanceHidden whether balance values should be masked
*/
internal class SwapAmountUpdateSubtitleConverter(
private val selectedAmountType: SwapAmountType,
private val isBalanceHidden: Boolean,
) {
/**
* Updates all subtitle fields after a quote arrives.
*
* @param field the current field content to update
* @param cryptoCurrencyStatus status used for balance formatting
* @param isAmountEmpty whether the amount in this field is empty/null
* @param displayAmount optional override for the displayed amount (e.g. quote toAmount/fromAmount)
*/
fun updateSubtitles(
field: SwapAmountFieldUM.Content,
cryptoCurrencyStatus: CryptoCurrencyStatus,
isAmountEmpty: Boolean,
displayAmount: BigDecimal? = null,
): SwapAmountFieldUM.Content {
val isEntering = field.amountType == selectedAmountType
val subtitles = when (field.amountType) {
SwapAmountType.From -> SwapFromSubtitleConverter.convert(
cryptoCurrencyStatus = cryptoCurrencyStatus,
isBalanceHidden = isBalanceHidden,
isEntering = isEntering,
isAmountEmpty = isAmountEmpty,
displayAmount = displayAmount,
)
SwapAmountType.To -> SwapToSubtitleConverter.convert(
cryptoCurrencyStatus = cryptoCurrencyStatus,
isBalanceHidden = isBalanceHidden,
isEntering = isEntering,
isAmountEmpty = isAmountEmpty,
displayAmount = displayAmount,
)
}
return field.copy(
subtitleLeft = subtitles.subtitleLeft,
subtitleEllipsisLeft = subtitles.subtitleEllipsisLeft,
subtitleRight = subtitles.subtitleRight,
subtitleEllipsisRight = subtitles.subtitleEllipsisRight,
)
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.features.swap.v2.impl.amount.model.converter
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.swap.v2.impl.R
import java.math.BigDecimal
/**
* Computes all subtitle fields for the **From** (primary) field.
*
* | State | subtitleLeft | subtitleRight | ellipsisLeft |
* |--------------------------------|-----------------------------------|-----------------------------------|------------------------|
* | Entering (float), any | "Balance: " (empty param) | "{balance}" masked | OffsetEnd(symbol) |
* | Viewing (fixed), empty | "Balance: " (empty param) | "{balance}" masked (crypto only) | End |
* | Viewing (fixed), not empty | send_from_title | "{displayStr}" masked | OffsetEnd(symbol) |
*/
internal object SwapFromSubtitleConverter {
fun convert(
cryptoCurrencyStatus: CryptoCurrencyStatus,
isBalanceHidden: Boolean,
isEntering: Boolean,
isAmountEmpty: Boolean,
displayAmount: BigDecimal?,
): SwapSubtitleResult {
val symbol = cryptoCurrencyStatus.currency.symbol
val balance = cryptoCurrencyStatus.value.amount.format {
crypto(cryptoCurrency = cryptoCurrencyStatus.currency)
}
val displayStr = displayAmount?.format {
crypto(cryptoCurrency = cryptoCurrencyStatus.currency)
} ?: balance
val subtitleLeft: TextReference
val subtitleRight: TextReference
val ellipsisLeft: TextEllipsis
when {
isEntering -> {
subtitleLeft = resourceReference(R.string.common_balance, wrappedList(""))
subtitleRight = combinedReference(stringReference(balance))
.orMaskWithStars(isBalanceHidden)
ellipsisLeft = TextEllipsis.OffsetEnd(symbol.length)
}
!isEntering && isAmountEmpty -> {
subtitleLeft = resourceReference(R.string.common_balance, wrappedList(""))
subtitleRight = combinedReference(stringReference(balance))
.orMaskWithStars(isBalanceHidden)
ellipsisLeft = TextEllipsis.End
}
else -> {
subtitleLeft = resourceReference(R.string.send_from_title)
subtitleRight = combinedReference(stringReference(displayStr))
.orMaskWithStars(isBalanceHidden)
ellipsisLeft = TextEllipsis.OffsetEnd(symbol.length)
}
}
return SwapSubtitleResult(
subtitleLeft = subtitleLeft,
subtitleRight = subtitleRight,
subtitleEllipsisLeft = ellipsisLeft,
subtitleEllipsisRight = TextEllipsis.OffsetEnd(symbol.length),
)
}
}

View file

@ -42,6 +42,10 @@ internal class SwapQuoteUMConverter(
append(rate.format { crypto(secondaryCurrency) })
}
val fromAmountValue = stringReference(
quote.fromTokenAmount?.format { crypto(primaryCurrency) }.orEmpty(),
)
return if (allowanceContract != null) {
if (isApprovalNeeded) {
SwapQuoteUM.Allowance(
@ -51,11 +55,13 @@ internal class SwapQuoteUMConverter(
} else {
SwapQuoteUM.Content(
provider = provider,
quoteAmount = quote.toTokenAmount,
toAmount = quote.toTokenAmount,
fromAmount = quote.fromTokenAmount,
diffPercent = DifferencePercent.Empty,
quoteAmountValue = stringReference(
toAmountValue = stringReference(
quote.toTokenAmount.toQuoteValue(),
),
fromAmountValue = fromAmountValue,
rate = annotatedReference(rateString),
isSingleProvider = false,
)
@ -63,11 +69,13 @@ internal class SwapQuoteUMConverter(
} else {
SwapQuoteUM.Content(
provider = provider,
quoteAmount = quote.toTokenAmount,
toAmount = quote.toTokenAmount,
fromAmount = quote.fromTokenAmount,
diffPercent = DifferencePercent.Empty,
quoteAmountValue = stringReference(
toAmountValue = stringReference(
quote.toTokenAmount.toQuoteValue(),
),
fromAmountValue = fromAmountValue,
rate = annotatedReference(rateString),
isSingleProvider = false,
)

View file

@ -0,0 +1,11 @@
package com.tangem.features.swap.v2.impl.amount.model.converter
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.extensions.TextReference
internal data class SwapSubtitleResult(
val subtitleLeft: TextReference,
val subtitleRight: TextReference,
val subtitleEllipsisLeft: TextEllipsis,
val subtitleEllipsisRight: TextEllipsis,
)

View file

@ -0,0 +1,73 @@
package com.tangem.features.swap.v2.impl.amount.model.converter
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.swap.v2.impl.R
import com.tangem.utils.StringsSigns.TILDE_SIGN
import java.math.BigDecimal
/**
* Computes all subtitle fields for the **To** (secondary) field.
*
* | State | subtitleLeft | subtitleRight | ellipsisLeft |
* |--------------------------------|------------------------------------|----------------------------|------------------------|
* | Viewing (float), empty | "Will be sent to recipient" | EMPTY | End |
* | Viewing (float), not empty | "Recipient gets" (empty param) | "~{displayStr}" masked | End |
* | Entering (fixed), any | "Will be sent to recipient" | EMPTY | End |
*/
internal object SwapToSubtitleConverter {
fun convert(
cryptoCurrencyStatus: CryptoCurrencyStatus,
isBalanceHidden: Boolean,
isEntering: Boolean,
isAmountEmpty: Boolean,
displayAmount: BigDecimal?,
): SwapSubtitleResult {
val symbol = cryptoCurrencyStatus.currency.symbol
val balance = cryptoCurrencyStatus.value.amount.format {
crypto(cryptoCurrency = cryptoCurrencyStatus.currency)
}
val displayStr = displayAmount?.format {
crypto(cryptoCurrency = cryptoCurrencyStatus.currency)
} ?: balance
val subtitleLeft: TextReference
val subtitleRight: TextReference
val ellipsisLeft: TextEllipsis
when {
isEntering -> {
subtitleLeft = resourceReference(R.string.send_amount_receive_token_subtitle)
subtitleRight = TextReference.EMPTY
ellipsisLeft = TextEllipsis.End
}
!isEntering && isAmountEmpty -> {
subtitleLeft = resourceReference(R.string.send_amount_receive_token_subtitle)
subtitleRight = TextReference.EMPTY
ellipsisLeft = TextEllipsis.End
}
else -> {
subtitleLeft = resourceReference(
R.string.send_with_swap_recipient_get_amount,
wrappedList(""),
)
subtitleRight = combinedReference(
stringReference(TILDE_SIGN),
stringReference(displayStr),
).orMaskWithStars(isBalanceHidden)
ellipsisLeft = TextEllipsis.End
}
}
return SwapSubtitleResult(
subtitleLeft = subtitleLeft,
subtitleRight = subtitleRight,
subtitleEllipsisLeft = ellipsisLeft,
subtitleEllipsisRight = TextEllipsis.OffsetEnd(symbol.length),
)
}
}

View file

@ -1,70 +1,55 @@
package com.tangem.features.swap.v2.impl.amount.model.transformers
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountUpdateSubtitleConverter
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.transformer.Transformer
@Suppress("LongParameterList")
internal class SwapAmountBalanceHiddenTransformer(
private val isBalanceHidden: Boolean,
private val isSingleWallet: Boolean,
private val userWallet: UserWallet,
private val appCurrency: AppCurrency,
private val swapDirection: SwapDirection,
private val clickIntents: AmountScreenClickIntents,
private val isAccountsMode: Boolean,
private val account: Account?,
) : Transformer<SwapAmountUM> {
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
val content = prevState as? SwapAmountUM.Content ?: return prevState
val amountFieldConverter = SwapAmountFieldConverter(
swapDirection = swapDirection,
val quoteContent = content.selectedQuote as? SwapQuoteUM.Content
val subtitleConverter = SwapAmountUpdateSubtitleConverter(
selectedAmountType = content.selectedAmountType,
isBalanceHidden = isBalanceHidden,
userWallet = userWallet,
appCurrency = appCurrency,
clickIntents = clickIntents,
isSingleWallet = isSingleWallet,
isAccountsMode = isAccountsMode,
account = account,
)
val recalculatedPrimary = amountFieldConverter.convert(
selectedType = SwapAmountType.From,
cryptoCurrencyStatus = content.primaryCryptoCurrencyStatus,
) as SwapAmountFieldUM.Content
val oldPrimary = content.primaryAmount as? SwapAmountFieldUM.Content
val mergedAmountField = if (
oldPrimary?.amountField is AmountState.Data && recalculatedPrimary.amountField is AmountState.Data
) {
val oldData = oldPrimary.amountField
val newData = recalculatedPrimary.amountField
newData.copy(
amountTextField = oldData.amountTextField,
isPrimaryButtonEnabled = oldData.isPrimaryButtonEnabled,
isEditingDisabled = oldData.isEditingDisabled,
reduceAmountBy = oldData.reduceAmountBy,
isIgnoreReduce = oldData.isIgnoreReduce,
val updatedPrimary = (content.primaryAmount as? SwapAmountFieldUM.Content)?.let { primary ->
val isAmountEmpty = (primary.amountField as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value == null
subtitleConverter.updateSubtitles(
field = primary,
cryptoCurrencyStatus = content.primaryCryptoCurrencyStatus,
isAmountEmpty = isAmountEmpty,
displayAmount = quoteContent?.fromAmount,
)
} ?: content.primaryAmount
val updatedSecondary = if (content.secondaryCryptoCurrencyStatus != null) {
(content.secondaryAmount as? SwapAmountFieldUM.Content)?.let { secondary ->
val isAmountEmpty = (secondary.amountField as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value == null
subtitleConverter.updateSubtitles(
field = secondary,
cryptoCurrencyStatus = content.secondaryCryptoCurrencyStatus,
isAmountEmpty = isAmountEmpty,
displayAmount = quoteContent?.toAmount,
)
} ?: content.secondaryAmount
} else {
recalculatedPrimary.amountField
content.secondaryAmount
}
val updatedPrimaryAmount = recalculatedPrimary.copy(
amountField = mergedAmountField,
return content.copy(
primaryAmount = updatedPrimary,
secondaryAmount = updatedSecondary,
)
return content.copy(primaryAmount = updatedPrimaryAmount)
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.swap.v2.impl.amount.model.transformers
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.utils.transformer.Transformer
/**
* Sets a generic "Something went wrong" error on the selected amount field
* when all quotes returned errors.
*/
internal object SwapAmountErrorQuoteTransformer : Transformer<SwapAmountUM> {
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
if (prevState !is SwapAmountUM.Content) return prevState
val error = resourceReference(R.string.send_with_swap_something_went_wrong)
val isPrimarySelected = prevState.selectedAmountType == SwapAmountType.From
val newPrimaryAmount = if (isPrimarySelected) {
applyError(prevState.primaryAmount, error)
} else {
prevState.primaryAmount
}
val newSecondaryAmount = if (!isPrimarySelected) {
applyError(prevState.secondaryAmount, error)
} else {
prevState.secondaryAmount
}
return prevState.copy(
isPrimaryButtonEnabled = false,
primaryAmount = newPrimaryAmount,
secondaryAmount = newSecondaryAmount,
)
}
private fun applyError(
field: SwapAmountFieldUM,
error: com.tangem.core.ui.extensions.TextReference,
): SwapAmountFieldUM {
val content = field as? SwapAmountFieldUM.Content ?: return field
val amountData = content.amountField as? AmountState.Data ?: return field
return content.copy(
amountField = amountData.copy(
amountTextField = amountData.amountTextField.copy(
error = error,
isError = true,
),
),
)
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencies
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.swap.models.SwapRateMode
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter
@ -46,8 +47,9 @@ internal class SwapAmountPrimaryReadyStateTransformer(
isPrimaryButtonEnabled = false,
primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus,
primaryAmount = amountFieldConverter.convert(
selectedType = SwapAmountType.From,
swapAmountType = SwapAmountType.From,
cryptoCurrencyStatus = primaryCryptoCurrencyStatus,
isSelected = prevState.selectedAmountType == SwapAmountType.From,
),
secondaryCryptoCurrencyStatus = null,
secondaryAmount = SwapAmountFieldUM.Empty(
@ -62,6 +64,7 @@ internal class SwapAmountPrimaryReadyStateTransformer(
appCurrency = appCurrency,
isShowBestRateAnimation = isShowBestRateAnimation,
isShowFCAWarning = false,
swapRateMode = (prevState as? SwapAmountUM.Content)?.swapRateMode ?: SwapRateMode.FLOAT_ONLY,
)
}
}

View file

@ -2,13 +2,15 @@ package com.tangem.features.swap.v2.impl.amount.model.transformers
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencies
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapRateMode
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
@ -20,6 +22,7 @@ internal class SwapAmountSecondaryReadyStateTransformer(
private val userWallet: UserWallet,
private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus,
private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus,
private val providers: List<ExpressProvider>,
private val appCurrency: AppCurrency,
private val swapCurrencies: SwapCurrencies,
private val clickIntents: AmountScreenClickIntents,
@ -43,24 +46,41 @@ internal class SwapAmountSecondaryReadyStateTransformer(
)
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
val rateTypes = providers.flatMapTo(mutableSetOf()) { it.rateTypes }
val swapRateMode = when {
rateTypes.containsAll(listOf(ExpressRateType.Float, ExpressRateType.Fixed)) -> SwapRateMode.FLOAT_AND_FIXED
rateTypes.contains(ExpressRateType.Fixed) -> SwapRateMode.FIXED_ONLY
else -> SwapRateMode.FLOAT_ONLY
}
val selectedAmountType = if (swapRateMode != SwapRateMode.FLOAT_ONLY) {
ExpressRateType.Fixed
} else {
ExpressRateType.Float
}
return SwapAmountUM.Content(
isPrimaryButtonEnabled = false,
primaryAmount = prevState.primaryAmount,
primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus,
secondaryAmount = amountFieldConverter.convert(
selectedType = SwapAmountType.To,
swapAmountType = SwapAmountType.To,
cryptoCurrencyStatus = secondaryCryptoCurrencyStatus,
isSelected = prevState.selectedAmountType == SwapAmountType.To,
),
secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus,
swapCurrencies = swapCurrencies,
selectedAmountType = prevState.selectedAmountType,
selectedAmountType = if (selectedAmountType == ExpressRateType.Fixed) {
SwapAmountType.To
} else {
SwapAmountType.From
},
swapDirection = swapDirection,
swapRateType = ExpressRateType.Float,
swapRateType = selectedAmountType,
swapQuotes = persistentListOf(),
selectedQuote = SwapQuoteUM.Empty,
appCurrency = appCurrency,
isShowBestRateAnimation = isShowBestRateAnimation,
isShowFCAWarning = false,
swapRateMode = swapRateMode,
)
}
}

View file

@ -4,12 +4,15 @@ import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTrans
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.calculatePriceImpact
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountErrorConverter
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountUpdateSubtitleConverter
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.common.isRestrictedByFCA
import com.tangem.utils.extensions.orZero
@ -20,63 +23,162 @@ internal class SwapAmountSelectQuoteTransformer(
private val secondaryMaximumAmountBoundary: EnterAmountBoundary?,
private val secondaryMinimumAmountBoundary: EnterAmountBoundary?,
private val isNeedApplyFCARestrictions: Boolean,
private val isBalanceHidden: Boolean,
private val primaryMaximumAmountBoundary: EnterAmountBoundary? = null,
private val primaryMinimumAmountBoundary: EnterAmountBoundary? = null,
) : Transformer<SwapAmountUM> {
@Suppress("CyclomaticComplexMethod", "LongMethod")
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
if (prevState !is SwapAmountUM.Content) return prevState
val providerErrorConverter = SwapAmountErrorConverter(
val isPrimarySelected = prevState.selectedAmountType == SwapAmountType.From
val isSecondarySelected = prevState.selectedAmountType == SwapAmountType.To
val primaryProviderErrorConverter = SwapAmountErrorConverter(
cryptoCurrency = prevState.primaryCryptoCurrencyStatus.currency,
)
val secondaryProviderErrorConverter = prevState.secondaryCryptoCurrencyStatus?.let {
SwapAmountErrorConverter(cryptoCurrency = it.currency)
}
return prevState.copy(
isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content,
selectedQuote = quoteUM,
isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true,
primaryAmount = if (prevState.selectedAmountType == SwapAmountType.From) {
val swapAmountField = prevState.primaryAmount as? SwapAmountFieldUM.Content
val amountField = swapAmountField?.amountField as? AmountState.Data
val quoteContent = quoteUM as? SwapQuoteUM.Content
val fromAmount = quoteContent?.fromAmount
val toAmount = quoteContent?.toAmount
val amountError = (quoteUM as? SwapQuoteUM.Error)?.expressError?.let(providerErrorConverter::convert)
val primarySwapAmountField = prevState.primaryAmount as? SwapAmountFieldUM.Content
val secondarySwapAmountField = prevState.secondaryAmount as? SwapAmountFieldUM.Content
swapAmountField?.copy(
val subtitleConverter = SwapAmountUpdateSubtitleConverter(
selectedAmountType = prevState.selectedAmountType,
isBalanceHidden = isBalanceHidden,
)
val newPrimaryAmount = when {
fromAmount != null && primaryMaximumAmountBoundary != null -> {
primarySwapAmountField?.let { fromField ->
subtitleConverter.updateSubtitles(
field = fromField,
cryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
isAmountEmpty = false,
displayAmount = fromAmount,
).copy(
amountField = AmountFieldChangeTransformer(
cryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
maxEnterAmount = primaryMaximumAmountBoundary,
minimumTransactionAmount = primaryMinimumAmountBoundary,
value = fromAmount.parseBigDecimal(
prevState.primaryCryptoCurrencyStatus.currency.decimals,
),
).transform(fromField.amountField),
)
} ?: prevState.primaryAmount
}
isPrimarySelected -> {
val amountField = primarySwapAmountField?.amountField as? AmountState.Data
val amountError = (quoteUM as? SwapQuoteUM.Error)?.expressError
?.let(primaryProviderErrorConverter::convert)
primarySwapAmountField?.copy(
amountField = amountField?.copy(
amountTextField = amountField.amountTextField.copy(
error = amountError ?: TextReference.EMPTY,
isError = amountError != null,
),
) ?: swapAmountField.amountField,
) ?: primarySwapAmountField.amountField,
) ?: prevState.primaryAmount
} else {
prevState.primaryAmount
},
secondaryAmount = if (prevState.selectedAmountType == SwapAmountType.From &&
prevState.secondaryCryptoCurrencyStatus != null && secondaryMaximumAmountBoundary != null
) {
val secondaryAmountField = prevState.secondaryAmount as? SwapAmountFieldUM.Content
val fromAmount = (prevState.primaryAmount.amountField as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value.orZero()
val toAmount = (quoteUM as? SwapQuoteUM.Content)?.quoteAmount
val priceImpact = calculatePriceImpact(
swapDirection = prevState.swapDirection,
fromTokenAmount = fromAmount,
toTokenAmount = toAmount.orZero(),
primaryCryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
secondaryCryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
)
secondaryAmountField?.copy(
priceImpact = priceImpact,
}
!isPrimarySelected && primarySwapAmountField != null && primaryMaximumAmountBoundary != null -> {
subtitleConverter.updateSubtitles(
field = primarySwapAmountField,
cryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
isAmountEmpty = true,
).copy(
amountField = AmountFieldChangeTransformer(
cryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
maxEnterAmount = secondaryMaximumAmountBoundary,
minimumTransactionAmount = secondaryMinimumAmountBoundary,
value = toAmount?.parseBigDecimal(prevState.secondaryCryptoCurrencyStatus.currency.decimals)
.orEmpty(),
).transform(secondaryAmountField.amountField),
) ?: prevState.secondaryAmount
cryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
maxEnterAmount = primaryMaximumAmountBoundary,
minimumTransactionAmount = primaryMinimumAmountBoundary,
value = "",
).transform(primarySwapAmountField.amountField),
)
}
else -> prevState.primaryAmount
}
val newSecondaryAmount = if (
prevState.secondaryCryptoCurrencyStatus != null &&
secondaryMaximumAmountBoundary != null &&
secondarySwapAmountField != null
) {
val fromAmountForPriceImpact = fromAmount
?: (prevState.primaryAmount.amountField as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value.orZero()
val priceImpact = calculatePriceImpact(
swapDirection = prevState.swapDirection,
fromTokenAmount = fromAmountForPriceImpact,
toTokenAmount = toAmount.orZero(),
primaryCryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
secondaryCryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
)
val isAmountEmpty = toAmount == null
val secondaryAmountError = if (isSecondarySelected) {
(quoteUM as? SwapQuoteUM.Error)?.expressError
?.let { secondaryProviderErrorConverter?.convert(it) }
} else {
prevState.secondaryAmount
},
null
}
val transformedSecondaryAmountField = if (isSecondarySelected && toAmount == null) {
secondarySwapAmountField.amountField
} else {
AmountFieldChangeTransformer(
cryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
maxEnterAmount = secondaryMaximumAmountBoundary,
minimumTransactionAmount = secondaryMinimumAmountBoundary,
value = toAmount?.parseBigDecimal(prevState.secondaryCryptoCurrencyStatus.currency.decimals)
.orEmpty(),
).transform(secondarySwapAmountField.amountField)
}
val insufficientFundsError = if (isSecondarySelected && fromAmount != null) {
val primaryBalance = prevState.primaryCryptoCurrencyStatus.value.amount
if (primaryBalance != null && fromAmount > primaryBalance) {
resourceReference(R.string.swapping_insufficient_funds)
} else {
null
}
} else {
null
}
val effectiveSecondaryError = secondaryAmountError ?: insufficientFundsError
val secondaryAmountFieldWithError = if (isSecondarySelected) {
(transformedSecondaryAmountField as? AmountState.Data)?.let { data ->
data.copy(
amountTextField = data.amountTextField.copy(
error = effectiveSecondaryError ?: TextReference.EMPTY,
isError = effectiveSecondaryError != null,
),
)
} ?: transformedSecondaryAmountField
} else {
transformedSecondaryAmountField
}
subtitleConverter.updateSubtitles(
field = secondarySwapAmountField,
cryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
isAmountEmpty = isAmountEmpty,
displayAmount = toAmount,
).copy(
priceImpact = priceImpact,
amountField = secondaryAmountFieldWithError,
)
} else {
prevState.secondaryAmount
}
return prevState.copy(
isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content,
selectedQuote = quoteUM,
isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true,
primaryAmount = newPrimaryAmount,
secondaryAmount = newSecondaryAmount,
)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM.Content.DifferencePercent
@ -23,18 +24,29 @@ internal class SwapAmountSetQuotesTransformer(
private val secondaryMinimumAmountBoundary: EnterAmountBoundary?,
private val isSilentReload: Boolean,
private val isNeedApplyFcaRestrictions: Boolean,
private val isBalanceHidden: Boolean,
private val primaryMaximumAmountBoundary: EnterAmountBoundary? = null,
private val primaryMinimumAmountBoundary: EnterAmountBoundary? = null,
) : Transformer<SwapAmountUM> {
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
if (prevState !is SwapAmountUM.Content) return prevState
val selectedAmountType = prevState.selectedAmountType
val comparator = SwapQuotesComparator(selectedAmountType)
val isSingleProvider = quotes.filter { swapQuoteUM ->
swapQuoteUM is SwapQuoteUM.Content || swapQuoteUM is SwapQuoteUM.Allowance ||
(swapQuoteUM as? SwapQuoteUM.Error)?.expressError is ExpressError.AmountError
}.isSingleItem()
val sortedQuotes = quotes.sortedWith(SwapQuotesComparator)
val bestQuote = findBestQuote(quotes) ?: SwapQuoteUM.Empty
val quotesWithDiff = getQuotesWithDiff(sortedQuotes, bestQuote, isSingleProvider)
val sortedQuotes = quotes.sortedWith(comparator)
val bestQuote = findBestQuote(quotes, comparator) ?: SwapQuoteUM.Empty
val quotesWithDiff = getQuotesWithDiff(
sortedQuotes = sortedQuotes,
bestQuote = bestQuote,
isSingleProvider = isSingleProvider,
selectedAmountType = selectedAmountType,
)
val selectedQuote = if (isSilentReload && prevState.selectedQuote !is SwapQuoteUM.Loading) {
quotesWithDiff.firstOrNull { it.provider?.providerId == prevState.selectedQuote.provider?.providerId }
?: prevState.selectedQuote
@ -51,14 +63,30 @@ internal class SwapAmountSetQuotesTransformer(
secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary,
isNeedApplyFCARestrictions = isNeedApplyFcaRestrictions &&
selectedQuote.provider?.isRestrictedByFCA() == true,
isBalanceHidden = isBalanceHidden,
primaryMaximumAmountBoundary = primaryMaximumAmountBoundary,
primaryMinimumAmountBoundary = primaryMinimumAmountBoundary,
)
val updatedState = selectQuoteTransformer.transform(prevState = prevState)
if (updatedState !is SwapAmountUM.Content) return prevState
return updatedState.copy(
isPrimaryButtonEnabled = updatedState.isPrimaryButtonEnabled && quotesWithDiff.isNotEmpty(),
swapQuotes = getQuotesWithDiff(sortedQuotes, bestQuote, isSingleProvider),
val areAllQuotesErrors = quotes.all { it is SwapQuoteUM.Error }
val stateWithError = if (areAllQuotesErrors) {
SwapAmountErrorQuoteTransformer.transform(updatedState)
} else {
updatedState
}
if (stateWithError !is SwapAmountUM.Content) return prevState
return stateWithError.copy(
isPrimaryButtonEnabled = stateWithError.isPrimaryButtonEnabled && quotesWithDiff.isNotEmpty(),
swapQuotes = getQuotesWithDiff(
sortedQuotes = sortedQuotes,
bestQuote = bestQuote,
isSingleProvider = isSingleProvider,
selectedAmountType = selectedAmountType,
),
)
}
@ -66,54 +94,75 @@ internal class SwapAmountSetQuotesTransformer(
sortedQuotes: List<SwapQuoteUM>,
bestQuote: SwapQuoteUM,
isSingleProvider: Boolean,
selectedAmountType: SwapAmountType,
): ImmutableList<SwapQuoteUM> {
return sortedQuotes.sortedWith(SwapQuotesComparator)
.map { quote ->
if (quote is SwapQuoteUM.Content && bestQuote is SwapQuoteUM.Content) {
if (quote.provider.providerId == bestQuote.provider.providerId) {
quote.copy(
diffPercent = DifferencePercent.Best,
isSingleProvider = isSingleProvider,
)
} else {
// current / selected - 1
val percent = quote.quoteAmount / bestQuote.quoteAmount - BigDecimal.ONE
quote.copy(
diffPercent = DifferencePercent.Diff(
isPositive = percent.isPositive(),
percent = stringReference(
if (percent.isPositive()) {
"${StringsSigns.PLUS}${percent.format { percent() }}"
} else {
"${StringsSigns.DASH_SIGN}${percent.format { percent() }}"
},
),
),
)
}
return sortedQuotes.map { quote ->
if (quote is SwapQuoteUM.Content && bestQuote is SwapQuoteUM.Content) {
if (quote.provider.providerId == bestQuote.provider.providerId) {
quote.copy(
diffPercent = DifferencePercent.Best,
isSingleProvider = isSingleProvider,
)
} else {
quote
val percent = if (selectedAmountType == SwapAmountType.To) {
// Fixed mode: best has lowest fromTokenAmount; compare as (best/current - 1)
val bestFrom = bestQuote.fromAmount
val quoteFrom = quote.fromAmount
if (bestFrom != null && quoteFrom != null && quoteFrom > BigDecimal.ZERO) {
bestFrom / quoteFrom - BigDecimal.ONE
} else {
BigDecimal.ZERO
}
} else {
// Float mode: best has highest toAmount; compare as (current/best - 1)
quote.toAmount / bestQuote.toAmount - BigDecimal.ONE
}
quote.copy(
diffPercent = DifferencePercent.Diff(
isPositive = percent.isPositive(),
percent = stringReference(
if (percent.isPositive()) {
"${StringsSigns.PLUS}${percent.format { percent() }}"
} else {
"${StringsSigns.DASH_SIGN}${percent.format { percent() }}"
},
),
),
)
}
}.toPersistentList()
} else {
quote
}
}.toPersistentList()
}
private fun findBestQuote(quotes: List<SwapQuoteUM>): SwapQuoteUM? {
return quotes
.sortedWith(SwapQuotesComparator)
.firstOrNull()
private fun findBestQuote(quotes: List<SwapQuoteUM>, comparator: Comparator<SwapQuoteUM>): SwapQuoteUM? {
return quotes.sortedWith(comparator).firstOrNull()
}
private object SwapQuotesComparator : Comparator<SwapQuoteUM> {
private class SwapQuotesComparator(private val selectedAmountType: SwapAmountType) : Comparator<SwapQuoteUM> {
override fun compare(p0: SwapQuoteUM?, p1: SwapQuoteUM?): Int {
return when {
p0 is SwapQuoteUM.Content && p1 !is SwapQuoteUM.Content -> -1
p0 !is SwapQuoteUM.Content && p1 is SwapQuoteUM.Content -> 1
p0 is SwapQuoteUM.Error && p1 is SwapQuoteUM.Error -> compareErrorQuote(p0 = p0, p1 = p1)
p0 is SwapQuoteUM.Content && p1 is SwapQuoteUM.Content -> p1.quoteAmount.compareTo(p0.quoteAmount)
p0 is SwapQuoteUM.Content && p1 is SwapQuoteUM.Content -> compareContent(p0, p1)
else -> 0
}
}
private fun compareContent(p0: SwapQuoteUM.Content, p1: SwapQuoteUM.Content): Int {
return if (selectedAmountType == SwapAmountType.To) {
// Fixed mode: lower fromTokenAmount = better (ascending)
val f0 = p0.fromAmount ?: BigDecimal.ZERO
val f1 = p1.fromAmount ?: BigDecimal.ZERO
f0.compareTo(f1)
} else {
// Float mode: higher toAmount = better (descending)
p1.toAmount.compareTo(p0.toAmount)
}
}
private fun compareErrorQuote(p0: SwapQuoteUM.Error, p1: SwapQuoteUM.Error): Int = when {
p0.expressError is ExpressError.AmountError && p1.expressError !is ExpressError.AmountError -> -1
p0.expressError !is ExpressError.AmountError && p1.expressError is ExpressError.AmountError -> 1

View file

@ -37,9 +37,9 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview
import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent

View file

@ -4,6 +4,7 @@ import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
@ -39,9 +40,9 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountClickIntents
import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountClickIntentsStub
@ -156,10 +157,12 @@ private fun SwapAmountBlock(
Box {
SwapAmountEditBlock(
amountFieldUM = amountFieldUM,
isFixedRate = isFixedRate,
modifier = Modifier,
onValueChange = clickIntents::onAmountValueChange,
onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss,
onCurrencyChange = clickIntents::onCurrencyChangeClick,
onRateClick = clickIntents::onRateClick,
)
HorizontalDivider(
modifier = Modifier
@ -182,12 +185,15 @@ private fun SwapAmountBlock(
}
}
@Suppress("LongParameterList")
@Composable
private fun SwapAmountEditBlock(
amountFieldUM: SwapAmountFieldUM,
isFixedRate: Boolean,
onValueChange: (String) -> Unit,
onValuePastedTriggerDismiss: () -> Unit,
onCurrencyChange: (Boolean) -> Unit,
onRateClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -209,8 +215,44 @@ private fun SwapAmountEditBlock(
onValueChange = onValueChange,
onValuePastedTriggerDismiss = onValuePastedTriggerDismiss,
onCurrencyChange = onCurrencyChange,
reserveSpaceForError = false,
modifier = Modifier,
)
SwapRateBadge(
isFixedRate = isFixedRate,
onClick = onRateClick,
)
}
}
@Composable
private fun SwapRateBadge(isFixedRate: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val iconRes = if (isFixedRate) R.drawable.ic_fixed else R.drawable.ic_floating
val textRes = if (isFixedRate) R.string.send_rate_is_fixed else R.string.send_rate_floating_info_title
Row(
modifier = modifier
.clip(RoundedCornerShape(6.dp))
.clickable(onClick = onClick)
.border(
width = 1.dp,
color = TangemTheme.colors.stroke.primary,
shape = RoundedCornerShape(6.dp),
)
.padding(vertical = 2.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = iconRes)),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
Text(
text = stringResourceSafe(textRes),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@ -227,6 +269,7 @@ private fun SwapAmountInfo(
modifier: Modifier = Modifier,
) {
val tokenIconState = (amountFieldUM.amountField as? AmountState.Data)?.tokenIconState ?: CurrencyIconState.Loading
val isEnabled = (amountFieldUM as? SwapAmountFieldUM.Content)?.isClickEnabled == true
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -234,11 +277,11 @@ private fun SwapAmountInfo(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
enabled = (amountFieldUM as? SwapAmountFieldUM.Content)?.isClickEnabled == true,
enabled = isEnabled,
onClick = {
if (isFixedRate) {
if (!isSelectedAmountType) {
onExpandEditField()
} else {
} else if (amountFieldUM.amountType == SwapAmountType.To) {
onSelectTokenClick()
}
},
@ -256,15 +299,14 @@ private fun SwapAmountInfo(
SwapAmountInfoMain(
amountFieldUM = amountFieldUM,
selectedQuote = selectedQuote,
isSelectedAmountType = isSelectedAmountType,
modifier = Modifier.weight(1f),
)
AnimatedContent(
targetState = isSelectedAmountType,
) { isSelected ->
if (isSelected) {
if (isSelected && amountFieldUM.amountType == SwapAmountType.From) {
AmountMaxButton(onMaxAmountClick)
} else {
} else if (isSelected && amountFieldUM.amountType == SwapAmountType.To) {
SwapAmountInfoQuote(
quoteUM = selectedQuote,
isFixedRate = isFixedRate,
@ -279,7 +321,6 @@ private fun SwapAmountInfo(
private fun SwapAmountInfoMain(
amountFieldUM: SwapAmountFieldUM,
selectedQuote: SwapQuoteUM?,
isSelectedAmountType: Boolean,
modifier: Modifier = Modifier,
) {
Column(
@ -300,48 +341,52 @@ private fun SwapAmountInfoMain(
)
}
}
AnimatedContent(isSelectedAmountType) { isSelected ->
if (isSelected && amountFieldUM is SwapAmountFieldUM.Content) {
SpacerH2()
Row {
EllipsisText(
text = amountFieldUM.subtitleLeft.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = amountFieldUM.subtitleEllipsisLeft,
modifier = Modifier.weight(1f, fill = false),
)
EllipsisText(
text = amountFieldUM.subtitleRight.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = amountFieldUM.subtitleEllipsisRight,
)
}
} else {
AnimatedContent(selectedQuote) { quote ->
when (quote) {
is SwapQuoteUM.Content -> {
SpacerH2()
AnimatedContent(selectedQuote) { quote ->
when (quote) {
is SwapQuoteUM.Content -> {
SpacerH2()
if (amountFieldUM is SwapAmountFieldUM.Content) {
SpacerH2()
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
EllipsisText(
text = stringResourceSafe(
R.string.send_with_swap_recipient_get_amount,
quote.quoteAmountValue.resolveReference(),
),
text = amountFieldUM.subtitleLeft.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = amountFieldUM.subtitleEllipsisLeft,
modifier = Modifier.weight(1f, fill = false),
)
}
SwapQuoteUM.Loading -> {
SpacerH2()
TextShimmer(
EllipsisText(
text = amountFieldUM.subtitleRight.resolveReference(),
style = TangemTheme.typography.caption2,
modifier = Modifier.width(72.dp),
color = TangemTheme.colors.text.tertiary,
ellipsis = amountFieldUM.subtitleEllipsisRight,
)
}
else -> Unit
} else {
SpacerH2()
TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(72.dp),
)
}
}
SwapQuoteUM.Loading -> {
SpacerH2()
TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(72.dp),
)
}
else -> {
SpacerH2()
Text(
text = "",
style = TangemTheme.typography.caption2,
)
}
}
}
}

View file

@ -10,6 +10,8 @@ internal object SwapAmountClickIntentsStub : SwapAmountClickIntents {
override fun onSelectTokenClick() {}
override fun onRateClick() {}
override fun onSeparatorClick() {}
override fun onProviderClick() {}

View file

@ -12,10 +12,11 @@ import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencies
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.swap.models.SwapRateMode
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.StringsSigns
@ -66,8 +67,10 @@ internal data object SwapAmountContentPreview {
private val quote = SwapQuoteUM.Content(
provider = provider,
quoteAmount = "123".toBigDecimal(),
quoteAmountValue = stringReference("123"),
toAmount = "123".toBigDecimal(),
fromAmount = null,
toAmountValue = stringReference("123"),
fromAmountValue = TextReference.EMPTY,
rate = stringReference("1 USD ≈ 123.123 POL"),
diffPercent = SwapQuoteUM.Content.DifferencePercent.Best,
isSingleProvider = false,
@ -92,6 +95,7 @@ internal data object SwapAmountContentPreview {
appCurrency = AppCurrency.Default,
isShowBestRateAnimation = false,
isShowFCAWarning = false,
swapRateMode = SwapRateMode.FLOAT_ONLY,
)
val defaultState = SwapAmountUM.Content(
@ -127,10 +131,11 @@ internal data object SwapAmountContentPreview {
selectedQuote = quote,
primaryCryptoCurrencyStatus = cryptoCurrencyStatus,
secondaryCryptoCurrencyStatus = cryptoCurrencyStatus,
swapRateType = ExpressRateType.Float,
swapRateType = ExpressRateType.Fixed,
isPrimaryButtonEnabled = true,
isShowBestRateAnimation = false,
isShowFCAWarning = true,
swapRateMode = SwapRateMode.FLOAT_AND_FIXED,
)
val defaultStateAccount: SwapAmountUM.Content

View file

@ -41,7 +41,7 @@ internal class SwapProviderListItemConverter(
title = stringReference(provider.name),
subtitle = stringReference(provider.type.typeName),
infoText = when (value) {
is SwapQuoteUM.Content -> value.quoteAmountValue
is SwapQuoteUM.Content -> value.toAmountValue
else -> TextReference.EMPTY
},
iconUrl = provider.imageLarge,

View file

@ -50,7 +50,7 @@ internal class SwapProviderStateConverter(
name = provider.name,
iconUrl = provider.imageLarge,
type = provider.type.typeName,
subtitle = quoteAmountValue,
subtitle = toAmountValue,
additionalBadge = additionalBadge,
diffPercent = diffPercent,
isSelected = provider == selectedProvider,

View file

@ -35,8 +35,10 @@ internal object SwapChooseProviderContentPreview {
private val quote1 = SwapQuoteUM.Content(
provider = provider1,
quoteAmount = "123".toBigDecimal(),
quoteAmountValue = stringReference("123"),
toAmount = "123".toBigDecimal(),
fromAmount = null,
toAmountValue = stringReference("123"),
fromAmountValue = stringReference(""),
rate = stringReference("1 USD ≈ 123.123 POL"),
diffPercent = SwapQuoteUM.Content.DifferencePercent.Best,
isSingleProvider = false,
@ -44,8 +46,10 @@ internal object SwapChooseProviderContentPreview {
private val quote2 = SwapQuoteUM.Content(
provider = provider2,
quoteAmount = "13.12".toBigDecimal(),
quoteAmountValue = stringReference("13.12"),
toAmount = "13.12".toBigDecimal(),
fromAmount = null,
toAmountValue = stringReference("13.12"),
fromAmountValue = stringReference(""),
rate = stringReference("1 USD ≈ 12.123 POL"),
diffPercent = SwapQuoteUM.Content.DifferencePercent.Empty,
isSingleProvider = false,

View file

@ -6,11 +6,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
@ -25,7 +23,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
@ -141,21 +138,6 @@ private fun SwapChooseTokenNetworkContentList(swapNetworks: ImmutableList<SwapCh
TangemTheme.colors.text.tertiary
},
)
if (network.hasFixedRate) {
SpacerWMax()
Text(
text = stringResourceSafe(R.string.swap_fixed_rate),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.border(
width = 1.dp,
color = TangemTheme.colors.stroke.primary,
shape = RoundedCornerShape(6.dp),
)
.padding(vertical = 2.dp, horizontal = 4.dp),
)
}
}
}
}

View file

@ -31,8 +31,10 @@ internal sealed class SwapQuoteUM {
data class Content(
override val provider: ExpressProvider,
val quoteAmount: BigDecimal,
val quoteAmountValue: TextReference,
val toAmount: BigDecimal,
val fromAmount: BigDecimal?,
val toAmountValue: TextReference,
val fromAmountValue: TextReference,
val diffPercent: DifferencePercent,
val isSingleProvider: Boolean,
val rate: TextReference,

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
@ -17,6 +18,7 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.common.ui.footers.SendingText
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.Fade
@ -75,38 +77,62 @@ internal fun SendWithSwapContent(
}
}
if (stackState.active.configuration != SendWithSwapRoute.Success) {
Column {
AnimatedVisibility(
visible = stackState.active.configuration == SendWithSwapRoute.Confirm,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
val confirmContentUM = confirmUM as? ConfirmUM.Content
val sendFooter = confirmContentUM?.sendingFooter ?: TextReference.EMPTY
val legalFooter = getAnnotatedStringForLegals(confirmContentUM?.tosUM, onClick = onLinkClick)
SendingText(
footerText = if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) {
combinedReference(sendFooter, legalFooter)
} else {
TextReference.EMPTY
},
)
}
NavigationPrimaryButton(
navigationUMContent.primaryButton,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
SendWithSwapFooter(
confirmUM = confirmUM,
stackState = stackState,
primaryButton = navigationUMContent.primaryButton,
onLinkClick = onLinkClick,
)
}
}
}
@Composable
private fun getAnnotatedStringForLegals(tosUM: ConfirmUM.Content.TosUM?, onClick: (String) -> Unit): TextReference {
private fun SendWithSwapFooter(
confirmUM: ConfirmUM,
stackState: ChildStack<SendWithSwapRoute, ComposableContentComponent>,
primaryButton: NavigationButton,
onLinkClick: (String) -> Unit,
) {
Column {
AnimatedVisibility(
visible = stackState.active.configuration == SendWithSwapRoute.Confirm,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
val confirmContentUM = confirmUM as? ConfirmUM.Content
val sendFooter = confirmContentUM?.sendingFooter ?: TextReference.EMPTY
val legalFooter = getAnnotatedStringForLegals(
tosUM = confirmContentUM?.tosUM,
sendFooter = sendFooter,
onClick = onLinkClick,
)
val footerText = remember(sendFooter, legalFooter) {
if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) {
combinedReference(sendFooter, legalFooter)
} else {
TextReference.EMPTY
}
}
SendingText(footerText = footerText)
}
NavigationPrimaryButton(
primaryButton = primaryButton,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
@Composable
private fun getAnnotatedStringForLegals(
tosUM: ConfirmUM.Content.TosUM?,
sendFooter: TextReference,
onClick: (String) -> Unit,
): TextReference {
if (tosUM == null) return TextReference.EMPTY
val tos = tosUM.tosLink
val policy = tosUM.policyLink
@ -118,7 +144,9 @@ private fun getAnnotatedStringForLegals(tosUM: ConfirmUM.Content.TosUM?, onClick
val policyIndex = fullString.indexOf(policyTitle)
annotatedReference {
append(StringsSigns.POINT_SIGN)
if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) {
append(StringsSigns.POINT_SIGN)
}
appendSpace()
append(fullString.substring(0, tosIndex))
withLink(