From a9dbcbc0d1411188f0183c9c62b0defc3755dd8e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Oct 2025 17:13:12 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../UpdateAccountTokenListTransformer.kt | 10 +- features/swap/domain/build.gradle.kts | 2 + .../swap/domain/models/ui/SwapState.kt | 2 + .../models/ui/TokensDataStateExpress.kt | 26 +- .../DefaultInitialToCurrencyResolver.kt | 32 ++ .../swap/domain/InitialToCurrencyResolver.kt | 10 + .../feature/swap/domain/SwapInteractor.kt | 29 +- .../feature/swap/domain/SwapInteractorImpl.kt | 181 +++++++++- features/swap/impl/build.gradle.kts | 2 + .../swap/converters/TokensDataConverter.kt | 4 +- .../tangem/feature/swap/model/SwapModel.kt | 328 ++++++++++++++---- .../swap/model/SwapProcessDataState.kt | 3 + .../swap/models/SwapSelectTokenStateHolder.kt | 28 +- .../feature/swap/ui/SwapSelectTokenScreen.kt | 120 ++++++- 14 files changed, 687 insertions(+), 90 deletions(-) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt index 5b13ba643c..a25fa8ec5c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -4,14 +4,17 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.plus import kotlinx.collections.immutable.toPersistentList internal class UpdateAccountTokenListTransformer( @@ -46,7 +49,12 @@ internal class UpdateAccountTokenListTransformer( ) } else { TokenListUMData.TokenList( - tokensList = accountList.flatMap { (_, currencyList) -> + tokensList = persistentListOf( + TokensListItemUM.GroupTitle( + id = "available_tokens_title", + text = resourceReference(R.string.exchange_tokens_available_tokens_header), + ), + ) + accountList.flatMap { (_, currencyList) -> currencyList.asSequence().map { (isAvailable, status) -> if (isAvailable) { availableConverter.convert(status) diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 8adde9bd53..b74d66f7e2 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -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) diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index e8e10f7165..7c76557067 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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( diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt index 6e4041dee5..f67c4a5801 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -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, ) { 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, val unavailable: List, + val accountCurrencyList: List, val isAfterSearch: Boolean, +) + +data class AccountSwapAvailability( + val account: Account.CryptoPortfolio, + val currencyList: List, +) + +data class AccountSwapCurrency( + val isAvailable: Boolean, + val account: Account.CryptoPortfolio, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val providers: List, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt index b71ed02569..a9764ab530 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt @@ -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() + } + } + } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt index c62a113adc..7759a61e98 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt @@ -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? } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 97a8cfc718..73d1b16834 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -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, 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 { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index bbebf6f219..3310348e4f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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, @@ -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, + cryptoCurrenciesList: Map>, + 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, 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, 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, diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index c87d68c2df..4d5d733b5e 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -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) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt index 9d3b8ab6ac..c4318d5226 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt @@ -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, @@ -53,6 +54,7 @@ class TokensDataConverter( } } .toImmutableList(), + tokensListData = TokenListUMData.EmptyList, onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, afterSearch = group.isAfterSearch, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a8bb23deaf..760015eb46 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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() @@ -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, @@ -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, @@ -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 { + 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.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() { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 604203a8f7..d18df192c1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index d99e967096..63fc478fc5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -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, val unavailableTokens: ImmutableList, + 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, -) \ No newline at end of file +) + +internal sealed interface TokenListUMData { + + val tokensList: ImmutableList + + data class AccountList( + override val tokensList: ImmutableList, + ) : TokenListUMData + + data class TokenList( + override val tokensList: ImmutableList, + ) : TokenListUMData + + data object EmptyList : TokenListUMData { + override val tokensList: ImmutableList = persistentListOf() + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index b03970cc63..a21eb7613a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -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, 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, 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 = {},