Updated on 2026-08-14
This commit is contained in:
parent
6ad742d678
commit
9101098d72
6 changed files with 116 additions and 34 deletions
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue