Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-26 12:57:29 +05:00
parent f6d9c35468
commit 16004fe75a
13 changed files with 1600 additions and 0 deletions

View file

@ -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<Boolean>
data class AmountParams(
override val amountUM: SwapAmountUM,
override val analyticsCategoryName: String,
override val userWallet: UserWallet,
override val isBalanceHidingFlow: StateFlow<Boolean>,
override val appCurrency: AppCurrency,
override val swapDirection: SwapDirection,
val primaryCryptoCurrency: CryptoCurrency,
val secondaryCryptoCurrency: CryptoCurrency?,
val callback: SwapAmountComponent.ModelCallback,
val currentRoute: Flow<SwapRoute.Amount>,
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<Boolean>,
override val appCurrency: AppCurrency,
override val swapDirection: SwapDirection,
val primaryCryptoCurrencyStatus: CryptoCurrencyStatus,
val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus?,
val blockClickEnableFlow: StateFlow<Boolean>,
) : SwapAmountComponentParams()
}

View file

@ -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<SwapAmountUM>
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)
}
}

View file

@ -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())
}
}
}

View file

@ -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
}
}

View file

@ -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<Unit, SwapAmountUM> {
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,
)
}
}

View file

@ -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<SwapAmountUM> {
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),
)
},
)
}
}

View file

@ -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<SwapAmountUM> {
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,
),
)
},
)
}
}

View file

@ -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<SwapAmountUM> {
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
},
)
}
}

View file

@ -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<SwapQuoteUM>,
private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus,
private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus,
private val secondaryMaximumAmountBoundary: EnterAmountBoundary,
private val secondaryMinimumAmountBoundary: EnterAmountBoundary,
) : Transformer<SwapAmountUM> {
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<SwapQuoteUM>, bestQuote: SwapQuoteUM): 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)
} 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>): SwapQuoteUM? {
return quotes
.sortedWith(SwapQuotesComparator)
.firstOrNull()
}
private object SwapQuotesComparator : 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)
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
}
}
}

View file

@ -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<SwapAmountUM> {
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),
)
},
)
}
}

View file

@ -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<SwapAmountUM> {
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),
)
},
)
}
}

View file

@ -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<SwapAmountUM> {
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
},
)
}
}

View file

@ -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<SwapAmountUM> {
override val values: Sequence<SwapAmountUM>
get() = sequenceOf(
SwapAmountContentPreview.emptyState,
SwapAmountContentPreview.defaultState,
)
}
// endregion