Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-22 10:58:25 +02:00
parent 6ad742d678
commit 9101098d72
6 changed files with 116 additions and 34 deletions

View file

@ -101,8 +101,9 @@ internal class ChooseTokenListItemConverter(
private fun AccountStatus.CryptoPortfolio.toPortfolioItem(
params: TokenConverterParams.Account,
): TokensListItemUM.Portfolio {
val tokenList: TokenList = this.tokenList
val account: Account.CryptoPortfolio = this.account
val displayedStatus = filterForDisplay()
val account: Account.CryptoPortfolio = displayedStatus.account
val displayedTokenList: TokenList = displayedStatus.tokenList
val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId)
val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount ->
onAccountItemClick(clickedAccount, isExpanded)
@ -116,10 +117,9 @@ internal class ChooseTokenListItemConverter(
fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) },
subtitle2StateProvider = { _ -> null },
)
val accountItem = converter.convert(tokenList.totalFiatBalance)
val tokenConverter = tokenStatusConverter(this)
val tokensListState = convertTokenList(tokenConverter, tokenList, this)
val items = tokensListState.tokensList
val accountItem = converter.convert(displayedTokenList.totalFiatBalance)
val items = displayedTokenList.toUmData(tokenStatusConverter(this)).tokensList
return TokensListPortfolioItemConverter(
tokenItemUM = accountItem,
isExpanded = isExpanded,
@ -128,22 +128,30 @@ internal class ChooseTokenListItemConverter(
).convert(Unit)
}
private fun AccountStatus.CryptoPortfolio.filterForDisplay(): AccountStatus.CryptoPortfolio {
val filteredTokenList = filterTokenList(tokenList, this)
return copy(
account = account.copy(cryptoCurrencies = filteredTokenList.flattenCurrencies().map { it.currency }),
tokenList = filteredTokenList,
)
}
private fun convertTokenList(
tokenConverter: TokenItemStateConverter,
tokenListParam: TokenList,
account: AccountStatus.CryptoPortfolio,
): TokenListUMData {
return when (val tokenList = filterTokenList(tokenListParam, account)) {
is TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
}
): TokenListUMData = filterTokenList(tokenListParam, account).toUmData(tokenConverter)
private fun TokenList.toUmData(tokenConverter: TokenItemStateConverter): TokenListUMData = when (this) {
TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = flattenCurrencies().size,
)
}
private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> =

View file

@ -7,6 +7,8 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.*
@ -20,6 +22,7 @@ import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -32,6 +35,7 @@ internal class ChooseTokenModel @Inject constructor(
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory,
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
paramsContainer: ParamsContainer,
) : Model() {
@ -62,6 +66,13 @@ internal class ChooseTokenModel @Inject constructor(
)
}
/** Tokens the user already holds in the selected wallet — subtracted from the predefined "Other eligible" block. */
@OptIn(ExperimentalCoroutinesApi::class)
private val portfolioTokenKeysFlow: Flow<Set<Pair<String, String>>> = bridge.selectedWalletFlow
.flatMapLatest { wallet -> singleAccountStatusListSupplier(wallet.walletId) }
.map { accountStatusList -> accountStatusList.toTokenKeys() }
.onStart { emit(emptySet()) }
private val predefinedTokensBlockDelegate: PredefinedTokensBlockDelegate by lazy {
val block = bridge.settings.chooserBlock as ChooserBlock.Predefined
predefinedTokensBlockDelegateFactory.create(
@ -71,6 +82,7 @@ internal class ChooseTokenModel @Inject constructor(
addToPortfolioSlot = bottomSheetNavigation,
modelScope = modelScope,
tokenFilter = bridge.tokenFilter,
portfolioTokenKeys = portfolioTokenKeysFlow,
)
}
@ -124,6 +136,12 @@ internal class ChooseTokenModel @Inject constructor(
.launchIn(modelScope)
}
private fun AccountStatusList.toTokenKeys(): Set<Pair<String, String>> =
flattenCurrencies().mapNotNullTo(hashSetOf()) { status ->
val rawId = status.currency.id.rawCurrencyId?.value ?: return@mapNotNullTo null
rawId to status.currency.network.rawId
}
fun onBackClicked() {
bridge.onClose()
}

View file

@ -33,6 +33,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
@Assisted private val portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
) {
init {
@ -44,8 +45,13 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
val stateFlow: Flow<PredefinedTokensUM?> = combine(
predefinedTokens,
searchQueryState,
) { tokens, query ->
val filtered = tokens.filter { it.hasValidNetwork() && it.matchesQuery(query.value) }
portfolioTokenKeys,
) { tokens, query, portfolioKeys ->
val filtered = tokens.filter { token ->
token.hasValidNetwork() &&
token.matchesQuery(query.value) &&
!portfolioKeys.contains(token.toKey())
}
if (filtered.isEmpty()) {
null
} else {
@ -66,6 +72,9 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
}
}
/** Identity of a predefined token as `(rawCurrencyId, networkId)` — matches the portfolio token keys. */
private fun PredefinedTokenToAdd.toKey(): Pair<String, String> = token.id.value to network.networkId
private fun PredefinedTokenToAdd.hasValidNetwork(): Boolean =
network.networkId.isNotBlank() && network.decimalCount != null
@ -104,6 +113,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
modelScope: CoroutineScope,
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
): PredefinedTokensBlockDelegate
}
}

View file

@ -125,6 +125,41 @@ internal class PredefinedTokensBlockDelegateTest {
assertThat(actual).isNull()
}
@Test
fun `GIVEN predefined token already in portfolio WHEN state emitted THEN it is excluded`() = runTest {
// Arrange
val tokens = listOf(
createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID),
createPredefinedToken(id = "tether", symbol = "USDT", networkId = ETHEREUM_NETWORK_ID),
)
val delegate = createDelegate(
predefinedTokens = MutableStateFlow(tokens),
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
)
// Act
val actual = lastState(delegate)
// Assert — usd-coin is already in the portfolio, so only tether stays in "Other eligible tokens"
assertThat(actual?.items?.map { it.id }).containsExactly("tether_$ETHEREUM_NETWORK_ID")
}
@Test
fun `GIVEN all predefined tokens already in portfolio WHEN state emitted THEN emits null`() = runTest {
// Arrange
val token = createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID)
val delegate = createDelegate(
predefinedTokens = MutableStateFlow(listOf(token)),
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
)
// Act
val actual = lastState(delegate)
// Assert
assertThat(actual).isNull()
}
@ParameterizedTest
@ProvideTestModels
fun filter(model: FilterModel) = runTest {
@ -234,6 +269,7 @@ internal class PredefinedTokensBlockDelegateTest {
searchQueryState: MutableStateFlow<SearchQuery> = MutableStateFlow(SearchQuery.Empty),
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow({ _, _ -> true }),
portfolioTokenKeys: MutableStateFlow<Set<Pair<String, String>>> = MutableStateFlow(emptySet()),
): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate(
predefinedTokens = predefinedTokens,
searchQueryState = searchQueryState,
@ -241,6 +277,7 @@ internal class PredefinedTokensBlockDelegateTest {
addToPortfolioSlot = addToPortfolioSlot,
modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)),
tokenFilter = tokenFilter,
portfolioTokenKeys = portfolioTokenKeys,
)
private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus =