Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-11 14:56:20 +02:00
parent 407cfd491f
commit 646349ff69
7 changed files with 359 additions and 11 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.features.commonfeatures.impl.choosetoken
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
@ -9,9 +10,9 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge.Sett
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioFullBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioListBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -32,7 +33,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
private val onSearchQuery: Channel<SearchQuery> = Channel()
override val searchQueryState: StateFlow<SearchQuery> = onSearchQuery.receiveAsFlow()
.debounce(ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY)
.debounce(ChooseTokenModel.DEBOUNCE_SEARCH_DELAY)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty)
private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create(
@ -45,6 +46,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
modelScope = modelScope,
searchQueryState = searchQueryState,
portfolioListBlockDelegate = portfolioListBlockDelegate,
featureSettings = settings,
)
override val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>
@ -53,6 +55,9 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
override val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?>
get() = portfolioFullBlockDelegate.fullPortfolioBlock
override val selectedWalletFlow: SharedFlow<UserWallet>
get() = portfolioFullBlockDelegate.selectedWalletFlow
init {
portfolioListBlockDelegate.onTokenChosen.receiveAsFlow()
.onEach { chooseResult -> onCurrencyChosen(chooseResult) }

View file

@ -42,6 +42,8 @@ internal class ChooseTokenModel @Inject constructor(
screensSourcesName = bridge.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
selectedWalletFlow = bridge.selectedWalletFlow,
shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets,
)
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot

View file

@ -6,7 +6,10 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
@ -14,9 +17,9 @@ import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
@ -25,11 +28,9 @@ import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlin.collections.filter
import kotlin.collections.map
import kotlin.collections.orEmpty
@Suppress("LongParameterList")
internal class MarketBlockDelegate @AssistedInject constructor(
@ -37,9 +38,12 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val screensSourcesName: String,
@Assisted private val selectedWalletFlow: SharedFlow<UserWallet>,
@Assisted private val shouldShowSingleCurrencyWallets: Boolean,
) {
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
@ -52,7 +56,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
)
val marketsStateFlow: Flow<SwapMarketState> = searchQueryState
private val baseMarketsStateFlow: Flow<SwapMarketState> = searchQueryState
// Switch between default and search market flows
.map { it.value.isEmpty() }
.distinctUntilChanged()
@ -66,6 +70,24 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
}
/**
* Market block constrained by the currently selected wallet:
* - single-currency wallet: hidden entirely (`null`) - no market tokens can be added;
* - single-currency-with-token wallet (e.g. NODL): items filtered to the wallet's network,
* block hidden when nothing remains;
* - multi-currency wallet: shown as is.
*
* When single-currency wallets aren't selectable here (e.g. swap), the wallet is always
* multi-currency, so we skip the per-wallet logic entirely and return [baseMarketsStateFlow].
*/
val marketsStateFlow: Flow<SwapMarketState?> = if (!shouldShowSingleCurrencyWallets) {
baseMarketsStateFlow
} else {
selectedWalletFlow
.flatMapLatest(::marketsFlowForWallet)
.distinctUntilChanged()
}
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
@ -181,6 +203,44 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
}
private fun marketsFlowForWallet(wallet: UserWallet): Flow<SwapMarketState?> {
if (wallet !is UserWallet.Cold) return baseMarketsStateFlow
val resolver = wallet.scanResponse.cardTypesResolver
return when {
// Single-currency wallet can't hold market tokens - hide the whole block.
resolver.isSingleWallet() -> flowOf(null)
// Single-currency-with-token wallet (NODL) - keep only tokens available on the wallet's network(s).
resolver.isSingleWalletWithToken() -> combine(
baseMarketsStateFlow,
singleAccountStatusListSupplier(wallet.walletId),
) { state, accountStatusList ->
filterStateByNetwork(state, accountStatusList.allowedNetworkIds())
}
// Multi-currency wallet - the common case, no filtering needed.
else -> baseMarketsStateFlow
}
}
private fun AccountStatusList.allowedNetworkIds(): Set<String> =
flattenCurrencies().mapTo(hashSetOf()) { it.currency.network.rawId }
private fun filterStateByNetwork(state: SwapMarketState, allowedNetworkIds: Set<String>): SwapMarketState? {
if (state !is SwapMarketState.Content) return state
if (allowedNetworkIds.isEmpty()) return null
val filteredItems = state.items.filter { item ->
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id)
tokenMarket?.networks?.any { allowedNetworkIds.contains(it.networkId) } == true
}.toImmutableList()
return if (filteredItems.isEmpty()) {
null
} else {
state.copy(items = filteredItems, total = filteredItems.size)
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id) ?: return
@ -218,6 +278,8 @@ internal class MarketBlockDelegate @AssistedInject constructor(
searchQueryState: StateFlow<SearchQuery>,
modelScope: CoroutineScope,
screensSourcesName: String,
selectedWalletFlow: SharedFlow<UserWallet>,
shouldShowSingleCurrencyWallets: Boolean,
): MarketBlockDelegate
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
@ -34,9 +35,11 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
@Assisted private val modelScope: CoroutineScope,
@Assisted private val portfolioListBlockDelegate: PortfolioListBlockDelegate,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val featureSettings: ChooseTokenBridge.Settings,
) {
private val isSearchingState: Boolean get() = searchQueryState.isSearchingState
private val isOnlyMultiCurrency: Boolean get() = !featureSettings.isShowSingleCurrencyWallets
private val onWalletSelected = Channel<UserWalletId>(capacity = Channel.BUFFERED)
val selectedWalletFlow: SharedFlow<UserWallet> = onWalletSelected.receiveAsFlow()
@ -50,9 +53,11 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
init {
val globalSelectedWallet = selectedWalletUseCase.sync().getOrNull()
val allWallets = getWalletsUseCase.invokeSync().filter { it.isMultiCurrency }
val allWallets = getWalletsUseCase.invokeSync()
.filter { !isOnlyMultiCurrency || it.isMultiCurrency }
val firstSelectedWallet = when {
globalSelectedWallet?.isMultiCurrency == true -> globalSelectedWallet
globalSelectedWallet != null && (!isOnlyMultiCurrency || globalSelectedWallet.isMultiCurrency) ->
globalSelectedWallet
allWallets.isNotEmpty() -> allWallets.first()
else -> null
}
@ -60,7 +65,10 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
}
private fun buildFlow() = flow {
val walletsFlow = getWalletsUseCase.invokeAsMap(filterLocked = true)
val walletsFlow = getWalletsUseCase.invokeAsMap(
isOnlyMultiCurrency = isOnlyMultiCurrency,
filterLocked = true,
)
val fullPortfolioBlockFlow = combine(
flow = walletsFlow,
flow2 = portfolioListBlockDelegate.portfolioList,
@ -107,6 +115,7 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
modelScope: CoroutineScope,
portfolioListBlockDelegate: PortfolioListBlockDelegate,
searchQueryState: StateFlow<SearchQuery>,
featureSettings: ChooseTokenBridge.Settings,
): PortfolioFullBlockDelegate
}
}

