From 16004fe75a0e9dfb720cfc001626034375683dad Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 26 Jun 2025 12:57:29 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../impl/amount/SwapAmountComponentParams.kt | 48 ++ .../v2/impl/amount/model/SwapAmountModel.kt | 498 ++++++++++++++++++ .../impl/amount/model/SwapAmountQuoteUtils.kt | 60 +++ .../converter/SwapAmountFieldConverter.kt | 90 ++++ .../SwapAmountReadyStateConverter.kt | 58 ++ .../SwapAmountChangeCurrencyTransformer.kt | 34 ++ .../SwapAmountPasteTransformer.kt | 27 + .../SwapAmountSelectQuoteTransformer.kt | 60 +++ .../SwapAmountSetQuotesTransformer.kt | 172 ++++++ .../SwapAmountValueChangeTransformer.kt | 47 ++ .../SwapAmountValueMaxTransformer.kt | 46 ++ .../SwapQuoteLoadingStateTransformer.kt | 19 + .../v2/impl/amount/ui/SwapAmountContent.kt | 441 ++++++++++++++++ 13 files changed, 1600 insertions(+) create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountReadyStateConverter.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeCurrencyTransformer.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPasteTransformer.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSetQuotesTransformer.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueChangeTransformer.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueMaxTransformer.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapQuoteLoadingStateTransformer.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt new file mode 100644 index 0000000000..d7a15f42f7 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt @@ -0,0 +1,48 @@ +package com.tangem.features.swap.v2.impl.amount + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.swap.SwapRoute +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +internal sealed class SwapAmountComponentParams { + + abstract val amountUM: SwapAmountUM + abstract val analyticsCategoryName: String + abstract val userWallet: UserWallet + abstract val appCurrency: AppCurrency + abstract val swapDirection: SwapDirection + abstract val isBalanceHidingFlow: StateFlow + + data class AmountParams( + override val amountUM: SwapAmountUM, + override val analyticsCategoryName: String, + override val userWallet: UserWallet, + override val isBalanceHidingFlow: StateFlow, + override val appCurrency: AppCurrency, + override val swapDirection: SwapDirection, + val primaryCryptoCurrency: CryptoCurrency, + val secondaryCryptoCurrency: CryptoCurrency?, + val callback: SwapAmountComponent.ModelCallback, + val currentRoute: Flow, + val onBackClick: () -> Unit, + val onNextClick: () -> Unit, + ) : SwapAmountComponentParams() + + data class AmountBlockParams( + override val amountUM: SwapAmountUM, + override val analyticsCategoryName: String, + override val userWallet: UserWallet, + override val isBalanceHidingFlow: StateFlow, + override val appCurrency: AppCurrency, + override val swapDirection: SwapDirection, + val primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus?, + val blockClickEnableFlow: StateFlow, + ) : SwapAmountComponentParams() +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt new file mode 100644 index 0000000000..9d188d6cb3 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -0,0 +1,498 @@ +package com.tangem.features.swap.v2.impl.amount.model + +import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.common.ui.navigationButtons.NavigationButton +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.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.swap.models.SwapQuoteModel +import com.tangem.domain.swap.usecase.GetSwapPairsUseCase +import com.tangem.domain.swap.usecase.GetSwapQuoteUseCase +import com.tangem.domain.swap.usecase.SelectInitialPairUseCase +import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase +import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.usecase.GetAllowanceUseCase +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountReadyStateConverter +import com.tangem.features.swap.v2.impl.amount.model.transformers.* +import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent +import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.SwapChooseTokenNetworkListener +import com.tangem.features.swap.v2.impl.common.entity.NavigationUM +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM.Content.DifferencePercent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import kotlin.properties.Delegates +import com.tangem.utils.transformer.update as transformerUpdate + +@Suppress("LargeClass", "LongParameterList") +@ModelScoped +internal class SwapAmountModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val getSwapPairsUseCase: GetSwapPairsUseCase, + private val getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase, + private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, + private val selectInitialPairUseCase: SelectInitialPairUseCase, + private val getSwapQuoteUseCase: GetSwapQuoteUseCase, + private val swapChooseTokenNetworkListener: SwapChooseTokenNetworkListener, + private val getAllowanceUseCase: GetAllowanceUseCase, + private val appRouter: AppRouter, +) : Model(), SwapAmountClickIntents, SwapChooseProviderComponent.ModelCallback { + + private val params: SwapAmountComponentParams = paramsContainer.require() + private val swapDirection = params.swapDirection + private val appCurrency = params.appCurrency + private var userWallet = params.userWallet + + private var primaryCryptoCurrency: CryptoCurrency by Delegates.notNull() + private var secondaryCryptoCurrency: CryptoCurrency? = null + + private var primaryCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + private var secondaryCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + + private var primaryMaximumAmountBoundary: EnterAmountBoundary by Delegates.notNull() + private var secondaryMaximumAmountBoundary: EnterAmountBoundary by Delegates.notNull() + private var primaryMinimumAmountBoundary: EnterAmountBoundary by Delegates.notNull() + private var secondaryMinimumAmountBoundary: EnterAmountBoundary by Delegates.notNull() + + val uiState: StateFlow + field = MutableStateFlow(params.amountUM) + + init { + when (params) { + is SwapAmountComponentParams.AmountParams -> { + primaryCryptoCurrency = params.primaryCryptoCurrency + secondaryCryptoCurrency = params.secondaryCryptoCurrency + } + is SwapAmountComponentParams.AmountBlockParams -> { + primaryCryptoCurrency = params.primaryCryptoCurrencyStatus.currency + secondaryCryptoCurrency = params.secondaryCryptoCurrencyStatus?.currency + primaryCryptoCurrencyStatus = params.primaryCryptoCurrencyStatus + params.secondaryCryptoCurrencyStatus?.let { + secondaryCryptoCurrencyStatus = it + } + } + } + + initialState() + configAmountNavigation() + + observeChooseSelectToken() + // todo observe balance hiding flow + } + + fun updateState(amountUM: SwapAmountUM) { + uiState.update { amountUM } + } + + override fun onProviderResult(quoteUM: SwapQuoteUM) { + uiState.transformerUpdate( + SwapAmountSelectQuoteTransformer( + quoteUM = quoteUM, + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + secondaryMaximumAmountBoundary = secondaryMaximumAmountBoundary, + secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary, + ), + ) + } + + override fun onExpandEditField(selectedAmountType: SwapAmountType) { + uiState.update { amountUM -> + if (amountUM !is SwapAmountUM.Content) return + amountUM.copy( + selectedAmountType = selectedAmountType, + ) + } + } + + override fun onAmountValueChange(value: String) { + uiState.transformerUpdate( + SwapAmountValueChangeTransformer( + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + primaryMaximumAmountBoundary = primaryMaximumAmountBoundary, + secondaryMaximumAmountBoundary = secondaryMaximumAmountBoundary, + primaryMinimumAmountBoundary = primaryMinimumAmountBoundary, + secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary, + value = value, + ), + ) + loadQuotes() + } + + override fun onAmountPasteTriggerDismiss() { + uiState.transformerUpdate(SwapAmountPasteTransformer) + } + + override fun onMaxValueClick() { + uiState.transformerUpdate( + SwapAmountValueMaxTransformer( + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + primaryMaximumAmountBoundary = primaryMaximumAmountBoundary, + secondaryMaximumAmountBoundary = secondaryMaximumAmountBoundary, + primaryMinimumAmountBoundary = primaryMinimumAmountBoundary, + secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary, + ), + ) + loadQuotes() + } + + override fun onCurrencyChangeClick(isFiat: Boolean) { + uiState.transformerUpdate( + SwapAmountChangeCurrencyTransformer( + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + isFiatSelected = isFiat, + ), + ) + } + + override fun onAmountNext() { + saveResult() + } + + override fun onSelectTokenClick() { + appRouter.push( + AppRoute.ChooseManagedTokens( + userWalletId = userWallet.walletId, + initialCurrency = primaryCryptoCurrency, + source = AppRoute.ChooseManagedTokens.Source.SendViaSwap, + ), + ) + } + + private fun observeChooseSelectToken() { + swapChooseTokenNetworkListener.swapChooseTokenNetworkResultFlow + .onEach { currency -> + uiState.update { amountUM -> + if (amountUM is SwapAmountUM.Content) { + amountUM.copy( + isPrimaryButtonEnabled = false, + secondaryAmount = SwapAmountFieldUM.Loading( + amountType = SwapAmountType.To, + ), + ) + } else { + amountUM + } + } + initPairs(primaryCryptoCurrency, currency) + } + .launchIn(modelScope) + } + + private fun initialState() { + if (uiState.value is SwapAmountUM.Empty) { + initPairs( + primaryCryptoCurrency = primaryCryptoCurrency, + secondaryCryptoCurrency = secondaryCryptoCurrency, + ) + uiState.update { + SwapAmountUM.Content( + isPrimaryButtonEnabled = it.isPrimaryButtonEnabled, + primaryAmount = it.primaryAmount, + secondaryAmount = it.secondaryAmount, + swapDirection = swapDirection, + swapCurrencies = SwapCurrencies.EMPTY, + appCurrency = appCurrency, + swapQuotes = persistentListOf(), + swapRateType = ExpressRateType.Float, + selectedAmountType = SwapAmountType.From, + selectedQuote = SwapQuoteUM.Empty, + primaryCryptoCurrencyStatus = null, + secondaryCryptoCurrencyStatus = null, + ) + } + } + } + + private fun initPairs(primaryCryptoCurrency: CryptoCurrency, secondaryCryptoCurrency: CryptoCurrency?) { + modelScope.launch { + val cryptoCurrencyStatusList = getMultiCryptoCurrencyStatusUseCase + .invokeMultiWalletSync(userWallet.walletId) + .getOrElse { emptyList() } + + val primaryStatus = cryptoCurrencyStatusList.firstOrNull { + it.currency.id == primaryCryptoCurrency.id + } + if (primaryStatus == null) { + Timber.e("Failed to get crypto currency status") + // todo error + return@launch + } + + val cryptoCurrencyStatusListExceptPrimary = cryptoCurrencyStatusList.filter { + val statusFilter = it.value is CryptoCurrencyStatus.Loaded || it.value is CryptoCurrencyStatus.NoAccount + val notCustomTokenFilter = !it.currency.isCustom + statusFilter && notCustomTokenFilter + } + + getSwapPairsUseCase( + userWallet = userWallet, + initialCurrency = primaryCryptoCurrency, + cryptoCurrencyStatusList = cryptoCurrencyStatusListExceptPrimary, + ).fold( + ifRight = { swapCurrencies -> + val secondaryStatus = selectInitialPairUseCase( + primaryCryptoCurrency = primaryCryptoCurrency, + secondaryCryptoCurrency = secondaryCryptoCurrency, + userWallet = userWallet, + swapCurrencies = swapCurrencies, + swapDirection = params.swapDirection, + ) + + if (secondaryStatus != null) { + initCurrencies(primaryStatus, secondaryStatus) + uiState.update { + SwapAmountReadyStateConverter( + swapCurrencies = swapCurrencies, + userWallet = userWallet, + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + appCurrency = appCurrency, + swapDirection = swapDirection, + clickIntents = this@SwapAmountModel, + isBalanceHidden = params.isBalanceHidingFlow.value, + ).convert(Unit) + } + } else { + // todo not available currency to swap + } + }, + ifLeft = { + // todo error + }, + ) + } + } + + private suspend fun initCurrencies(primaryStatus: CryptoCurrencyStatus, secondaryStatus: CryptoCurrencyStatus) { + primaryCryptoCurrencyStatus = primaryStatus + secondaryCryptoCurrencyStatus = secondaryStatus + + primaryMinimumAmountBoundary = EnterAmountBoundary( + amount = getMinimumTransactionAmountSyncUseCase + .invoke( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = primaryStatus, + ).getOrNull().orZero(), + fiatRate = primaryStatus.value.fiatRate, + fiatAmount = primaryStatus.value.fiatAmount, + ) + secondaryMinimumAmountBoundary = EnterAmountBoundary( + amount = getMinimumTransactionAmountSyncUseCase + .invoke( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = secondaryStatus, + ).getOrNull().orZero(), + fiatRate = secondaryStatus.value.fiatRate, + fiatAmount = secondaryStatus.value.fiatAmount, + ) + primaryMaximumAmountBoundary = MaxEnterAmountConverter().convert(primaryStatus) + secondaryMaximumAmountBoundary = MaxEnterAmountConverter().convert(secondaryStatus) + } + + private fun loadQuotes() { + val state = uiState.value as? SwapAmountUM.Content ?: return + + val (fromCryptoCurrency, toCryptoCurrency) = when (state.swapDirection) { + SwapDirection.Direct -> primaryCryptoCurrencyStatus.currency to secondaryCryptoCurrencyStatus.currency + SwapDirection.Reverse -> secondaryCryptoCurrencyStatus.currency to primaryCryptoCurrencyStatus.currency + } + + val fromAmount = when (state.swapDirection) { + SwapDirection.Direct -> state.primaryAmount.amountField + SwapDirection.Reverse -> state.secondaryAmount.amountField + } as? AmountState.Data + + if (fromAmount?.amountTextField?.isError == true) return + + val fromAmountValue = fromAmount?.amountTextField?.cryptoAmount?.value ?: return + + val swapGroups = when (state.swapDirection) { + SwapDirection.Direct -> state.swapCurrencies.toGroup.available + SwapDirection.Reverse -> state.swapCurrencies.fromGroup.available + } + + uiState.transformerUpdate(SwapQuoteLoadingStateTransformer) + + modelScope.launch { + val quotes = swapGroups.firstOrNull { + it.currencyStatus.currency.id == toCryptoCurrency.id + }?.providers?.map { provider -> + async { + getSwapQuoteUseCase( + userWallet = userWallet, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + fromAmount = fromAmountValue, + provider = provider, + ).fold( + ifLeft = { error -> + SwapQuoteUM.Error( + provider = provider, + expressError = error, + ) + }, + ifRight = { quote: SwapQuoteModel -> + convertToSwapProviderUM( + quote = quote, + provider = provider, + differencePercent = DifferencePercent.Empty, + swapDirection = swapDirection, + ) + }, + ) + } + }?.awaitAll().orEmpty() + + uiState.transformerUpdate( + SwapAmountSetQuotesTransformer( + quotes = quotes, + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + secondaryMaximumAmountBoundary = secondaryMaximumAmountBoundary, + secondaryMinimumAmountBoundary = secondaryMinimumAmountBoundary, + ), + ) + } + } + + private suspend fun convertToSwapProviderUM( + quote: SwapQuoteModel, + provider: ExpressProvider, + differencePercent: DifferencePercent, + swapDirection: SwapDirection, + ): SwapQuoteUM { + // todo swap allowance + val allowanceContract = quote.allowanceContract + return if (allowanceContract != null) { + val allowance = getAllowanceUseCase( + userWalletId = userWallet.walletId, + cryptoCurrency = primaryCryptoCurrencyStatus.currency, + spenderAddress = allowanceContract, + ).getOrNull().orZero() + val isApprovalNeeded = allowance < primaryCryptoCurrencyStatus.value.amount.orZero() + + if (isApprovalNeeded) { + SwapQuoteUM.Allowance( + provider = provider, + allowanceContract = allowanceContract, + ) + } else { + SwapQuoteUM.Content( + provider = provider, + quoteAmount = quote.toTokenAmount, + diffPercent = differencePercent, + quoteAmountValue = stringReference( + quote.toTokenAmount.format { + crypto( + when (swapDirection) { + SwapDirection.Direct -> secondaryCryptoCurrencyStatus.currency + SwapDirection.Reverse -> primaryCryptoCurrencyStatus.currency + }, + ) + }, + ), + ) + } + } else { + SwapQuoteUM.Content( + provider = provider, + quoteAmount = quote.toTokenAmount, + diffPercent = differencePercent, + quoteAmountValue = stringReference( + quote.toTokenAmount.format { + crypto( + when (swapDirection) { + SwapDirection.Direct -> secondaryCryptoCurrencyStatus.currency + SwapDirection.Reverse -> primaryCryptoCurrencyStatus.currency + }, + ) + }, + ), + ) + } + } + + private fun saveResult() { + val params = params as? SwapAmountComponentParams.AmountParams ?: return + params.callback.onAmountResult(uiState.value) + } + + private fun configAmountNavigation() { + val params = params as? SwapAmountComponentParams.AmountParams ?: return + combine( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, route) -> + params.callback.onNavigationResult( + NavigationUM.Content( + title = resourceReference(R.string.common_swap), + subtitle = null, + backIconRes = if (route.isEditMode) { + R.drawable.ic_back_24 + } else { + R.drawable.ic_close_24 + }, + backIconClick = { + // if (!route.isEditMode) { + // todo analytics + // analyticsEventHandler.send( + // CommonSendAnalyticEvents.CloseButtonClicked( + // categoryName = params.analyticsCategoryName, + // source = SendScreenSource.Address, + // isFromSummary = false, + // isValid = state.isPrimaryButtonEnabled, + // ), + // ) + // } + params.onBackClick() + }, + primaryButton = NavigationButton( + textReference = if (route.isEditMode) { + resourceReference(R.string.common_continue) + } else { + resourceReference(R.string.common_next) + }, + isEnabled = state.isPrimaryButtonEnabled, + onClick = { + saveResult() + params.onNextClick() + }, + ), + ), + ) + }.launchIn(modelScope) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt new file mode 100644 index 0000000000..bef7ed2108 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountQuoteUtils.kt @@ -0,0 +1,60 @@ +package com.tangem.features.swap.v2.impl.amount.model + +import com.tangem.core.ui.extensions.TextReference +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.swap.models.SwapDirection +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.utils.extensions.isZero +import java.math.BigDecimal +import java.math.RoundingMode + +internal object SwapAmountQuoteUtils { + + fun calculatePriceImpact( + fromTokenAmount: BigDecimal, + toTokenAmount: BigDecimal, + swapDirection: SwapDirection, + primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + secondaryCryptoCurrencyStatus: CryptoCurrencyStatus, + ): TextReference? { + val (fromRate, toRate) = if (swapDirection == SwapDirection.Direct) { + primaryCryptoCurrencyStatus.value.fiatRate to secondaryCryptoCurrencyStatus.value.fiatRate + } else { + secondaryCryptoCurrencyStatus.value.fiatRate to primaryCryptoCurrencyStatus.value.fiatRate + } + + val fromTokenFiatValue = fromTokenAmount.multiply(fromRate) + val toTokenFiatValue = toTokenAmount.multiply(toRate) + + // Check for zero division + if (fromTokenFiatValue.isZero() || toTokenFiatValue.isZero()) return null + + val value = BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP) + + return stringReference("$(-${value.format { percent(withoutSign = false) }})").takeIf { + value > 0.1.toBigDecimal() + } + } + + fun SwapAmountUM.updateAmount( + onPrimaryAmount: SwapAmountFieldUM.Content.() -> SwapAmountFieldUM, + onSecondaryAmount: SwapAmountFieldUM.Content.() -> SwapAmountFieldUM, + ): SwapAmountUM { + if (this !is SwapAmountUM.Content) return this + + return if ( + selectedAmountType == SwapAmountType.From && swapDirection == SwapDirection.Direct + ) { + val amountFieldUM = primaryAmount as? SwapAmountFieldUM.Content ?: return this + copy(primaryAmount = amountFieldUM.onPrimaryAmount()) + } else { + val amountFieldUM = secondaryAmount as? SwapAmountFieldUM.Content ?: return this + copy(secondaryAmount = amountFieldUM.onSecondaryAmount()) + } + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt new file mode 100644 index 0000000000..0b70b34750 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt @@ -0,0 +1,90 @@ +package com.tangem.features.swap.v2.impl.amount.model.converter + +import com.tangem.common.ui.amountScreen.AmountScreenClickIntents +import com.tangem.common.ui.amountScreen.converters.AmountStateConverterV2 +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.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +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.SwapAmountType + +internal class SwapAmountFieldConverter( + private val swapDirection: SwapDirection, + private val isBalanceHidden: Boolean, + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val clickIntents: AmountScreenClickIntents, +) { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val maxEnterAmountConverter = MaxEnterAmountConverter() + + fun convert(selectedType: SwapAmountType, cryptoCurrencyStatus: CryptoCurrencyStatus): SwapAmountFieldUM { + return SwapAmountFieldUM.Content( + amountType = selectedType, + title = stringReference(cryptoCurrencyStatus.currency.name), + subtitle = getSubtitle(selectedType = selectedType, cryptoCurrencyStatus = cryptoCurrencyStatus), + subtitleEllipsis = getSubtitleEllipsis( + selectedType = selectedType, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ), + priceImpact = null, + isClickEnabled = selectedType.isViewingField(), + amountField = AmountStateConverterV2( + clickIntents = clickIntents, + appCurrency = appCurrency, + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxEnterAmount = maxEnterAmountConverter.convert(cryptoCurrencyStatus), + iconStateConverter = iconStateConverter, + isRedesignEnabled = true, + ).convert( + AmountParameters( + title = combinedReference( + resourceReference(R.string.send_from_wallet_android), + stringReference(" "), + stringReference(userWallet.name), + ), + value = "", + ), + ), + ) + } + + private fun getSubtitle(selectedType: SwapAmountType, cryptoCurrencyStatus: CryptoCurrencyStatus) = when { + selectedType.isEnteringField() -> resourceReference( + R.string.common_balance, + wrappedList( + cryptoCurrencyStatus.value.amount.format { + crypto(cryptoCurrency = cryptoCurrencyStatus.currency) + }, + ), + ).orMaskWithStars(isBalanceHidden) + selectedType.isViewingField() -> resourceReference(R.string.send_with_swap_recipient_amount_text) + else -> TextReference.Companion.EMPTY + } + + private fun getSubtitleEllipsis(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 + } + + private fun SwapAmountType.isViewingField(): Boolean { + return this == SwapAmountType.From && swapDirection == SwapDirection.Reverse || + this == SwapAmountType.To && swapDirection == SwapDirection.Direct + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountReadyStateConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountReadyStateConverter.kt new file mode 100644 index 0000000000..7de7bab116 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountReadyStateConverter.kt @@ -0,0 +1,58 @@ +package com.tangem.features.swap.v2.impl.amount.model.converter + +import com.tangem.common.ui.amountScreen.AmountScreenClickIntents +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.swap.models.SwapCurrencies +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.swap.v2.impl.amount.entity.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.converter.Converter +import kotlinx.collections.immutable.persistentListOf + +@Suppress("LongParameterList") +internal class SwapAmountReadyStateConverter( + private val userWallet: UserWallet, + private val swapCurrencies: SwapCurrencies, + private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val appCurrency: AppCurrency, + private val clickIntents: AmountScreenClickIntents, + private val swapDirection: SwapDirection, + private val isBalanceHidden: Boolean, +) : Converter { + + private val amountFieldConverter = SwapAmountFieldConverter( + swapDirection = swapDirection, + isBalanceHidden = isBalanceHidden, + userWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + ) + + override fun convert(value: Unit): SwapAmountUM { + return SwapAmountUM.Content( + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + primaryAmount = amountFieldConverter.convert( + selectedType = SwapAmountType.From, + cryptoCurrencyStatus = primaryCryptoCurrencyStatus, + ), + secondaryAmount = amountFieldConverter.convert( + selectedType = SwapAmountType.To, + cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + ), + swapCurrencies = swapCurrencies, + swapDirection = swapDirection, + swapQuotes = persistentListOf(), + selectedQuote = SwapQuoteUM.Empty, + selectedAmountType = SwapAmountType.From, + appCurrency = appCurrency, + swapRateType = ExpressRateType.Float, + isPrimaryButtonEnabled = false, + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeCurrencyTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeCurrencyTransformer.kt new file mode 100644 index 0000000000..05c0a9d16b --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeCurrencyTransformer.kt @@ -0,0 +1,34 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.tangem.common.ui.amountScreen.converters.AmountCurrencyTransformer +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.updateAmount +import com.tangem.utils.transformer.Transformer + +internal class SwapAmountChangeCurrencyTransformer( + private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val isFiatSelected: Boolean, +) : Transformer { + override fun transform(prevState: SwapAmountUM): SwapAmountUM { + return prevState.updateAmount( + onPrimaryAmount = { + copy( + amountField = AmountCurrencyTransformer( + cryptoCurrencyStatus = primaryCryptoCurrencyStatus, + value = isFiatSelected, + ).transform(prevState.primaryAmount.amountField), + ) + }, + onSecondaryAmount = { + copy( + amountField = AmountCurrencyTransformer( + cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + value = isFiatSelected, + ).transform(prevState.secondaryAmount.amountField), + ) + }, + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPasteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPasteTransformer.kt new file mode 100644 index 0000000000..2b6eb14c59 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountPasteTransformer.kt @@ -0,0 +1,27 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.tangem.common.ui.amountScreen.converters.AmountPastedTriggerDismissTransformer +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.updateAmount +import com.tangem.utils.transformer.Transformer + +internal object SwapAmountPasteTransformer : Transformer { + override fun transform(prevState: SwapAmountUM): SwapAmountUM { + return prevState.updateAmount( + onPrimaryAmount = { + copy( + amountField = AmountPastedTriggerDismissTransformer.transform( + prevState = prevState.primaryAmount.amountField, + ), + ) + }, + onSecondaryAmount = { + copy( + amountField = AmountPastedTriggerDismissTransformer.transform( + prevState = prevState.secondaryAmount.amountField, + ), + ) + }, + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt new file mode 100644 index 0000000000..0c11067bfc --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt @@ -0,0 +1,60 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType +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.common.entity.SwapQuoteUM +import com.tangem.utils.extensions.orZero +import com.tangem.utils.transformer.Transformer + +internal class SwapAmountSelectQuoteTransformer( + private val quoteUM: SwapQuoteUM, + private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryMaximumAmountBoundary: EnterAmountBoundary, + private val secondaryMinimumAmountBoundary: EnterAmountBoundary, +) : Transformer { + override fun transform(prevState: SwapAmountUM): SwapAmountUM { + if (prevState !is SwapAmountUM.Content) return prevState + + return prevState.copy( + isPrimaryButtonEnabled = true, + selectedQuote = quoteUM, + secondaryAmount = if ( + prevState.selectedAmountType == SwapAmountType.From && prevState.swapDirection == SwapDirection.Direct + ) { + 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 = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + ) + + secondaryAmountField?.copy( + priceImpact = priceImpact, + amountField = AmountFieldChangeTransformer( + cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + maxEnterAmount = secondaryMaximumAmountBoundary, + minimumTransactionAmount = secondaryMinimumAmountBoundary, + value = toAmount?.parseBigDecimal(secondaryCryptoCurrencyStatus.currency.decimals) + .orEmpty(), + ).transform(secondaryAmountField.amountField), + ) ?: prevState.secondaryAmount + } else { + prevState.secondaryAmount + }, + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSetQuotesTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSetQuotesTransformer.kt new file mode 100644 index 0000000000..ac39b1417e --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSetQuotesTransformer.kt @@ -0,0 +1,172 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer +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.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.swap.models.SwapDirection +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.SwapAmountType +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.common.entity.SwapQuoteUM +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM.Content.DifferencePercent +import com.tangem.utils.StringsSigns +import com.tangem.utils.extensions.isPositive +import com.tangem.utils.extensions.orZero +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal + +internal class SwapAmountSetQuotesTransformer( + private val quotes: List, + private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryMaximumAmountBoundary: EnterAmountBoundary, + private val secondaryMinimumAmountBoundary: EnterAmountBoundary, +) : Transformer { + override fun transform(prevState: SwapAmountUM): SwapAmountUM { + if (prevState !is SwapAmountUM.Content) return prevState + + val fromAmount = when (prevState.swapDirection) { + SwapDirection.Direct -> prevState.primaryAmount.amountField + SwapDirection.Reverse -> prevState.secondaryAmount.amountField + } as? AmountState.Data + val fromAmountValue = fromAmount?.amountTextField?.cryptoAmount?.value.orZero() + + val sortedQuotes = quotes.sortedWith(SwapQuotesComparator) + val bestQuote = findBestQuote(quotes) ?: SwapQuoteUM.Empty + + return prevState.copy( + isPrimaryButtonEnabled = bestQuote is SwapQuoteUM.Content, + swapQuotes = getQuotesWithDiff(sortedQuotes, bestQuote), + selectedQuote = bestQuote, + primaryAmount = if ( + prevState.selectedAmountType == SwapAmountType.From && prevState.swapDirection == SwapDirection.Direct + ) { + val swapAmountField = prevState.primaryAmount as? SwapAmountFieldUM.Content + val amountField = swapAmountField?.amountField as? AmountState.Data + + val amountError = (bestQuote as? SwapQuoteUM.Error)?.expressError.getAmountError() + + if (amountField?.amountTextField?.isError == true) { + prevState.primaryAmount + } else { + swapAmountField?.copy( + amountField = amountField?.copy( + amountTextField = amountField.amountTextField.copy( + error = amountError ?: TextReference.EMPTY, + isError = amountError != null, + ), + ) ?: swapAmountField.amountField, + ) ?: prevState.primaryAmount + } + } else { + prevState.primaryAmount + }, + secondaryAmount = if ( + prevState.selectedAmountType == SwapAmountType.From && prevState.swapDirection == SwapDirection.Direct + ) { + val amountField = prevState.secondaryAmount as? SwapAmountFieldUM.Content + val toAmount = (bestQuote as? SwapQuoteUM.Content)?.quoteAmount + + val priceImpact = calculatePriceImpact( + swapDirection = prevState.swapDirection, + fromTokenAmount = fromAmountValue, + toTokenAmount = toAmount.orZero(), + primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus, + secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + ) + + amountField?.copy( + priceImpact = priceImpact, + amountField = AmountFieldChangeTransformer( + cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + maxEnterAmount = secondaryMaximumAmountBoundary, + minimumTransactionAmount = secondaryMinimumAmountBoundary, + value = toAmount?.parseBigDecimal(secondaryCryptoCurrencyStatus.currency.decimals).orEmpty(), + ).transform(amountField.amountField), + ) ?: prevState.secondaryAmount + } else { + prevState.secondaryAmount + }, + ) + } + + private fun ExpressError?.getAmountError(): TextReference? = when (this) { + is ExpressError.AmountError.TooSmallError -> resourceReference( + R.string.express_provider_min_amount, + wrappedList(amount.format { crypto(cryptoCurrency = primaryCryptoCurrencyStatus.currency) }), + ) + is ExpressError.AmountError.TooBigError -> resourceReference( + R.string.express_provider_max_amount, + wrappedList(amount.format { crypto(cryptoCurrency = primaryCryptoCurrencyStatus.currency) }), + ) + else -> null + } + + private fun getQuotesWithDiff(sortedQuotes: List, bestQuote: SwapQuoteUM): ImmutableList { + 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) + } else { + // current / selected - 1 + val percent = quote.quoteAmount / bestQuote.quoteAmount - BigDecimal.ONE + quote.copy( + diffPercent = DifferencePercent.Diff( + percent = stringReference( + if (percent.isPositive()) { + "${StringsSigns.PLUS}${percent.format { percent() }}" + } else { + "${StringsSigns.DASH_SIGN}${percent.format { percent() }}" + }, + ), + ), + ) + } + } else { + quote + } + }.toPersistentList() + } + + private fun findBestQuote(quotes: List): SwapQuoteUM? { + return quotes + .sortedWith(SwapQuotesComparator) + .firstOrNull() + } + + private object SwapQuotesComparator : Comparator { + 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) + else -> 0 + } + } + + 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 + p0.expressError is ExpressError.AmountError && p1.expressError is ExpressError.AmountError -> { + p0.expressError.amount.compareTo(p1.expressError.amount) + } + else -> 0 + } + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueChangeTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueChangeTransformer.kt new file mode 100644 index 0000000000..59687934ce --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueChangeTransformer.kt @@ -0,0 +1,47 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.updateAmount +import com.tangem.utils.transformer.Transformer + +@Suppress("LongParameterList") +internal class SwapAmountValueChangeTransformer( + private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val primaryMaximumAmountBoundary: EnterAmountBoundary, + private val secondaryMaximumAmountBoundary: EnterAmountBoundary, + private val primaryMinimumAmountBoundary: EnterAmountBoundary, + private val secondaryMinimumAmountBoundary: EnterAmountBoundary, + private val value: String, +) : Transformer { + override fun transform(prevState: SwapAmountUM): SwapAmountUM { + if (prevState !is SwapAmountUM.Content) return prevState + + return prevState + .updateAmount( + onPrimaryAmount = { + copy( + amountField = AmountFieldChangeTransformer( + cryptoCurrencyStatus = primaryCryptoCurrencyStatus, + maxEnterAmount = primaryMaximumAmountBoundary, + minimumTransactionAmount = primaryMinimumAmountBoundary, + value = value, + ).transform(prevState.primaryAmount.amountField), + ) + }, + onSecondaryAmount = { + copy( + amountField = AmountFieldChangeTransformer( + cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + maxEnterAmount = secondaryMaximumAmountBoundary, + minimumTransactionAmount = secondaryMinimumAmountBoundary, + value = value, + ).transform(prevState.secondaryAmount.amountField), + ) + }, + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueMaxTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueMaxTransformer.kt new file mode 100644 index 0000000000..8392215305 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountValueMaxTransformer.kt @@ -0,0 +1,46 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmountTransformer +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.amount.model.SwapAmountQuoteUtils.updateAmount +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.utils.transformer.Transformer + +internal class SwapAmountValueMaxTransformer( + private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus, + private val primaryMaximumAmountBoundary: EnterAmountBoundary, + private val secondaryMaximumAmountBoundary: EnterAmountBoundary, + private val primaryMinimumAmountBoundary: EnterAmountBoundary, + private val secondaryMinimumAmountBoundary: EnterAmountBoundary, +) : Transformer { + override fun transform(prevState: SwapAmountUM): SwapAmountUM { + if (prevState !is SwapAmountUM.Content) return prevState + + return prevState + .copy(selectedQuote = SwapQuoteUM.Loading) + .updateAmount( + onPrimaryAmount = { + copy( + amountField = AmountFieldSetMaxAmountTransformer( + cryptoCurrencyStatus = primaryCryptoCurrencyStatus, + maxAmount = primaryMaximumAmountBoundary, + minAmount = primaryMinimumAmountBoundary, + ).transform(prevState.primaryAmount.amountField), + ) + }, + onSecondaryAmount = { + copy( + amountField = AmountFieldSetMaxAmountTransformer( + cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, + maxAmount = secondaryMaximumAmountBoundary, + minAmount = secondaryMinimumAmountBoundary, + ).transform(prevState.secondaryAmount.amountField), + ) + }, + + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapQuoteLoadingStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapQuoteLoadingStateTransformer.kt new file mode 100644 index 0000000000..12bcce175c --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapQuoteLoadingStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.swap.v2.impl.amount.model.transformers + +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.utils.transformer.Transformer + +internal object SwapQuoteLoadingStateTransformer : Transformer { + override fun transform(prevState: SwapAmountUM): SwapAmountUM { + if (prevState !is SwapAmountUM.Content) return prevState + + return prevState.copy( + selectedQuote = if (prevState.selectedQuote is SwapQuoteUM.Empty) { + SwapQuoteUM.Loading + } else { + prevState.selectedQuote + }, + ) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt new file mode 100644 index 0000000000..671ce82c48 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountContent.kt @@ -0,0 +1,441 @@ +package com.tangem.features.swap.v2.impl.amount.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +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.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.ui.AmountFieldV2 +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.conditionalCompose +import com.tangem.core.ui.extensions.resolveReference +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.SwapDirection +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.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 +import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM + +@Composable +internal fun SwapAmountContent( + amountUM: SwapAmountUM, + clickIntents: SwapAmountClickIntents, + modifier: Modifier = Modifier, +) { + ConstraintLayout( + modifier = modifier, + ) { + val (amountFromRef, amountToRef, middleButtonRef) = createRefs() + SwapAmountBlock( + amountUM = amountUM, + amountFieldUM = amountUM.primaryAmount, + clickIntents = clickIntents, + modifier = Modifier.constrainAs(amountFromRef) { + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + ) + SwapAmountBlock( + amountUM = amountUM, + amountFieldUM = amountUM.secondaryAmount, + clickIntents = clickIntents, + modifier = Modifier.constrainAs(amountToRef) { + top.linkTo(amountFromRef.bottom, 8.dp) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + ) + SwapAmountBlockSeparator( + modifier = Modifier + .constrainAs(middleButtonRef) { + top.linkTo(amountFromRef.bottom) + bottom.linkTo(amountToRef.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + ) + } +} + +@Composable +private fun SwapAmountBlockSeparator(modifier: Modifier = Modifier) { + // todo add swap type like [Swap, SendViaSwap, SendIncognito] + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .heightIn(max = 28.dp) + .clip(RoundedCornerShape(32.dp)) + .background(TangemTheme.colors.background.secondary) + .padding( + vertical = 6.dp, + horizontal = 12.dp, + ), + ) { + Text( + text = "Convert", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + VerticalDivider( + thickness = 1.dp, + color = TangemTheme.colors.icon.inactive, + ) + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_close_24), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } +} + +@Composable +private fun SwapAmountBlock( + amountUM: SwapAmountUM, + amountFieldUM: SwapAmountFieldUM, + clickIntents: SwapAmountClickIntents, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(TangemTheme.colors.background.action), + ) { + AnimatedVisibility(amountUM.selectedAmountType == amountFieldUM.amountType) { + Box { + SwapAmountEditBlock( + amountFieldUM = amountFieldUM, + modifier = Modifier, + onValueChange = clickIntents::onAmountValueChange, + onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, + onCurrencyChange = clickIntents::onCurrencyChangeClick, + ) + HorizontalDivider( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp), + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + } + } + SwapAmountInfo( + amountUM = amountUM, + amountFieldUM = amountFieldUM, + onExpandEditField = clickIntents::onExpandEditField, + onSelectTokenClick = clickIntents::onSelectTokenClick, + onMaxAmountClick = clickIntents::onMaxValueClick, + ) + } +} + +@Composable +private fun SwapAmountEditBlock( + amountFieldUM: SwapAmountFieldUM, + onValueChange: (String) -> Unit, + onValuePastedTriggerDismiss: () -> Unit, + onCurrencyChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = 48.dp), + ) { + if (amountFieldUM.amountField !is AmountState.Data) { + TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier.width(60.dp), + ) + } else { + Text( + text = (amountFieldUM.amountField as AmountState.Data).title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + AmountFieldV2( + amountUM = amountFieldUM.amountField, + onValueChange = onValueChange, + onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, + onCurrencyChange = onCurrencyChange, + modifier = Modifier, + ) + } +} + +@Composable +private fun SwapAmountInfo( + amountUM: SwapAmountUM, + amountFieldUM: SwapAmountFieldUM, + onExpandEditField: (SwapAmountType) -> Unit, + onMaxAmountClick: () -> Unit, + onSelectTokenClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val tokenIconState = (amountFieldUM.amountField as? AmountState.Data)?.tokenIconState ?: CurrencyIconState.Loading + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + enabled = (amountFieldUM as? SwapAmountFieldUM.Content)?.isClickEnabled == true, + onClick = { + if ((amountUM as? SwapAmountUM.Content)?.swapRateType == ExpressRateType.Fixed) { + onExpandEditField(amountFieldUM.amountType) + } else { + onSelectTokenClick() + } + }, + ), + ) { + CurrencyIcon( + state = tokenIconState, + shouldDisplayNetwork = true, + modifier = Modifier.padding( + start = 16.dp, + top = 16.dp, + bottom = 16.dp, + ), + ) + SwapAmountInfoMain(amountFieldUM = amountFieldUM) + SpacerWMax() + AnimatedContent( + amountUM, + ) { wrappedAmountUM -> + if (wrappedAmountUM is SwapAmountUM.Content) { + SwapAmountInfoExtra( + amountUM = wrappedAmountUM, + amountFieldUM = amountFieldUM, + onMaxAmountClick = onMaxAmountClick, + onSelectTokenClick = onSelectTokenClick, + ) + } else { + RectangleShimmer() + } + } + } +} + +@Composable +private fun SwapAmountInfoMain(amountFieldUM: SwapAmountFieldUM, modifier: Modifier = Modifier) { + AnimatedContent( + targetState = amountFieldUM !is SwapAmountFieldUM.Content, + modifier = modifier, + ) { isContent -> + if (isContent) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + TextShimmer( + style = TangemTheme.typography.subtitle2, + modifier = Modifier.width(56.dp), + ) + TextShimmer( + style = TangemTheme.typography.caption2, + modifier = Modifier.width(72.dp), + ) + } + } else { + val amountFieldUM = amountFieldUM as SwapAmountFieldUM.Content + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = amountFieldUM.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + ) + EllipsisText( + text = amountFieldUM.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = amountFieldUM.subtitleEllipsis, + ) + } + } + } +} + +@Composable +private fun SwapAmountInfoExtra( + amountUM: SwapAmountUM.Content, + amountFieldUM: SwapAmountFieldUM, + onMaxAmountClick: () -> Unit, + onSelectTokenClick: () -> Unit, +) { + when (amountFieldUM.amountType) { + SwapAmountType.From -> when (amountUM.swapDirection) { + SwapDirection.Direct -> { + AnimatedVisibility( + visible = amountUM.selectedAmountType == amountFieldUM.amountType, + enter = fadeIn(), + exit = fadeOut(), + ) { + AmountMaxButton(onMaxAmountClick) + } + } + SwapDirection.Reverse -> { + SwapAmountInfoQuote( + quoteUM = amountUM.selectedQuote, + swapRateType = amountUM.swapRateType, + onSelectTokenClick = + onSelectTokenClick, + ) + } + } + + SwapAmountType.To -> when (amountUM.swapDirection) { + SwapDirection.Direct -> { + SwapAmountInfoQuote( + quoteUM = amountUM.selectedQuote, + swapRateType = amountUM.swapRateType, + onSelectTokenClick = onSelectTokenClick, + ) + } + SwapDirection.Reverse -> { + AnimatedVisibility( + visible = amountUM.selectedAmountType == amountFieldUM.amountType, + enter = fadeIn(), + exit = fadeOut(), + ) { + AmountMaxButton(onMaxAmountClick) + } + } + } + } +} + +@Composable +private fun AmountMaxButton(onMaxAmountClick: () -> Unit) { + SecondaryButton( + text = stringResourceSafe(R.string.send_max_amount), + size = TangemButtonSize.Small, + onClick = onMaxAmountClick, + modifier = Modifier.padding(end = 16.dp), + ) +} + +@Composable +private fun SwapAmountInfoQuote(quoteUM: SwapQuoteUM, swapRateType: ExpressRateType, onSelectTokenClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.conditionalCompose( + condition = swapRateType == ExpressRateType.Fixed, + modifier = { + clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onSelectTokenClick, + ) + }, + ), + ) { + when (quoteUM) { + is SwapQuoteUM.Content -> EllipsisText( + text = quoteUM.quoteAmountValue.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(end = 2.dp), + ) + + is SwapQuoteUM.Error, + is SwapQuoteUM.Empty, + -> Box(modifier = Modifier.padding(start = 16.dp)) + + is SwapQuoteUM.Loading -> CircularProgressIndicator( + color = TangemTheme.colors.icon.inactive, + modifier = Modifier + .padding(end = 4.dp) + .size(20.dp), + ) + + is SwapQuoteUM.Allowance -> Text( + text = "ALLOWANCE NOT IMPLEMENTED", + ) + } + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_chevron_24), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + modifier = Modifier + .padding( + end = 16.dp, + top = 24.dp, + bottom = 24.dp, + ) + .size(24.dp), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SwapAmountContent_Preview( + @PreviewParameter(SwapAmountContentPreviewProvider::class) params: SwapAmountUM, +) { + TangemThemePreview { + SwapAmountContent( + amountUM = params, + modifier = Modifier, + clickIntents = SwapAmountClickIntentsStub, + ) + } +} + +private class SwapAmountContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SwapAmountContentPreview.emptyState, + SwapAmountContentPreview.defaultState, + ) +} +// endregion \ No newline at end of file