Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-08 11:49:42 +05:00
parent 971c419623
commit 231c97e371
20 changed files with 717 additions and 56 deletions

View file

@ -60,7 +60,7 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M
@Composable
fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
if (state.isExpanded) {
ExpandedPortfolioHeader(state.state, modifier)
ExpandedPortfolioHeader(state = state.state, isCollapsable = state.isCollapsable, modifier = modifier)
} else {
TokenItem(
state = state.state,
@ -83,7 +83,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B
}
@Composable
private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier = Modifier) {
private fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
@ -130,11 +130,13 @@ private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier =
)
}
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_minimize_24),
tint = TangemTheme.colors.icon.inactive,
contentDescription = null,
)
if (isCollapsable) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_minimize_24),
tint = TangemTheme.colors.icon.inactive,
contentDescription = null,
)
}
}
}

View file

@ -43,6 +43,7 @@ sealed interface TokensListItemUM {
data class Portfolio(
val state: TokenItemState,
val isExpanded: Boolean,
val isCollapsable: Boolean,
val tokens: List<PortfolioTokensListItemUM>,
) : TokensListItemUM {
override val id: String = state.id

View file

@ -46,6 +46,7 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.domain.transaction.models)
implementation(projects.domain.account.status)
/** DI */
implementation(deps.hilt.android)

View file

@ -0,0 +1,29 @@
package com.tangem.features.onramp.swap.availablepairs.entity.converters
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.AccountStatus
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class LoadingAccountTokenItemConverter(
private val appCurrency: AppCurrency,
) : Converter<AccountStatus.CryptoPortfolio, TokensListItemUM.Portfolio> {
override fun convert(value: AccountStatus.CryptoPortfolio): TokensListItemUM.Portfolio {
val (account, currencies) = value
return TokensListItemUM.Portfolio(
state = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
tokens = currencies.flattenCurrencies().map(LoadingTokenListItemConverter::convert).toPersistentList(),
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.onramp.swap.availablepairs.entity.transformers
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.toPersistentList
internal class SetNoAvailablePairsTransformerV2(
private val appCurrency: AppCurrency,
private val accountList: Map<Account.CryptoPortfolio, List<CryptoCurrencyStatus>>,
private val isBalanceHidden: Boolean,
private val isAccountsMode: Boolean,
private val unavailableErrorText: TextReference,
) : TokenListUMTransformer {
private val unavailableConverter = OnrampTokenItemStateConverterFactory
.createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText)
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = if (isAccountsMode) {
TokenListUMData.AccountList(
tokensList = accountList.map { (account, cryptoCurrencies) ->
TokensListItemUM.Portfolio(
state = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
tokens = unavailableConverter.convertList(cryptoCurrencies)
.map(TokensListItemUM::Token)
.toPersistentList(),
)
}.toPersistentList(),
)
} else {
TokenListUMData.TokenList(
tokensList = accountList.flatMap { (_, cryptoCurrencies) ->
unavailableConverter.convertList(cryptoCurrencies)
.map(TokensListItemUM::Token)
}.toPersistentList(),
)
},
isBalanceHidden = isBalanceHidden,
warning = NotificationUM.Warning.SwapNoAvailablePair,
)
}
}

View file

@ -1,12 +1,15 @@
package com.tangem.features.onramp.swap.availablepairs.model
import arrow.core.getOrElse
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.extensions.capitalize
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
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.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -15,6 +18,8 @@ import com.tangem.domain.core.utils.getOrElse
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
@ -28,11 +33,13 @@ import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponen
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetLoadingTokenItemsTransformer
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformer
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformerV2
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.swap.entity.AccountCurrencyUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMController
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.*
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
@ -43,7 +50,7 @@ import javax.inject.Inject
private typealias AvailablePairsState = Lce<Throwable, List<SwapPairLeast>>
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class AvailableSwapPairsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
@ -53,7 +60,10 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getAvailablePairsUseCase: GetAvailablePairsUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
val state: StateFlow<TokenListUM> = tokenListUMController.state
@ -62,13 +72,17 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
private val tokenListFlow = getTokenListUseCaseFlow()
private val accountListFlow = getAccountListUseCaseFlow()
private val availablePairsByNetworkFlow = MutableStateFlow<Map<LeastTokenInfo, AvailablePairsState>>(emptyMap())
init {
initializeSearchBarCallbacks()
if (accountsFeatureToggles.isFeatureEnabled) {
subscribeOnUpdateStateV2()
} else {
subscribeOnUpdateState()
}
subscribeOnUpdateState()
initializeSearchBarCallbacks()
subscribeOnAvailablePairsUpdates()
}
@ -79,12 +93,20 @@ internal class AvailableSwapPairsModel @Inject constructor(
maybeTokenList.getOrElse(
ifLoading = { it ?: TokenList.Empty },
ifError = { TokenList.Empty },
)
.flattenCurrencies()
).flattenCurrencies()
}
.shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1)
}
private fun getAccountListUseCaseFlow(): SharedFlow<List<AccountStatus>> {
return singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(params.userWalletId))
.distinctUntilChanged()
.map { accountStatusList ->
accountStatusList.accountStatuses.toList()
}.flowOn(dispatchers.default)
.shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1)
}
private fun initializeSearchBarCallbacks() {
tokenListUMController.update(
transformer = UpdateSearchBarCallbacksTransformer(
@ -130,6 +152,53 @@ internal class AvailableSwapPairsModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnUpdateStateV2() {
combine(
flow = getAccountsAndModeFlow(),
flow2 = getAppCurrencyAndBalanceHidingFlow(),
flow3 = params.selectedStatus,
flow4 = searchManager.query,
flow5 = availablePairsByNetworkFlow
.map { it[params.selectedStatus.value?.toLeastTokenInfo()] }
.distinctUntilChanged(),
) { accountListAndMode, appCurrencyAndBalanceHiding, selectedStatus, query, availablePairsState ->
val (accountList, isAccountsMode) = accountListAndMode
availablePairsState?.fold(
ifLoading = {
SetLoadingAccountTokenListTransformer(
appCurrency = appCurrencyAndBalanceHiding.first,
accountList = accountList,
isAccountsMode = isAccountsMode,
)
},
ifContent = { pairs ->
handleContentStateV2(
appCurrencyAndBalanceHiding = appCurrencyAndBalanceHiding,
accountList = accountList,
selectedStatus = selectedStatus,
query = query,
availablePairs = pairs,
isAccountsMode = isAccountsMode,
)
},
ifError = {
handleErrorStateV2(
cause = it,
networkInfo = params.selectedStatus.value?.toLeastTokenInfo(),
accountList = accountList,
)
},
) ?: SetLoadingAccountTokenListTransformer(
appCurrency = appCurrencyAndBalanceHiding.first,
accountList = accountList,
isAccountsMode = isAccountsMode,
)
}
.onEach(tokenListUMController::update)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun handleContentState(
appCurrencyAndBalanceHiding: Pair<AppCurrency, Boolean>,
currencies: List<CryptoCurrencyStatus>,
@ -176,6 +245,53 @@ internal class AvailableSwapPairsModel @Inject constructor(
}
}
private fun handleContentStateV2(
appCurrencyAndBalanceHiding: Pair<AppCurrency, Boolean>,
accountList: List<AccountStatus>,
selectedStatus: CryptoCurrencyStatus?,
query: String,
availablePairs: List<SwapPairLeast>,
isAccountsMode: Boolean,
): TokenListUMTransformer {
val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding
val filterByQueryAccountList = accountList.associate { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.account to accountStatus.tokenList.flattenCurrencies()
.filter { it.currency != selectedStatus?.currency }
.filterByQuery(query = query)
}
}
if (availablePairs.isEmpty()) {
return SetNoAvailablePairsTransformerV2(
appCurrency = appCurrency,
accountList = filterByQueryAccountList,
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
)
}
return if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) {
SetNothingToFoundStateTransformerV2(
isBalanceHidden = isBalanceHidden,
emptySearchMessageReference = resourceReference(
id = R.string.action_buttons_swap_empty_search_message,
),
)
} else {
UpdateAccountTokenListTransformer(
appCurrency = appCurrency,
onItemClick = params.onTokenClick,
accountList = filterByQueryAccountList.filterByAvailability(availablePairs = availablePairs),
isBalanceHidden = isBalanceHidden,
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
isAccountsMode = isAccountsMode,
)
}
}
private fun handleErrorState(
cause: Throwable,
networkInfo: LeastTokenInfo?,
@ -193,6 +309,26 @@ internal class AvailableSwapPairsModel @Inject constructor(
)
}
private fun handleErrorStateV2(
cause: Throwable,
networkInfo: LeastTokenInfo?,
accountList: List<AccountStatus>,
): SetErrorWarningTransformer {
return SetErrorWarningTransformer(
cause = cause,
onRefresh = {
modelScope.launch {
if (networkInfo != null) {
accountList.filterIsInstance<AccountStatus.CryptoPortfolio>()
.forEach { (_, currencies) ->
updateAvailablePairs(networkInfo, currencies.flattenCurrencies())
}
}
}
},
)
}
private fun subscribeOnAvailablePairsUpdates() {
modelScope.launch {
params.selectedStatus
@ -203,9 +339,19 @@ internal class AvailableSwapPairsModel @Inject constructor(
val isAlreadyLoaded = availablePairsByNetworkFlow.value[networkInfo]?.isContent() == true
if (isAlreadyLoaded) return@collectLatest
val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest
updateAvailablePairs(networkInfo = networkInfo, statuses = statuses)
if (accountsFeatureToggles.isFeatureEnabled) {
val accountList = accountListFlow.firstOrNull() ?: return@collectLatest
updateAvailablePairs(
networkInfo = networkInfo,
statuses = accountList.filterIsInstance<AccountStatus.CryptoPortfolio>()
.flatMap { accountStatus ->
accountStatus.flattenCurrencies()
}.toSet().toList(),
)
} else {
val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest
updateAvailablePairs(networkInfo = networkInfo, statuses = statuses)
}
}
}
}
@ -247,6 +393,14 @@ internal class AvailableSwapPairsModel @Inject constructor(
)
}
private fun getAccountsAndModeFlow(): Flow<Pair<List<AccountStatus>, Boolean>> {
return combine(
flow = accountListFlow.distinctUntilChanged(),
flow2 = isAccountsModeEnabledUseCase().distinctUntilChanged(),
transform = ::Pair,
)
}
private fun onSearchQueryChange(newQuery: String) {
if (state.value.searchBarUM.query == newQuery) return
@ -286,6 +440,29 @@ internal class AvailableSwapPairsModel @Inject constructor(
}
}
private fun Map<Account.CryptoPortfolio, List<CryptoCurrencyStatus>>.filterByAvailability(
availablePairs: List<SwapPairLeast>,
): List<AccountAvailabilityUM> {
return map { (account, currencies) ->
AccountAvailabilityUM(
account = account,
currencyList = currencies.map { status ->
val isAvailable = availablePairs.map(SwapPairLeast::to).contains(status.toLeastTokenInfo())
val isAvailableToSwap = isAvailable &&
status.value !is CryptoCurrencyStatus.MissedDerivation &&
status.value !is CryptoCurrencyStatus.Unreachable &&
!status.currency.isCustom
AccountCurrencyUM(
cryptoCurrencyStatus = status,
isAvailable = isAvailableToSwap,
)
},
)
}
}
private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo {
return LeastTokenInfo(
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0",

View file

@ -0,0 +1,14 @@
package com.tangem.features.onramp.swap.entity
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
internal data class AccountAvailabilityUM(
val account: Account.CryptoPortfolio,
val currencyList: List<AccountCurrencyUM>,
)
internal data class AccountCurrencyUM(
val isAvailable: Boolean,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
)

View file

@ -19,6 +19,19 @@ internal data class TokenListUM(
val searchBarUM: SearchBarUM,
val availableItems: ImmutableList<TokensListItemUM>,
val unavailableItems: ImmutableList<TokensListItemUM>,
val tokensListData: TokenListUMData,
val isBalanceHidden: Boolean,
val warning: NotificationUM? = null,
)
)
internal sealed interface TokenListUMData {
data class AccountList(
val tokensList: ImmutableList<TokensListItemUM.Portfolio>,
) : TokenListUMData
data class TokenList(
val tokensList: ImmutableList<TokensListItemUM>,
) : TokenListUMData
data object EmptyList : TokenListUMData
}

View file

@ -31,6 +31,7 @@ internal class TokenListUMController @Inject constructor() {
),
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
isBalanceHidden = false,
),
)