View file

@ -0,0 +1,258 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.card.WalletData
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.test.core.getEmittedValues
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketBlockDelegateTest {
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory = mockk()
private val excludedBlockchains: ExcludedBlockchains = mockk(relaxed = true)
private val getUserWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true)
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val defaultManager: MarketsListBatchFlowManager = mockk(relaxed = true)
private val searchManager: MarketsListBatchFlowManager = mockk(relaxed = true)
private val searchQueryState = MutableStateFlow(SearchQuery.Empty)
private val defaultUiItems = MutableStateFlow<ImmutableList<MarketsListItemUM>>(persistentListOf())
// Keyed by the raw id value: CryptoCurrency.RawID is a value class, unboxed to String at the JVM boundary.
private val tokenMarketsByRawId = mutableMapOf<String, TokenMarket>()
@BeforeEach
fun setup() {
clearMocks(
marketsListBatchFlowManagerFactory,
addToPortfolioManagerFactory,
singleAccountStatusListSupplier,
defaultManager,
searchManager,
)
searchQueryState.value = SearchQuery.Empty
defaultUiItems.value = persistentListOf()
tokenMarketsByRawId.clear()
every {
marketsListBatchFlowManagerFactory.create(
GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
any(),
any(),
any()
)
} returns defaultManager
every {
marketsListBatchFlowManagerFactory.create(
GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
any(),
any(),
any()
)
} returns searchManager
every { addToPortfolioManagerFactory.create(any(), any(), any()) } returns mockk(relaxed = true)
every { defaultManager.uiItems } returns defaultUiItems
every { defaultManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
every { defaultManager.totalCount } returns MutableStateFlow(null)
every { defaultManager.getTokenMarketById(any()) } answers { tokenMarketsByRawId[firstArg<String>()] }
every { searchManager.uiItems } returns MutableStateFlow(persistentListOf())
every { searchManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
every { searchManager.isSearchNotFoundState } returns MutableStateFlow(false)
every { searchManager.totalCount } returns MutableStateFlow(null)
every { searchManager.getTokenMarketById(any()) } returns null
}
@Test
fun `GIVEN multi-currency wallet WHEN trending emitted THEN all items shown unchanged`() = runTest {
// Arrange
val item1 = marketItem("token-1")
val item2 = marketItem("token-2")
defaultUiItems.value = persistentListOf(item1, item2)
val delegate = createDelegate(wallet = MockUserWalletFactory.create())
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(item1, item2).inOrder()
}
@Test
fun `GIVEN single-currency wallet WHEN trending emitted THEN market block is hidden`() = runTest {
// Arrange
defaultUiItems.value = persistentListOf(marketItem("token-1"))
val delegate = createDelegate(wallet = createSingleCurrencyWallet())
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN single-currency wallets not shown WHEN trending emitted THEN base state returned without filtering`() =
runTest {
// Arrange
val item1 = marketItem("token-1")
defaultUiItems.value = persistentListOf(item1)
// Single-currency wallet would normally hide the block, but the setting short-circuits the per-wallet logic.
val delegate = createDelegate(wallet = createSingleCurrencyWallet(), showSingleCurrencyWallets = false)
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(item1)
}
@Test
fun `GIVEN NODL wallet WHEN trending emitted THEN only items on wallet network are shown`() = runTest {
// Arrange
val nodlWallet = MockUserWalletFactory.createSingleWalletWithToken()
val itemOnWalletNetwork = marketItem("token-stellar")
val itemOnOtherNetwork = marketItem("token-eth")
tokenMarketsByRawId["token-stellar"] = tokenMarket(STELLAR_NETWORK_ID)
tokenMarketsByRawId["token-eth"] = tokenMarket(ETHEREUM_NETWORK_ID)
defaultUiItems.value = persistentListOf(itemOnWalletNetwork, itemOnOtherNetwork)
every {
singleAccountStatusListSupplier(nodlWallet.walletId)
} returns flowOf(accountStatusList(STELLAR_NETWORK_ID))
// Act
val result = lastMarketState(createDelegate(wallet = nodlWallet))
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(itemOnWalletNetwork)
assertThat(result.total).isEqualTo(1)
}
@Test
fun `GIVEN NODL wallet WHEN no trending tokens on wallet network THEN market block is hidden`() = runTest {
// Arrange
val nodlWallet = MockUserWalletFactory.createSingleWalletWithToken()
val itemOnOtherNetwork = marketItem("token-eth")
tokenMarketsByRawId["token-eth"] = tokenMarket(ETHEREUM_NETWORK_ID)
defaultUiItems.value = persistentListOf(itemOnOtherNetwork)
every {
singleAccountStatusListSupplier(nodlWallet.walletId)
} returns flowOf(accountStatusList(STELLAR_NETWORK_ID))
// Act
val result = lastMarketState(createDelegate(wallet = nodlWallet))
// Assert
assertThat(result).isNull()
}
// region Helpers
private fun TestScope.lastMarketState(delegate: MarketBlockDelegate): SwapMarketState? {
val emittedValues = getEmittedValues(delegate.marketsStateFlow)
advanceUntilIdle()
return emittedValues.last()
}
private fun TestScope.createDelegate(
wallet: UserWallet,
showSingleCurrencyWallets: Boolean = true,
): MarketBlockDelegate {
val selectedWalletFlow = MutableSharedFlow<UserWallet>(replay = 1)
selectedWalletFlow.tryEmit(wallet)
return MarketBlockDelegate(
marketsListBatchFlowManagerFactory = marketsListBatchFlowManagerFactory,
excludedBlockchains = excludedBlockchains,
getUserWalletsUseCase = getUserWalletsUseCase,
addToPortfolioManagerFactory = addToPortfolioManagerFactory,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
modelScope = backgroundScope,
searchQueryState = searchQueryState,
screensSourcesName = "test",
selectedWalletFlow = selectedWalletFlow,
shouldShowSingleCurrencyWallets = showSingleCurrencyWallets,
)
}
private fun marketItem(id: String): MarketsListItemUM = mockk {
every { this@mockk.id } returns CryptoCurrency.RawID(id)
}
private fun tokenMarket(vararg networkIds: String): TokenMarket = mockk {
every { networks } returns networkIds.map { networkId ->
TokenMarket.Network(networkId = networkId, contractAddress = null, decimalCount = null)
}
}
private fun accountStatusList(vararg networkIds: String): AccountStatusList = mockk {
every { flattenCurrencies() } returns networkIds.map { networkId ->
mockk<CryptoCurrencyStatus> {
every { currency.network.rawId } returns networkId
}
}
}
private fun createSingleCurrencyWallet(): UserWallet.Cold = UserWallet.Cold(
name = "Single",
walletId = UserWalletId("022"),
cardsInWallet = emptySet(),
isMultiCurrency = false,
scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(maxWalletCount = 2),
derivedKeys = emptyMap(),
).copy(
productType = ProductType.Note,
walletData = WalletData(blockchain = "XLM", token = null),
),
hasBackupError = false,
)
// endregion
private companion object {
const val STELLAR_NETWORK_ID = "stellar"
const val ETHEREUM_NETWORK_ID = "ethereum"
}
}