diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt index 84238b8387..288a503c4a 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt @@ -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.filterCurrencies(account: AccountStatus): List = diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt index a824606076..31bc97edf0 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt @@ -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>> = 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> = + flattenCurrencies().mapNotNullTo(hashSetOf()) { status -> + val rawId = status.currency.id.rawCurrencyId?.value ?: return@mapNotNullTo null + rawId to status.currency.network.rawId + } + fun onBackClicked() { bridge.onClose() } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt index 2b69771852..647c3b3965 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt @@ -33,6 +33,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor( @Assisted private val addToPortfolioSlot: SlotNavigation, @Assisted private val modelScope: CoroutineScope, @Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>, + @Assisted private val portfolioTokenKeys: Flow>>, ) { init { @@ -44,8 +45,13 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor( val stateFlow: Flow = 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 = 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, modelScope: CoroutineScope, tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>, + portfolioTokenKeys: Flow>>, ): PredefinedTokensBlockDelegate } } \ No newline at end of file diff --git a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegateTest.kt b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegateTest.kt index 3dcdd6db5b..9edb023e69 100644 --- a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegateTest.kt +++ b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegateTest.kt @@ -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 = MutableStateFlow(SearchQuery.Empty), tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> = MutableStateFlow({ _, _ -> true }), + portfolioTokenKeys: MutableStateFlow>> = 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 = diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt index 4d9d11235f..2720ea1bef 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt @@ -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().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 diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt index ac1a70953b..f6925ce535 100644 --- a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt @@ -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()) coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable()) every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId -> mockk { 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,