View file

@ -0,0 +1,45 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.AccountStatus
import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingAccountTokenItemConverter
import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingTokenListItemConverter
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class SetLoadingAccountTokenListTransformer(
appCurrency: AppCurrency,
private val accountList: List<AccountStatus>,
private val isAccountsMode: Boolean,
) : TokenListUMTransformer {
private val accountListItemConverter = LoadingAccountTokenItemConverter(appCurrency)
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = if (isAccountsMode) {
TokenListUMData.AccountList(
tokensList = accountListItemConverter.convertList(
accountList.filterIsInstance<AccountStatus.CryptoPortfolio>(),
).toPersistentList(),
)
} else {
TokenListUMData.TokenList(
tokensList = accountList.flatMap { account ->
when (account) {
is AccountStatus.CryptoPortfolio -> LoadingTokenListItemConverter.convertList(
account.tokenList.flattenCurrencies(),
)
}
}.toPersistentList(),
)
},
warning = null,
)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onramp.impl.R
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -28,6 +29,7 @@ internal class SetNothingToFoundStateTransformer(
).let(::add)
}.toImmutableList(),
unavailableItems = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
isBalanceHidden = isBalanceHidden,
)
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class SetNothingToFoundStateTransformerV2(
private val isBalanceHidden: Boolean,
private val emptySearchMessageReference: TextReference,
) : TokenListUMTransformer {
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = TokenListUMData.TokenList(tokensList = buildList {
TokensListItemUM.Text(
id = emptySearchMessageReference.hashCode(),
text = emptySearchMessageReference,
).let(::add)
}.toImmutableList()),
isBalanceHidden = isBalanceHidden,
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
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.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class UpdateAccountTokenItemConverter(
private val appCurrency: AppCurrency,
private val unavailableErrorText: TextReference,
onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit,
) : Converter<AccountAvailabilityUM, TokensListItemUM.Portfolio> {
private val availableConverter = OnrampTokenItemStateConverterFactory
.createAvailableItemConverter(appCurrency, onItemClick)
private val unavailableConverter = OnrampTokenItemStateConverterFactory
.createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText)
override fun convert(value: AccountAvailabilityUM): TokensListItemUM.Portfolio {
return TokensListItemUM.Portfolio(
state = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = value.account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
tokens = value.currencyList.asSequence().map { (isAvailable, status) ->
if (isAvailable) {
availableConverter.convert(status)
} else {
unavailableConverter.convert(status)
}
}.map(TokensListItemUM::Token).toPersistentList(),
)
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
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.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.toPersistentList
internal class UpdateAccountTokenListTransformer(
private val appCurrency: AppCurrency,
private val onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit,
private val accountList: List<AccountAvailabilityUM>,
private val isBalanceHidden: Boolean,
private val unavailableErrorText: TextReference,
private val warning: NotificationUM? = null,
private val isAccountsMode: Boolean,
) : TokenListUMTransformer {
private val accountListItemConverter = UpdateAccountTokenItemConverter(
appCurrency = appCurrency,
onItemClick = onItemClick,
unavailableErrorText = unavailableErrorText,
)
private val availableConverter = OnrampTokenItemStateConverterFactory
.createAvailableItemConverter(appCurrency, onItemClick)
private val unavailableConverter = OnrampTokenItemStateConverterFactory
.createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText)
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = if (isAccountsMode) {
TokenListUMData.AccountList(
tokensList = accountListItemConverter.convertList(accountList).toPersistentList(),
)
} else {
TokenListUMData.TokenList(
tokensList = accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { (isAvailable, status) ->
if (isAvailable) {
availableConverter.convert(status)
} else {
unavailableConverter.convert(status)
}
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList(),
)
},
isBalanceHidden = isBalanceHidden,
warning = warning,
)
}
}

