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 =

View file

@ -11,12 +11,11 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
@ -47,6 +46,7 @@ import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
@ -61,7 +61,7 @@ internal class ActivateCampaignsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
private val urlOpener: UrlOpener,
@GlobalUiMessageSender private val messageSender: UiMessageSender,
@ -199,22 +199,30 @@ internal class ActivateCampaignsModel @Inject constructor(
urlOpener.openUrl(campaignContent.learnMoreUrl)
}
private suspend fun hasMultipleCryptoPortfolioAccounts(): Boolean {
return multiAccountListSupplier.invoke()
.first()
.any { accountList ->
accountList.accounts.filterIsInstance<Account.CryptoPortfolio>().size > 1
}
}
private fun onTokenChosen(result: ChooseTokenResult) {
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
val networkAddress = result.currency.value.networkAddress ?: return
modelScope.launch {
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
val selectedAccountUM = if (hasMultipleCryptoPortfolioAccounts()) {
when (val account = result.account.account) {
is Account.CryptoPortfolio -> SelectedAccountUM(
iconState = accountIconConverter.convert(account),
name = account.accountName.toUM().value,
)
is Account.Payment -> SelectedAccountUM(
iconState = CurrencyIconState.PaymentAccount(size = AccountIconSize.ExtraSmall),
name = account.accountName.toUM().value,
)
is Account.Virtual -> null
// Payment accounts are hidden in the chooser and don't count towards accounts mode,
// so there is no account label to show for them.
is Account.Payment,
is Account.Virtual,
-> null
}
} else {
null

View file

@ -9,7 +9,8 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
@ -53,7 +54,7 @@ internal class ActivateCampaignsModelTest {
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk()
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true)
@ -72,7 +73,7 @@ internal class ActivateCampaignsModelTest {
fun setup() {
clearMocks(
getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase,
multiAccountListSupplier,
enrollPromoCampaignUseCase,
getWalletsUseCase,
messageSender,
@ -258,7 +259,7 @@ internal class ActivateCampaignsModelTest {
}
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList<AccountList>())
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId ->
mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
@ -274,7 +275,7 @@ internal class ActivateCampaignsModelTest {
dispatchers = createTestingCoroutineDispatcherProvider(),
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
multiAccountListSupplier = multiAccountListSupplier,
enrollPromoCampaignUseCase = enrollPromoCampaignUseCase,
urlOpener = urlOpener,
messageSender = messageSender,