Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-23 17:13:12 +05:00
parent 9c296c7ced
commit a9dbcbc0d1
14 changed files with 687 additions and 90 deletions

View file

@ -36,6 +36,8 @@ dependencies {
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.express.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.models.ExpressDataError
@ -79,6 +80,7 @@ data class TokenSwapInfo(
val tokenAmount: SwapAmount,
val amountFiat: BigDecimal,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val account: Account.CryptoPortfolio?,
)
data class RequestApproveStateData(

View file

@ -1,5 +1,7 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -9,12 +11,11 @@ data class TokensDataStateExpress(
val allProviders: List<SwapProvider>,
) {
companion object {
val EMPTY =
TokensDataStateExpress(
fromGroup = CurrenciesGroup(emptyList(), emptyList(), false),
toGroup = CurrenciesGroup(emptyList(), emptyList(), false),
allProviders = emptyList(),
)
val EMPTY = TokensDataStateExpress(
fromGroup = CurrenciesGroup(emptyList(), emptyList(), emptyList(), false),
toGroup = CurrenciesGroup(emptyList(), emptyList(), emptyList(), false),
allProviders = emptyList(),
)
}
}
@ -29,5 +30,18 @@ fun TokensDataStateExpress.getGroupWithReverse(isReverseFromTo: Boolean): Curren
data class CurrenciesGroup(
val available: List<CryptoCurrencySwapInfo>,
val unavailable: List<CryptoCurrencySwapInfo>,
val accountCurrencyList: List<AccountSwapAvailability>,
val isAfterSearch: Boolean,
)
data class AccountSwapAvailability(
val account: Account.CryptoPortfolio,
val currencyList: List<AccountSwapCurrency>,
)
data class AccountSwapCurrency(
val isAvailable: Boolean,
val account: Account.CryptoPortfolio,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val providers: List<SwapProvider>,
)

View file

@ -3,8 +3,10 @@ package com.tangem.feature.swap.domain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
internal class DefaultInitialToCurrencyResolver(
@ -27,10 +29,40 @@ internal class DefaultInitialToCurrencyResolver(
}
}
override suspend fun tryGetFromCacheV2(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency? {
val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null
return if (id != initialCryptoCurrency.id.value) {
val group = state.getGroupWithReverse(isReverseFromTo)
group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id }
}
} else {
null
}
}
override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus? {
val group = state.getGroupWithReverse(isReverseFromTo)
return group.available.maxByOrNull {
it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO
}?.currencyStatus
}
override fun tryGetWithMaxAmountV2(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? {
val group = state.getGroupWithReverse(isReverseFromTo)
return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.maxByOrNull { swapAccountCurrency ->
swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount
.takeIf { swapAccountCurrency.isAvailable }
.orZero()
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
interface InitialToCurrencyResolver {
@ -15,4 +16,13 @@ interface InitialToCurrencyResolver {
): CryptoCurrencyStatus?
fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus?
suspend fun tryGetFromCacheV2(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency?
fun tryGetWithMaxAmountV2(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency?
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.domain
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
@ -29,16 +30,23 @@ interface SwapInteractor {
* Find best quote for given tokens to swap
* under the hood calls different methods to receive data, depends on permission for given token
*
* @param fromToken [Currency] from which want to swap
* @param toToken [Currency] that receive after swap
* @param amountToSwap amount you want to swap
* @param selectedFee selected fee to swap
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param providers list of providers to find quote
* @param amountToSwap amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
* @param selectedFee selected fee to swap
* @return
*/
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
providers: List<SwapProvider>,
amountToSwap: String,
reduceBalanceBy: BigDecimal,
@ -90,6 +98,19 @@ interface SwapInteractor {
isReverseFromTo: Boolean,
): CryptoCurrencyStatus?
/**
* Returns initial currency to swap as AccountSwapCurrency
*
* @param initialCryptoCurrency initial currency selected to swap
* @param state current tokens data state
* @param isReverseFromTo flag indicating the direction of the swap
*/
suspend fun getInitialCurrencyToSwapV2(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency?
fun getNativeToken(networkId: String): CryptoCurrency
interface Factory {

View file

@ -16,12 +16,16 @@ import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -87,6 +91,8 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase,
private val amountFormatter: AmountFormatter,
private val rampStateManager: RampStateManager,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val accountsFeatureToggles: AccountsFeatureToggles,
@Assisted private val userWalletId: UserWalletId,
) : SwapInteractor {
@ -102,7 +108,16 @@ internal class SwapInteractorImpl @AssistedInject constructor(
}
override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress {
val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId)
return if (accountsFeatureToggles.isFeatureEnabled) {
getAccountCurrencyTokensDataState(currency)
} else {
getCurrencyTokensDataState(currency)
}
}
private suspend fun getCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress {
val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase
.invokeMultiWalletSync(userWalletId)
.getOrElse { emptyList() }
val walletCurrencyStatusesExceptInitial = walletCurrencyStatuses
@ -146,6 +161,65 @@ internal class SwapInteractorImpl @AssistedInject constructor(
)
}
private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress {
val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull(
SingleAccountStatusListProducer.Params(userWalletId),
)?.accountStatuses.orEmpty()
val walletAccountCurrencyStatusesExceptInitial = walletAccountCurrencyStatuses
.mapNotNull { accountStatus ->
val filteredCurrencies = accountStatus.flattenCurrencies().filter {
val currencyFilter = it.currency.network.backendId != currency.network.backendId ||
it.currency.getContractAddress() != currency.getContractAddress()
val statusFilter =
it.value is CryptoCurrencyStatus.Loaded || it.value is CryptoCurrencyStatus.NoAccount
val notCustomTokenFilter = !it.currency.isCustom
statusFilter && currencyFilter && notCustomTokenFilter
}
if (filteredCurrencies.isNotEmpty()) {
accountStatus.account to filteredCurrencies
} else {
null
}
}.toMap()
if (walletAccountCurrencyStatusesExceptInitial.isEmpty()) {
return TokensDataStateExpress.EMPTY
}
val pairsLeast = getPairs(
userWallet = userWallet,
initialCurrency = LeastTokenInfo(
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0",
network = currency.network.backendId,
),
currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus ->
accountStatus.value.map { it.currency }
},
)
return TokensDataStateExpress(
fromGroup = getToCurrenciesGroupV2(
currency = currency,
leastPairs = pairsLeast.pairs,
cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial,
tokenInfoForFilter = { it.to },
tokenInfoForAvailable = { it.from },
),
toGroup = getToCurrenciesGroupV2(
currency = currency,
leastPairs = pairsLeast.pairs,
cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial,
tokenInfoForFilter = { it.from },
tokenInfoForAvailable = { it.to },
),
allProviders = pairsLeast.allProviders,
)
}
private suspend fun getToCurrenciesGroup(
currency: CryptoCurrency,
leastPairs: List<SwapPairLeast>,
@ -174,6 +248,49 @@ internal class SwapInteractorImpl @AssistedInject constructor(
return CurrenciesGroup(
available = availableCryptoCurrencies,
unavailable = unavailableCryptoCurrencies.map { CryptoCurrencySwapInfo(it, emptyList()) },
accountCurrencyList = emptyList(),
isAfterSearch = false,
)
}
private suspend fun getToCurrenciesGroupV2(
currency: CryptoCurrency,
leastPairs: List<SwapPairLeast>,
cryptoCurrenciesList: Map<Account, List<CryptoCurrencyStatus>>,
tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo,
tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo,
): CurrenciesGroup {
val filteredPairs = leastPairs.filter {
tokenInfoForFilter(it).contractAddress == currency.getContractAddress() &&
tokenInfoForFilter(it).network == currency.network.backendId
}
val accountCurrencyList = cryptoCurrenciesList.mapNotNull { (account, currencyStatusList) ->
val account = account as? Account.CryptoPortfolio ?: return@mapNotNull null
AccountSwapAvailability(
account = account,
currencyList = currencyStatusList.map { currencyStatus ->
val providers = findProvidersForPair(
cryptoCurrencyStatuses = currencyStatus,
swapPairsLeastList = filteredPairs,
tokenInfoForAvailable = tokenInfoForAvailable,
)
val isUnavailable = providers.isNullOrEmpty()
AccountSwapCurrency(
isAvailable = !isUnavailable,
account = account,
cryptoCurrencyStatus = currencyStatus,
providers = providers.orEmpty(),
)
},
)
}
return CurrenciesGroup(
available = emptyList(),
unavailable = emptyList(),
accountCurrencyList = accountCurrencyList,
isAfterSearch = false,
)
}
@ -257,7 +374,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
override suspend fun findBestQuote(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
providers: List<SwapProvider>,
amountToSwap: String,
reduceBalanceBy: BigDecimal,
@ -288,7 +407,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
manageDexSolana(
networkId = networkId,
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
provider = provider,
selectedFee = selectedFee,
amount = amount,
@ -299,7 +420,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
manageDex(
networkId = networkId,
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
provider = provider,
selectedFee = selectedFee,
amount = amount,
@ -312,7 +435,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
manageCex(
networkId = networkId,
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
@ -327,7 +452,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private suspend fun manageDex(
networkId: String,
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
provider: SwapProvider,
selectedFee: FeeType,
amount: SwapAmount,
@ -338,6 +465,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
return provider to produceDexSwapDataError(
error = ExpressDataError.DexActiveSupplyError,
fromToken = fromToken,
fromAccount = fromAccount,
amount = amount,
)
}
@ -374,7 +502,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
provider = provider,
networkId = networkId,
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
amount = amount,
selectedFee = selectedFee,
expressOperationType = expressOperationType,
@ -385,7 +515,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
quoteDataModel = maybeQuotes,
amount = amount,
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
networkId = networkId,
isAllowedToSpend = isAllowedToSpend,
isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough,
@ -400,7 +532,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private suspend fun manageDexSolana(
networkId: String,
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
provider: SwapProvider,
selectedFee: FeeType,
amount: SwapAmount,
@ -425,7 +559,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
provider = provider,
networkId = networkId,
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
amount = amount,
selectedFee = selectedFee,
expressOperationType = expressOperationType,
@ -436,7 +572,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
quoteDataModel = maybeQuotes,
amount = amount,
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
networkId = networkId,
isAllowedToSpend = true,
isBalanceWithoutFeeEnough = false,
@ -451,7 +589,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private suspend fun manageCex(
networkId: String,
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
provider: SwapProvider,
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
@ -463,7 +603,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
fromTokenStatus = fromToken,
fromAccount = fromAccount,
toTokenStatus = toToken,
toAccount = toAccount,
isAllowedToSpend = true,
isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough,
provider = provider,
@ -976,6 +1118,17 @@ internal class SwapInteractorImpl @AssistedInject constructor(
?: group.available.firstOrNull()?.currencyStatus
}
override suspend fun getInitialCurrencyToSwapV2(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): AccountSwapCurrency? {
val group = state.getGroupWithReverse(isReverseFromTo)
return initialToCurrencyResolver.tryGetFromCacheV2(userWallet, initialCryptoCurrency, state, isReverseFromTo)
?: initialToCurrencyResolver.tryGetWithMaxAmountV2(state, isReverseFromTo)
?: group.accountCurrencyList.firstNotNullOfOrNull { it.currencyList.firstOrNull { it.isAvailable } }
}
override fun getNativeToken(networkId: String): CryptoCurrency {
return repository.getNativeTokenForNetwork(networkId)
}
@ -1020,7 +1173,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
fromTokenStatus: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toTokenStatus: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
provider: SwapProvider,
isAllowedToSpend: Boolean,
isBalanceWithoutFeeEnough: Boolean,
@ -1073,7 +1228,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
quoteDataModel = quotes,
amount = amount,
fromToken = fromTokenStatus,
fromAccount = fromAccount,
toToken = toTokenStatus,
toAccount = toAccount,
networkId = networkId,
isAllowedToSpend = isAllowedToSpend,
isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough,
@ -1091,7 +1248,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
quoteDataModel: Either<ExpressDataError, QuoteModel>,
amount: SwapAmount,
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
networkId: String,
isAllowedToSpend: Boolean,
isBalanceWithoutFeeEnough: Boolean,
@ -1105,7 +1264,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val swapState = updateBalances(
networkId = networkId,
fromTokenStatus = fromToken,
fromAccount = fromAccount,
toTokenStatus = toToken,
toAccount = toAccount,
fromTokenAmount = amount,
toTokenAmount = quoteModel.toTokenAmount,
swapData = null,
@ -1133,6 +1294,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val state = updatePermissionState(
networkId = networkId,
fromTokenStatus = fromToken,
fromAccount = fromAccount,
swapAmount = amount,
quotesLoadedState = swapState,
isAllowedToSpend = isAllowedToSpend,
@ -1174,6 +1336,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
ifLeft = { error ->
createSwapErrorWith(
fromToken = fromToken,
fromAccount = fromAccount,
amount = amount,
includeFeeInAmount = includeFeeInAmount,
expressDataError = error,
@ -1184,6 +1347,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private suspend fun createSwapErrorWith(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
amount: SwapAmount,
includeFeeInAmount: IncludeFeeInAmount,
expressDataError: ExpressDataError,
@ -1194,6 +1358,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
amountFiat = rates[fromToken.currency.id]?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
account = fromAccount,
)
return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount)
}
@ -1307,7 +1472,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
provider: SwapProvider,
networkId: String,
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
amount: SwapAmount,
selectedFee: FeeType,
expressOperationType: ExpressOperationType,
@ -1345,6 +1512,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
return produceDexSwapDataError(
error = ExpressDataError.TooLargeSolanaTransactionError,
fromToken = fromToken,
fromAccount = fromAccount,
amount = amount,
)
}
@ -1384,7 +1552,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val swapState = updateBalances(
networkId = networkId,
fromTokenStatus = fromToken,
fromAccount = fromAccount,
toTokenStatus = toToken,
toAccount = toAccount,
fromTokenAmount = amount,
toTokenAmount = swapData.toTokenAmount,
swapData = swapData,
@ -1413,6 +1583,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
produceDexSwapDataError(
error = error,
fromToken = fromToken,
fromAccount = fromAccount,
amount = amount,
)
},
@ -1422,6 +1593,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private suspend fun produceDexSwapDataError(
error: ExpressDataError,
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
amount: SwapAmount,
): SwapState.SwapError {
val rates = getQuotes(fromToken.currency.id)
@ -1430,6 +1602,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
amountFiat = rates[fromToken.currency.id]?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
account = fromAccount,
)
return SwapState.SwapError(
fromTokenSwapInfo,
@ -1498,7 +1671,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
provider: SwapProvider,
networkId: String,
fromTokenStatus: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toTokenStatus: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
fromTokenAmount: SwapAmount,
toTokenAmount: SwapAmount,
swapData: SwapDataModel?,
@ -1511,6 +1686,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = fromTokenAmount,
account = fromAccount,
cryptoCurrencyStatus = fromTokenStatus,
amountFiat = rates[fromToken.id]?.multiply(fromTokenAmount.value)
?: BigDecimal.ZERO,
@ -1518,6 +1694,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
toTokenInfo = TokenSwapInfo(
tokenAmount = toTokenAmount,
cryptoCurrencyStatus = toTokenStatus,
account = toAccount,
amountFiat = rates[toToken.id]?.multiply(toTokenAmount.value)
?: BigDecimal.ZERO,
),
@ -1564,6 +1741,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
private suspend fun updatePermissionState(
networkId: String,
fromTokenStatus: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
swapAmount: SwapAmount,
quotesLoadedState: SwapState.QuotesLoadedState,
spenderAddress: String?,
@ -1620,6 +1798,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
// it's impossible next steps without fee
return createSwapErrorWith(
fromToken = fromTokenStatus,
fromAccount = fromAccount,
amount = swapAmount,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,

View file

@ -44,6 +44,8 @@ dependencies {
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.express.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
/** Feature modules */
implementation(projects.features.swap.domain)

View file

@ -12,13 +12,14 @@ import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo
import com.tangem.feature.swap.models.CurrenciesGroupWithFromCurrency
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.TokenToSelectState
import com.tangem.feature.swap.presentation.R
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
class TokensDataConverter(
internal class TokensDataConverter(
private val onSearchEntered: (String) -> Unit,
private val onTokenSelected: (String) -> Unit,
private val isBalanceHiddenProvider: Provider<Boolean>,
@ -53,6 +54,7 @@ class TokensDataConverter(
}
}
.toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
onSearchEntered = onSearchEntered,
onTokenSelected = onTokenSelected,
afterSearch = group.isAfterSearch,

View file

@ -20,6 +20,10 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -29,6 +33,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -105,6 +110,9 @@ internal class SwapModel @Inject constructor(
swapInteractorFactory: SwapInteractor.Factory,
private val urlOpener: UrlOpener,
router: AppRouter,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -158,6 +166,10 @@ internal class SwapModel @Inject constructor(
private var swapRouter: SwapRouter = SwapRouter(router = router)
private var userCountry: UserCountry? = null
private lateinit var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus
private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null
private var isAccountsMode: Boolean = false
private val isUserResolvableError: (SwapState) -> Boolean = {
it is SwapState.SwapError &&
(
@ -183,19 +195,48 @@ internal class SwapModel @Inject constructor(
}
modelScope.launch(dispatchers.io) {
val fromStatus =
getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId, initialCurrencyFrom.id)
.getOrNull()
val toStatus = initialCurrencyTo?.let {
getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId, it.id).getOrNull()
}
if (accountsFeatureToggles.isFeatureEnabled) {
isAccountsMode = isAccountsModeEnabledUseCase.invokeSync()
if (fromStatus == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back)
val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWalletId,
currency = initialCurrencyFrom,
).getOrNull()
val toAccountStatus = initialCurrencyTo?.let {
getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWalletId,
currency = it,
).getOrNull()
}
if (fromAccountStatus == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back)
} else {
fromAccountCurrencyStatus = fromAccountStatus
toAccountCurrencyStatus = toAccountStatus
initialFromStatus = fromAccountStatus.status
initialToStatus = toAccountStatus?.status
initTokens(isInitiallyReversed)
}
} else {
initialFromStatus = fromStatus
initialToStatus = toStatus
initTokens(isInitiallyReversed)
val fromStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWalletId = userWalletId,
cryptoCurrencyId = initialCurrencyFrom.id,
).getOrNull()
val toStatus = initialCurrencyTo?.let {
getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWalletId = userWalletId,
cryptoCurrencyId = it.id,
).getOrNull()
}
if (fromStatus == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back)
} else {
initialFromStatus = fromStatus
initialToStatus = toStatus
initTokens(isInitiallyReversed)
}
}
}
@ -225,7 +266,10 @@ internal class SwapModel @Inject constructor(
private fun sendSelectTokenScreenOpenedEvent() {
val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() == true
val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() == true
val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom
val isAnyAvailableAccountTokensTo = !dataState.tokensDataState?.toGroup?.accountCurrencyList.isNullOrEmpty()
val isAnyAvailableAccountTokensFrom = !dataState.tokensDataState?.fromGroup?.accountCurrencyList.isNullOrEmpty()
val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom ||
isAnyAvailableAccountTokensTo || isAnyAvailableAccountTokensFrom
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(availableTokens = isAnyAvailableTokens))
}
@ -235,15 +279,32 @@ internal class SwapModel @Inject constructor(
swapInteractor.getTokensDataState(initialCurrencyFrom)
}.onSuccess { state ->
updateTokensState(state)
val selectedCurrency = initialToStatus ?: swapInteractor.getInitialCurrencyToSwap(
initialCryptoCurrency = initialCurrencyFrom,
state = state,
isReverseFromTo = isReverseFromTo,
)
val (selectedCurrency, selectedAccount) = if (accountsFeatureToggles.isFeatureEnabled) {
val selectedAccountCurrency = toAccountCurrencyStatus ?: swapInteractor.getInitialCurrencyToSwapV2(
initialCryptoCurrency = initialCurrencyFrom,
state = state,
isReverseFromTo = isReverseFromTo,
)?.let {
AccountCryptoCurrencyStatus(
account = it.account,
status = it.cryptoCurrencyStatus,
)
}
selectedAccountCurrency?.status to selectedAccountCurrency?.account
} else {
val selectedCurrency = initialToStatus ?: swapInteractor.getInitialCurrencyToSwap(
initialCryptoCurrency = initialCurrencyFrom,
state = state,
isReverseFromTo = isReverseFromTo,
)
selectedCurrency to null
}
applyInitialTokenChoice(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isReverseFromTo,
)
@ -268,6 +329,7 @@ internal class SwapModel @Inject constructor(
applyInitialTokenChoice(
state = TokensDataStateExpress.EMPTY,
selectedCurrency = null,
selectedAccount = null,
isReverseFromTo = isReverseFromTo,
)
@ -304,6 +366,7 @@ internal class SwapModel @Inject constructor(
private fun applyInitialTokenChoice(
state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus?,
selectedAccount: Account.CryptoPortfolio?,
isReverseFromTo: Boolean,
) {
// exceptional case
@ -321,14 +384,27 @@ internal class SwapModel @Inject constructor(
} else {
initialFromStatus to selectedCurrency
}
val (fromAccount, toAccount) = if (accountsFeatureToggles.isFeatureEnabled) {
if (isOrderReversed) {
selectedAccount to fromAccountCurrencyStatus.account
} else {
fromAccountCurrencyStatus.account to selectedAccount
}
} else {
null to null
}
dataState = dataState.copy(
fromCryptoCurrency = fromCurrencyStatus,
fromAccount = fromAccount,
toCryptoCurrency = toCurrencyStatus,
toAccount = toAccount,
tokensDataState = state,
)
startLoadingQuotes(
fromToken = fromCurrencyStatus,
fromAccount = fromAccount,
toToken = toCurrencyStatus,
toAccount = toAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
@ -337,16 +413,28 @@ internal class SwapModel @Inject constructor(
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup
uiState = stateBuilder.addTokensToState(
uiState = uiState,
tokensDataState = tokensDataState,
fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom,
)
uiState = if (accountsFeatureToggles.isFeatureEnabled) {
// stateBuilder.addTokensToStateV2(
// uiState = uiState,
// tokensDataState = tokensDataState,
// isAccountsMode = isAccountsMode,
// )
TODO()
} else {
stateBuilder.addTokensToState(
uiState = uiState,
tokensDataState = tokensDataState,
fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom,
)
}
}
private fun startLoadingQuotes(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
@ -355,17 +443,19 @@ internal class SwapModel @Inject constructor(
singleTaskScheduler.cancelTask()
if (!isSilent) {
uiState = stateBuilder.createQuotesLoadingState(
uiState,
fromToken.currency,
toToken.currency,
initialCurrencyFrom.id.value,
uiStateHolder = uiState,
fromToken = fromToken.currency,
toToken = toToken.currency,
mainTokenId = initialCurrencyFrom.id.value,
)
}
singleTaskScheduler.scheduleTask(
modelScope,
loadQuotesTask(
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
toProvidersList = toProvidersList,
@ -380,7 +470,9 @@ internal class SwapModel @Inject constructor(
if (fromCurrency != null && toCurrency != null && amount != null) {
startLoadingQuotes(
fromToken = fromCurrency,
fromAccount = dataState.fromAccount,
toToken = toCurrency,
toAccount = dataState.toAccount,
amount = amount,
isSilent = isSilent,
reduceBalanceBy = dataState.reduceBalanceBy,
@ -391,7 +483,9 @@ internal class SwapModel @Inject constructor(
private fun loadQuotesTask(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
@ -409,7 +503,9 @@ internal class SwapModel @Inject constructor(
)
swapInteractor.findBestQuote(
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
providers = toProvidersList,
amountToSwap = amount,
reduceBalanceBy = reduceBalanceBy,
@ -774,6 +870,7 @@ internal class SwapModel @Inject constructor(
} else {
tokenDataState.toGroup
}
val available = group.available.filter {
it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
@ -782,11 +879,28 @@ internal class SwapModel @Inject constructor(
it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
}
val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability ->
val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency ->
val currency = accountSwapCurrency.cryptoCurrencyStatus.currency
currency.name.contains(searchQuery, ignoreCase = true) ||
currency.symbol.contains(searchQuery, ignoreCase = true)
}
if (filteredCurrencies.isEmpty()) {
return@mapNotNull null
}
accountSwapAvailability.copy(
currencyList = filteredCurrencies,
)
}
val filteredTokenDataState = if (isOrderReversed) {
tokenDataState.copy(
fromGroup = tokenDataState.fromGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
@ -795,6 +909,7 @@ internal class SwapModel @Inject constructor(
toGroup = tokenDataState.toGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
@ -805,25 +920,26 @@ internal class SwapModel @Inject constructor(
private fun onTokenSelect(id: String) {
val tokens = dataState.tokensDataState ?: return
val foundToken = if (isOrderReversed) {
tokens.fromGroup.available.firstOrNull {
it.currencyStatus.currency.id.value == id
}
} else {
tokens.toGroup.available.firstOrNull {
it.currencyStatus.currency.id.value == id
}
}
foundToken?.currencyStatus?.currency?.symbol?.let {
val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id)
foundToken?.currency?.symbol?.let {
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it))
}
if (foundToken != null) {
val fromToken: CryptoCurrencyStatus
val fromAccount: Account.CryptoPortfolio?
val toToken: CryptoCurrencyStatus
val toAccount: Account.CryptoPortfolio?
if (isOrderReversed) {
fromToken = foundToken.currencyStatus
fromToken = foundToken
fromAccount = foundAccount
toToken = initialFromStatus
toAccount = if (accountsFeatureToggles.isFeatureEnabled) {
fromAccountCurrencyStatus.account
} else {
null
}
val newToken = fromToken.currency as? CryptoCurrency.Coin
if (newToken != null) {
@ -835,7 +951,13 @@ internal class SwapModel @Inject constructor(
}
} else {
fromToken = initialFromStatus
toToken = foundToken.currencyStatus
fromAccount = if (accountsFeatureToggles.isFeatureEnabled) {
fromAccountCurrencyStatus.account
} else {
null
}
toToken = foundToken
toAccount = foundAccount
val newToken = toToken.currency as? CryptoCurrency.Coin
if (newToken != null) {
@ -853,12 +975,16 @@ internal class SwapModel @Inject constructor(
dataState = dataState.copy(
fromCryptoCurrency = fromToken,
fromAccount = fromAccount,
toCryptoCurrency = toToken,
toAccount = toAccount,
selectedProvider = null,
)
startLoadingQuotes(
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromToken, toToken),
@ -868,6 +994,28 @@ internal class SwapModel @Inject constructor(
}
}
private fun getSelectedTokenAndAccount(
tokens: TokensDataStateExpress,
id: String,
): Pair<CryptoCurrencyStatus?, Account.CryptoPortfolio?> {
return if (accountsFeatureToggles.isFeatureEnabled) {
val accountCryptoCurrencyStatus = if (isOrderReversed) {
tokens.fromGroup
} else {
tokens.toGroup
}.accountCurrencyList.firstNotNullOfOrNull {
it.currencyList.firstOrNull { it.cryptoCurrencyStatus.currency.id.value == id }
}
accountCryptoCurrencyStatus?.cryptoCurrencyStatus to accountCryptoCurrencyStatus?.account
} else {
if (isOrderReversed) {
tokens.fromGroup
} else {
tokens.toGroup
}.available.firstOrNull { it.currencyStatus.currency.id.value == id }?.currencyStatus to null
}
}
private fun subscribeToCoinBalanceUpdates(
userWalletId: UserWalletId,
coin: CryptoCurrency.Coin,
@ -875,34 +1023,65 @@ internal class SwapModel @Inject constructor(
) {
Timber.d("Subscribe to ${coin.id} balance updates")
getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = userWalletId,
currencyId = coin.id,
isSingleWalletWithTokens = false,
)
.mapNotNull { (it as? Either.Right)?.value }
.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes
.onEach {
Timber.d("${coin.id} balance is ${it.value.amount}")
if (accountsFeatureToggles.isFeatureEnabled) {
getAccountCurrencyStatusUseCase(
userWalletId = userWalletId,
currency = coin,
).distinctUntilChanged { old, new -> old.status.value.amount == new.status.value.amount } // Check only balance changes
.onEach { (account, currencyStatus) ->
Timber.d("${coin.id} balance is ${currencyStatus.value.amount}")
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = it,
).getOrNull() ?: it,
)
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
uiState = if (isFromCurrency) {
dataState = dataState.copy(fromCryptoCurrency = it)
stateBuilder.updateSendCurrencyBalance(uiState, it)
} else {
dataState = dataState.copy(toCryptoCurrency = it)
stateBuilder.updateReceiveCurrencyBalance(uiState, it)
uiState = if (isFromCurrency) {
dataState = dataState.copy(
fromCryptoCurrency = currencyStatus,
fromAccount = account,
)
stateBuilder.updateSendCurrencyBalance(uiState, currencyStatus)
} else {
dataState = dataState.copy(
toCryptoCurrency = currencyStatus,
toAccount = account,
)
stateBuilder.updateReceiveCurrencyBalance(uiState, currencyStatus)
}
startLoadingQuotesFromLastState(isSilent = true)
}
} else {
getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = userWalletId,
currencyId = coin.id,
isSingleWalletWithTokens = false,
).mapNotNull { (it as? Either.Right)?.value }
.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes
.onEach {
Timber.d("${coin.id} balance is ${it.value.amount}")
startLoadingQuotesFromLastState(isSilent = true)
}
.flowOn(dispatchers.main)
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = it,
).getOrNull() ?: it,
)
uiState = if (isFromCurrency) {
dataState = dataState.copy(fromCryptoCurrency = it)
stateBuilder.updateSendCurrencyBalance(uiState, it)
} else {
dataState = dataState.copy(toCryptoCurrency = it)
stateBuilder.updateReceiveCurrencyBalance(uiState, it)
}
startLoadingQuotesFromLastState(isSilent = true)
}
}.flowOn(dispatchers.main)
.launchIn(modelScope)
.saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder)
}
@ -910,14 +1089,18 @@ internal class SwapModel @Inject constructor(
private fun onChangeCardsClicked() {
modelScope.launch {
val newFromToken = dataState.toCryptoCurrency
val newFromAccount = dataState.toAccount
val newToToken = dataState.fromCryptoCurrency
val newToAccount = dataState.fromAccount
if (newFromToken != null && newToToken != null) {
isAmountChangedByUser = true
dataState = dataState.copy(
fromCryptoCurrency = newFromToken,
fromAccount = newFromAccount,
toCryptoCurrency = newToToken,
toAccount = newToAccount,
)
isOrderReversed = !isOrderReversed
dataState.tokensDataState?.let {
@ -940,7 +1123,9 @@ internal class SwapModel @Inject constructor(
)
startLoadingQuotes(
fromToken = newFromToken,
fromAccount = newFromAccount,
toToken = newToToken,
toAccount = newToAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(newFromToken, newToToken),
@ -982,7 +1167,9 @@ internal class SwapModel @Inject constructor(
amountDebouncer.debounce(modelScope, DEBOUNCE_AMOUNT_DELAY, forceUpdate = forceQuotesUpdate) {
startLoadingQuotes(
fromToken = fromToken,
fromAccount = dataState.fromAccount,
toToken = toToken,
toAccount = dataState.toAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromToken, toToken),
@ -1267,7 +1454,14 @@ internal class SwapModel @Inject constructor(
toToken.currency.id.value
}
return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers.orEmpty()
return if (accountsFeatureToggles.isFeatureEnabled) {
groupToFind.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.find { idToFind == it.cryptoCurrencyStatus.currency.id.value && it.isAvailable }
}?.providers
} else {
groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }
?.providers
}.orEmpty()
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
@ -1293,9 +1487,11 @@ internal class SwapModel @Inject constructor(
val chosen = if (isOrderReversed) from else to
return currenciesGroup.available
.map { it.currencyStatus.currency }
.contains(chosen.currency)
return if (accountsFeatureToggles.isFeatureEnabled) {
currenciesGroup.accountCurrencyList.flatMap { it.currencyList.map { it.cryptoCurrencyStatus } }
} else {
currenciesGroup.available.map { it.currencyStatus }
}.map { it.currency }.contains(chosen.currency)
}
private fun sendPermissionApproveClickedEvent() {

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.model
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -14,6 +15,8 @@ data class SwapProcessDataState(
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
val toCryptoCurrency: CryptoCurrencyStatus? = null,
val feePaidCryptoCurrency: CryptoCurrencyStatus? = null,
val fromAccount: Account.CryptoPortfolio? = null,
val toAccount: Account.CryptoPortfolio? = null,
// Amount from input
val amount: String? = null,
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,

View file

@ -1,18 +1,21 @@
package com.tangem.feature.swap.models
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class SwapSelectTokenStateHolder(
internal data class SwapSelectTokenStateHolder(
val availableTokens: ImmutableList<TokenToSelectState>,
val unavailableTokens: ImmutableList<TokenToSelectState>,
val tokensListData: TokenListUMData,
val afterSearch: Boolean,
val onSearchEntered: (String) -> Unit,
val onTokenSelected: (String) -> Unit,
)
sealed class TokenToSelectState {
internal sealed class TokenToSelectState {
data class Title(val title: TextReference) : TokenToSelectState()
@ -26,8 +29,25 @@ sealed class TokenToSelectState {
) : TokenToSelectState()
}
data class TokenBalanceData(
internal data class TokenBalanceData(
val amount: String?,
val amountEquivalent: String?,
val isBalanceHidden: Boolean,
)
)
internal sealed interface TokenListUMData {
val tokensList: ImmutableList<TokensListItemUM>
data class AccountList(
override val tokensList: ImmutableList<TokensListItemUM.Portfolio>,
) : TokenListUMData
data class TokenList(
override val tokensList: ImmutableList<TokensListItemUM>,
) : TokenListUMData
data object EmptyList : TokenListUMData {
override val tokensList: ImmutableList<TokensListItemUM> = persistentListOf()
}
}

View file

@ -14,32 +14,39 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW2
import com.tangem.core.ui.components.appbar.ExpandableSearchView
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.tokenlist.PortfolioListItem
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
import com.tangem.core.ui.components.tokenlist.TokenListItem
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.TokenToSelectState
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
BackHandler(onBack = onBack)
Scaffold(
@ -49,10 +56,12 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit)
content = { padding ->
val modifier = Modifier.padding(padding)
when {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && state.afterSearch -> {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && state.afterSearch -> {
TokensNotFound(modifier)
}
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && !state.afterSearch -> {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && !state.afterSearch -> {
EmptyTokensList(modifier)
}
else -> {
@ -133,6 +142,22 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val list = state.tokensListData) {
is TokenListUMData.AccountList -> list.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = false, // state.isBalanceHidden,
)
}
is TokenListUMData.TokenList -> {
tokensList(
items = list.tokensList,
isBalanceHidden = false, // state.isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
item { SpacerH12() }
@ -143,6 +168,86 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
}
}
private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isBalanceHidden: Boolean) {
itemsIndexed(
items = items,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { index, item ->
TokenListItem(
state = item,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = items.lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },
)
},
)
}
internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portfolio, isBalanceHidden: Boolean) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
portfolioItem(
portfolio = portfolio,
modifier = Modifier.padding(top = 8.dp),
isBalanceHidden = isBalanceHidden,
)
if (!isExpanded) return
itemsIndexed(
items = tokens,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
.roundedShapeItemDecoration(
currentIndex = indexWithHeader,
lastIndex = tokens.lastIndex.inc(),
backgroundColor = TangemTheme.colors.background.primary,
)
.conditional(tokenIndex == tokens.lastIndex) {
Modifier.padding(bottom = 8.dp)
},
)
},
)
}
private fun LazyListScope.portfolioItem(
portfolio: TokensListItemUM.Portfolio,
modifier: Modifier,
isBalanceHidden: Boolean,
) {
item(
key = "account-${portfolio.id}",
contentType = "account",
) {
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = portfolio.tokens.lastIndex.inc(),
backgroundColor = TangemTheme.colors.background.primary,
)
.then(modifier),
)
}
}
private fun LazyListScope.tokensToSelectItems(
items: ImmutableList<TokenToSelectState>,
onTokenClick: (String) -> Unit,
@ -311,6 +416,7 @@ private fun TokenScreenPreview() {
state = SwapSelectTokenStateHolder(
availableTokens = listOf(title, token, token, token).toImmutableList(),
unavailableTokens = listOf(title, token, token, token).toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
afterSearch = false,
onSearchEntered = {},
onTokenSelected = {},