View file

@ -6,6 +6,11 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
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
@ -13,6 +18,8 @@ import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.utils.getOrElse
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
@ -23,13 +30,11 @@ import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.swap.entity.AccountCurrencyUM
import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
import com.tangem.features.onramp.tokenlist.entity.OnrampOperation
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMController
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer
import com.tangem.features.onramp.tokenlist.entity.*
import com.tangem.features.onramp.tokenlist.entity.transformer.*
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
@ -42,7 +47,9 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
typealias AccountCryptoList = Map<Account.CryptoPortfolio, List<CryptoCurrencyStatus>>
@Suppress("LargeClass", "LongParameterList")
internal class OnrampTokenListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
@ -55,6 +62,9 @@ internal class OnrampTokenListModel @Inject constructor(
private val rampStateManager: RampStateManager,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
) : Model() {
val state: StateFlow<TokenListUM> = tokenListUMController.state
@ -71,8 +81,11 @@ internal class OnrampTokenListModel @Inject constructor(
onActiveChange = ::onSearchBarActiveChange,
),
)
subscribeOnUpdateState()
if (accountsFeatureToggles.isFeatureEnabled) {
subscribeOnUpdateStateV2()
} else {
subscribeOnUpdateState()
}
}
private fun subscribeOnUpdateState() {
@ -95,12 +108,7 @@ internal class OnrampTokenListModel @Inject constructor(
if (query.isNotEmpty() && filterByQueryTokenList.isEmpty()) {
SetNothingToFoundStateTransformer(
isBalanceHidden = isBalanceHidden,
emptySearchMessageReference = when (params.filterOperation) {
OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message
OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message
OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message
}
.let(::resourceReference),
emptySearchMessageReference = getEmptySearchMessageReference(),
)
} else {
val isInsufficientBalanceForSell = if (params.filterOperation == OnrampOperation.SELL) {
@ -134,6 +142,62 @@ internal class OnrampTokenListModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnUpdateStateV2() {
combine(
flow = singleAccountStatusListSupplier(
SingleAccountStatusListProducer.Params(params.userWalletId),
).distinctUntilChanged(),
flow2 = getAppCurrencyAndBalanceHidingFlow(),
flow3 = isAccountsModeEnabledUseCase(),
flow4 = searchManager.query,
flow5 = hasRestrictionForSellFlow(),
) { accountList, appCurrencyAndBalanceHiding, isAccountsMode, query, hasRestrictionForSell ->
val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding
val filterByQueryAccountList = accountList.filterAccountsByQuery(query)
if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) {
updateTokenListUM(
SetNothingToFoundStateTransformerV2(
isBalanceHidden = isBalanceHidden,
emptySearchMessageReference = getEmptySearchMessageReference(),
),
)
} else {
updateTokenListUM(
SetLoadingAccountTokenListTransformer(
appCurrency = appCurrency,
accountList = accountList.accountStatuses.toList(),
isAccountsMode = isAccountsMode,
),
)
updateTokenListUM(
UpdateAccountTokenListTransformer(
appCurrency = appCurrency,
onItemClick = params.onTokenClick,
accountList = filterByQueryAccountList.filterByAvailability(),
isBalanceHidden = isBalanceHidden,
unavailableErrorText = getUnavailableTokensHeaderReference(),
warning = getSellWarning(
hasRestrictionForSell = hasRestrictionForSell,
isInsufficientBalanceForSell = accountList.isInsufficientBalanceForSell(),
),
isAccountsMode = isAccountsMode,
),
)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun getAppCurrencyAndBalanceHidingFlow(): Flow<Pair<AppCurrency, Boolean>> {
return combine(
flow = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }.distinctUntilChanged(),
flow2 = getBalanceHidingSettingsUseCase().map { it.isBalanceHidden }.distinctUntilChanged(),
transform = ::Pair,
)
}
private fun hasRestrictionForSellFlow(): Flow<Boolean> {
return if (params.filterOperation == OnrampOperation.SELL) {
getUserCountryUseCase().map { maybe ->
@ -154,25 +218,52 @@ internal class OnrampTokenListModel @Inject constructor(
}
}
private fun AccountStatusList.isInsufficientBalanceForSell(): Boolean {
return if (params.filterOperation == OnrampOperation.SELL) {
(totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() == true
} else {
false
}
}
private fun getUnavailableTokensHeaderReference() = when (params.filterOperation) {
OnrampOperation.BUY -> R.string.tokens_list_unavailable_to_purchase_header
OnrampOperation.SELL -> R.string.tokens_list_unavailable_to_sell_header
OnrampOperation.SWAP -> R.string.tokens_list_unavailable_to_swap_source_header
}.let(::resourceReference)
private fun getEmptySearchMessageReference() = when (params.filterOperation) {
OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message
OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message
OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message
}.let(::resourceReference)
private fun updateTokenListUM(transformer: TokenListUMTransformer) {
tokenListUMController.update { prevState ->
transformer.transform(prevState).apply {
if (isFirstInitialization(prevState = prevState, newState = this)) {
params.onTokenListInitialized()
modelScope.launch {
tokenListUMController.update { prevState ->
transformer.transform(prevState).apply {
if (isFirstInitialization(prevState = prevState, newState = this)) {
params.onTokenListInitialized()
}
}
}
}
}
private fun getSellWarning(hasRestrictionForSell: Boolean, isInsufficientBalanceForSell: Boolean) = when {
hasRestrictionForSell -> NotificationUM.Warning.SellingRegionalRestriction
isInsufficientBalanceForSell -> NotificationUM.Warning.InsufficientBalanceForSelling
else -> null
}
private fun isFirstInitialization(prevState: TokenListUM, newState: TokenListUM): Boolean {
return prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() &&
(newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty())
return if (accountsFeatureToggles.isFeatureEnabled) {
prevState.tokensListData == TokenListUMData.EmptyList &&
newState.tokensListData != TokenListUMData.EmptyList
} else {
prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() &&
(newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty())
}
}
private fun onSearchQueryChange(newQuery: String) {
@ -195,6 +286,16 @@ internal class OnrampTokenListModel @Inject constructor(
)
}
private fun AccountStatusList.filterAccountsByQuery(query: String) = accountStatuses.asSequence()
.associate { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> {
val filteredList = accountStatus.tokenList.flattenCurrencies().filterByQuery(query = query)
accountStatus.account to filteredList
}
}
}.filter { (_, value) -> value.isNotEmpty() }
private fun List<CryptoCurrencyStatus>.filterByQuery(query: String): List<CryptoCurrencyStatus> {
return filter {
it.currency.name.contains(other = query, ignoreCase = true) ||
@ -237,6 +338,49 @@ internal class OnrampTokenListModel @Inject constructor(
}
}
private suspend fun AccountCryptoList.filterByAvailability(): List<AccountAvailabilityUM> {
return coroutineScope {
map { (account, currencies) ->
async {
AccountAvailabilityUM(
account = account,
currencyList = currencies.map { status ->
val isOperationAvailable = checkAvailabilityByOperation(status = status)
val isNotMissedDerivation = status.value !is CryptoCurrencyStatus.MissedDerivation
val isNotLoading = status.value !is CryptoCurrencyStatus.Loading
val requirements = getAssetRequirementsUseCase(
userWalletId = userWallet.walletId,
currency = status.currency,
).getOrNull()
val isAvailableForBuy = rampStateManager.checkAssetRequirements(requirements)
val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable
val isAvailable = when (params.filterOperation) {
OnrampOperation.BUY -> {
isAvailableForBuy
} // unreachable state is available for Buy operation
OnrampOperation.SELL -> isNotUnreachable
OnrampOperation.SWAP -> {
isNotUnreachable && isAvailableForBuy
}
}
val isTotalAvailable =
isOperationAvailable && isNotMissedDerivation && isNotLoading && isAvailable
AccountCurrencyUM(
cryptoCurrencyStatus = status,
isAvailable = isTotalAvailable,
)
},
)
}
}.awaitAll()
}
}
private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean {
return when (params.filterOperation) {
OnrampOperation.BUY -> {

View file

@ -30,6 +30,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.ui.preview.PreviewTokenListUMProvider
import kotlinx.collections.immutable.ImmutableList
@ -70,6 +71,22 @@ internal fun LazyListScope.onrampTokenList(state: TokenListUM) {
tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden)
tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden)
when (val list = state.tokensListData) {
is TokenListUMData.AccountList -> list.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = state.isBalanceHidden,
)
}
is TokenListUMData.TokenList -> {
tokensList(
items = list.tokensList,
isBalanceHidden = state.isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
}
private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) {

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import kotlinx.collections.immutable.persistentListOf
internal class PreviewTokenListUMProvider : PreviewParameterProvider<TokenListUM> {
@ -42,6 +43,7 @@ internal class PreviewTokenListUMProvider : PreviewParameterProvider<TokenListUM
createUnavailableTokenItem(),
createUnavailableTokenItem(),
),
tokensListData = TokenListUMData.EmptyList,
isBalanceHidden = false,
)
}

View file

@ -43,12 +43,7 @@ import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase
import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent
@ -57,6 +52,7 @@ import com.tangem.domain.tokens.model.analytics.TokenReceiveNewAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.AVAILABLE
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.toReasonAnalyticsText
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.domain.transaction.usecase.GetEnsNameUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
@ -519,15 +515,19 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return
val tokenListState = selectedWallet.tokensListState as? WalletTokensListState.ContentState.Content ?: return
val tokenListState = selectedWallet.tokensListState
if (tokenListState.items.count { it is TokensListItemUM.Token } < 2) {
handleError(
alertState = WalletAlertState.InsufficientTokensCountForSwapping,
eventCreator = MainScreenAnalyticsEvent::ButtonSwap,
when (tokenListState) {
is WalletTokensListState.ContentState.Content -> checkSwapCryptoAvailability(
tokenCount = tokenListState.items.count { it is TokensListItemUM.Token },
)
return
is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability(
tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } },
)
WalletTokensListState.ContentState.Loading,
WalletTokensListState.ContentState.Locked,
WalletTokensListState.Empty,
-> return
}
modelScope.launch {
@ -808,4 +808,15 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
)
}
}
private fun checkSwapCryptoAvailability(tokenCount: Int) {
if (tokenCount < 2) {
handleError(
alertState = WalletAlertState.InsufficientTokensCountForSwapping,
eventCreator = MainScreenAnalyticsEvent::ButtonSwap,
)
return
}
}
}

View file

@ -88,12 +88,14 @@ internal object WalletScreenPreviewData {
TokensListItemUM.Portfolio(
tokens = textContentTokensState.items.filterIsInstance<PortfolioTokensListItemUM>(),
isExpanded = false,
isCollapsable = true,
state = AccountItemPreviewData.accountItem
.copy(iconState = AccountItemPreviewData.accountLetterIcon),
),
TokensListItemUM.Portfolio(
tokens = textContentTokensState.items.filterIsInstance<PortfolioTokensListItemUM>(),
isExpanded = true,
isCollapsable = true,
state = AccountItemPreviewData.accountItem,
),
),

View file

@ -114,6 +114,7 @@ internal class TokenListStateConverter(
return TokensListItemUM.Portfolio(
state = accountItem,
isExpanded = isExtend,
isCollapsable = true,
tokens = items.filterIsInstance<PortfolioTokensListItemUM>(),
)
}