Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 12:53:59 +04:00
parent 5ca696d677
commit 579b3cba32
27 changed files with 887 additions and 58 deletions

View file

@ -28,7 +28,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
data class Settings(
val title: TextReference,
val isShowMarketBlock: Boolean,
val chooserBlock: ChooserBlock,
val isShowPaymentAccount: Boolean,
val isAppBarShown: Boolean = true,
/**
@ -40,24 +40,24 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
companion object {
val SwapFrom = Settings(
title = resourceReference(R.string.swapping_from_title),
isShowMarketBlock = true,
chooserBlock = ChooserBlock.Market,
isShowPaymentAccount = true,
)
val SwapTo = Settings(
title = resourceReference(R.string.swapping_to_title),
isShowMarketBlock = true,
chooserBlock = ChooserBlock.Market,
isShowPaymentAccount = true,
)
val AddFunds = Settings(
title = resourceReference(R.string.swapping_to_title),
isShowMarketBlock = true,
chooserBlock = ChooserBlock.Market,
isShowPaymentAccount = false,
isAppBarShown = false,
isShowSingleCurrencyWallets = true,
)
val Transfer = Settings(
title = resourceReference(R.string.common_transfer),
isShowMarketBlock = false,
chooserBlock = ChooserBlock.None,
isShowPaymentAccount = false,
isAppBarShown = false,
isShowSingleCurrencyWallets = true,
@ -116,6 +116,20 @@ data class ChooseTokenResult(
.any { it.value }
}
/**
* Which "add a token" block the chooser shows. Mutually exclusive by construction.
*/
sealed interface ChooserBlock {
data object None : ChooserBlock
data object Market : ChooserBlock
/**
* Shows an "add these tokens" block (e.g. Onramp promo payout). The caller owns [predefinedTokens]
* and pushes into it; the chooser derives the "add" cells and a network filter from it.
*/
data class Predefined(val predefinedTokens: StateFlow<List<PredefinedTokenToAdd>>) : ChooserBlock
}
sealed interface ChooseTokenAnalyticsPayload {
@Suppress("BooleanPropertyNaming")

View file

@ -0,0 +1,19 @@
package com.tangem.features.commonfeatures.api.choosetoken
import com.tangem.domain.markets.RawMarketToken
import com.tangem.domain.markets.TokenMarketInfo
/**
* A token offered for adding to the portfolio inside the token chooser, independent of any
* campaign/promo model. Callers convert their own models (e.g. promo payout tokens) into this type,
* so the chooser stays agnostic of feature-specific sources.
*
* Exactly one [network] per token the chooser renders one "add" row per [PredefinedTokenToAdd].
* [TokenMarketInfo.Network.decimalCount] must be resolved by the caller a network without decimals
* cannot be added and must be dropped before reaching the chooser.
*/
data class PredefinedTokenToAdd(
val token: RawMarketToken,
val network: TokenMarketInfo.Network,
val iconUrl: String? = null,
)

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -10,15 +11,18 @@ import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock
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.ChooseTokenComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.PredefinedTokensBlockDelegate
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.flow.*
import javax.inject.Inject
@ -28,6 +32,8 @@ import javax.inject.Inject
internal class ChooseTokenModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory,
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
paramsContainer: ParamsContainer,
) : Model() {
@ -36,34 +42,57 @@ internal class ChooseTokenModel @Inject constructor(
private val searchQueryState: StateFlow<SearchQuery> = bridge.searchQueryState
private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState
private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
screensSourcesName = bridge.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
selectedWalletFlow = bridge.selectedWalletFlow,
shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets,
private val screensSourcesName: String = bridge.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty()
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
scope = modelScope,
settings = AddToPortfolioManager.Settings.ChooseToken,
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
)
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager
private val marketsStateFlow: Flow<SwapMarketState?> = if (bridge.settings.isShowMarketBlock) {
marketBlockDelegate.marketsStateFlow
} else {
flowOf(null)
private val marketBlockDelegate: MarketBlockDelegate by lazy {
marketBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
selectedWalletFlow = bridge.selectedWalletFlow,
shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets,
addToPortfolioManager = addToPortfolioManager,
addToPortfolioSlot = bottomSheetNavigation,
)
}
private val predefinedTokensBlockDelegate: PredefinedTokensBlockDelegate by lazy {
val block = bridge.settings.chooserBlock as ChooserBlock.Predefined
predefinedTokensBlockDelegateFactory.create(
predefinedTokens = block.predefinedTokens,
searchQueryState = searchQueryState,
addToPortfolioManager = addToPortfolioManager,
addToPortfolioSlot = bottomSheetNavigation,
modelScope = modelScope,
tokenFilter = bridge.tokenFilter,
)
}
private val chooserBlockFlow: Flow<ChooserBlockUM?> = when (bridge.settings.chooserBlock) {
ChooserBlock.Market -> marketBlockDelegate.marketsStateFlow.map { it?.let(ChooserBlockUM::Market) }
is ChooserBlock.Predefined ->
predefinedTokensBlockDelegate.stateFlow.map { it?.let(ChooserBlockUM::Predefined) }
ChooserBlock.None -> flowOf(null)
}
private val initialState: MutableStateFlow<ChooseTokenInitialUM> = MutableStateFlow(getInitState())
val state: StateFlow<ChooseTokenFullUM> = combine(
flow = initialState,
flow2 = bridge.fullPortfolioBlock,
flow3 = marketsStateFlow,
transform = { initial, content, marketBlock ->
flow3 = chooserBlockFlow,
transform = { initial, content, chooserBlock ->
ChooseTokenFullUM(
initialUM = initial,
portfolioBlock = content,
marketsBlock = marketBlock,
chooserBlock = chooserBlock,
)
},
).stateIn(
@ -72,16 +101,18 @@ internal class ChooseTokenModel @Inject constructor(
initialValue = ChooseTokenFullUM(
initialUM = initialState.value,
portfolioBlock = bridge.fullPortfolioBlock.value,
marketsBlock = null,
chooserBlock = null,
),
)
init {
addToPortfolioManager.onDismiss.receiveAsFlow()
.onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() }
.onEach { bottomSheetNavigation.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = true) }
.onEach {
notifyCurrencyChosen(it, isMarketTokenSelected = bridge.settings.chooserBlock == ChooserBlock.Market)
}
.launchIn(modelScope)
addToPortfolioManager.onAddedTokenClick.receiveAsFlow()
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = false) }
@ -103,7 +134,7 @@ internal class ChooseTokenModel @Inject constructor(
),
)
bridge.onCurrencyChosen(chooseTokenResult)
marketBlockDelegate.addToPortfolioSlot.dismiss()
bottomSheetNavigation.dismiss()
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(

View file

@ -39,13 +39,13 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
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,
@Assisted private val addToPortfolioManager: AddToPortfolioManager,
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
) {
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
@ -53,13 +53,6 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.MarketCap)
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
scope = modelScope,
settings = AddToPortfolioManager.Settings.ChooseToken,
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
)
private val baseMarketsStateFlow: Flow<SwapMarketState> = searchQueryState
// Switch between default and search market flows
.map { it.value.isEmpty() }
@ -301,9 +294,10 @@ internal class MarketBlockDelegate @AssistedInject constructor(
fun create(
searchQueryState: StateFlow<SearchQuery>,
modelScope: CoroutineScope,
screensSourcesName: String,
selectedWalletFlow: SharedFlow<UserWallet>,
shouldShowSingleCurrencyWallets: Boolean,
addToPortfolioManager: AddToPortfolioManager,
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
): MarketBlockDelegate
}
}

View file

@ -0,0 +1,109 @@
package com.tangem.features.commonfeatures.impl.choosetoken.predefined
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokenItemUM
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokensUM
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.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Suppress("LongParameterList")
internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
@Assisted private val predefinedTokens: StateFlow<List<PredefinedTokenToAdd>>,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val addToPortfolioManager: AddToPortfolioManager,
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
) {
init {
predefinedTokens
.onEach { tokens -> tokenFilter.value = buildTokenFilter(tokens) }
.launchIn(modelScope)
}
val stateFlow: Flow<PredefinedTokensUM?> = combine(
predefinedTokens,
searchQueryState,
) { tokens, query ->
val filtered = tokens.filter { it.hasValidNetwork() && it.matchesQuery(query.value) }
if (filtered.isEmpty()) {
null
} else {
PredefinedTokensUM(items = filtered.map { it.toItemUM() }.toImmutableList())
}
}
private fun buildTokenFilter(
tokens: List<PredefinedTokenToAdd>,
): (AccountStatus, CryptoCurrencyStatus) -> Boolean {
val tokenKeys = tokens
.filter { it.hasValidNetwork() }
.mapTo(hashSetOf()) { it.token.id.value to it.network.networkId }
if (tokenKeys.isEmpty()) return { _, _ -> true }
return filter@{ _, currencyStatus ->
val rawId = currencyStatus.currency.id.rawCurrencyId?.value ?: return@filter false
tokenKeys.contains(rawId to currencyStatus.currency.network.rawId)
}
}
private fun PredefinedTokenToAdd.hasValidNetwork(): Boolean =
network.networkId.isNotBlank() && network.decimalCount != null
private fun PredefinedTokenToAdd.matchesQuery(query: String): Boolean {
if (query.isBlank()) return true
return token.symbol.contains(query, ignoreCase = true) ||
token.name.contains(query, ignoreCase = true)
}
private fun PredefinedTokenToAdd.toItemUM(): PredefinedTokenItemUM {
val item = this
val networkId = network.networkId
val networkName = Blockchain.fromNetworkId(networkId)?.fullName?.takeIf { it.isNotBlank() } ?: networkId
return PredefinedTokenItemUM(
id = "${token.id.value}_$networkId",
symbol = token.symbol,
networkName = TextReference.Str(networkName),
networkId = networkId,
iconUrl = iconUrl,
onAddClick = { onAddClick(item) },
)
}
private fun onAddClick(item: PredefinedTokenToAdd) {
addToPortfolioManager.setTokenParams(item.token)
addToPortfolioManager.setTokenNetworks(listOf(item.network))
addToPortfolioSlot.activate(AddToPortfolioRoute)
}
@AssistedFactory
interface Factory {
fun create(
predefinedTokens: StateFlow<List<PredefinedTokenToAdd>>,
searchQueryState: StateFlow<SearchQuery>,
addToPortfolioManager: AddToPortfolioManager,
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
modelScope: CoroutineScope,
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
): PredefinedTokensBlockDelegate
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.commonfeatures.impl.choosetoken.predefined.state
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class PredefinedTokensUM(
val items: ImmutableList<PredefinedTokenItemUM>,
)
internal data class PredefinedTokenItemUM(
val id: String,
val symbol: String,
val networkName: TextReference,
val networkId: String,
val iconUrl: String?,
val onAddClick: () -> Unit,
)

View file

@ -37,6 +37,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.ui.extensions.getActiveIconRes
import com.tangem.common.ui.tokens.NonContentItemContent
import com.tangem.common.ui.tokens.SlideInItemVisibility
import com.tangem.core.ui.components.SpacerH
@ -58,6 +61,9 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds.image.TangemIcon
@ -69,6 +75,7 @@ import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
@ -83,6 +90,9 @@ import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokenItemUM
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokensUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -97,10 +107,13 @@ private const val ACCOUNT_CONTENT_ANIM_MS = 350
private const val ACCOUNT_CONTENT_ANIM_DELAY_MS = 90
private const val ACCOUNT_BOUNDS_ANIM_MS = 250
private val ChooseTokenFullUM.marketState: SwapMarketState?
get() = (chooserBlock as? ChooserBlockUM.Market)?.state
private val ChooseTokenFullUM.isNotFoundState: Boolean
get() {
if (portfolioBlock == null) return false
if (marketsBlock == null) return false
val marketsBlock = marketState ?: return false
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
portfolioBlock.isSearching &&
marketsBlock !is SwapMarketState.Content &&
@ -110,7 +123,7 @@ private val ChooseTokenFullUM.isNotFoundState: Boolean
private val ChooseTokenFullUM.isEmptyState: Boolean
get() {
if (portfolioBlock == null) return false
if (marketsBlock == null) return false
val marketsBlock = marketState ?: return false
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
!portfolioBlock.isSearching &&
marketsBlock !is SwapMarketState.Content &&
@ -190,17 +203,25 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
isBalanceHidden = state.portfolioBlock.isBalanceHidden,
)
if (state.marketsBlock != null) {
item("markets_title_spacer") { SpacerH(height = 40.dp) }
swapMarketsListItems(state.marketsBlock)
when (val block = state.chooserBlock) {
is ChooserBlockUM.Market -> {
item("markets_title_spacer") { SpacerH(height = 40.dp) }
swapMarketsListItems(block.state)
}
is ChooserBlockUM.Predefined -> {
item("predefined_title_spacer") { SpacerH(height = 40.dp) }
predefinedTokensListItems(block.state)
}
null -> Unit
}
}
}
}
}
}
if (state.marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) {
SetupMarketScrollTracker(state.marketsBlock, lazyListState)
val marketsBlock = state.marketState
if (marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) {
SetupMarketScrollTracker(marketsBlock, lazyListState)
}
}
@ -664,6 +685,92 @@ private fun buildAccountSubtitle(tokensCount: TextReference?, balance: String?):
}
}
private fun LazyListScope.predefinedTokensListItems(state: PredefinedTokensUM) {
item(key = "predefined_title") {
Text(
text = stringResourceSafe(R.string.markets_portfolio_eligible_block_title),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing8,
),
)
}
itemsIndexed(
items = state.items,
key = { _, item -> "predefined_${item.id}" },
contentType = { _, _ -> PredefinedTokenItemUM::class.java },
itemContent = { index, item ->
PredefinedTokenItem(
state = item,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = if (LocalRedesignEnabled.current) {
TangemTheme.colors2.surface.level1
} else {
TangemTheme.colors.background.primary
},
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },
)
},
)
}
@Composable
private fun PredefinedTokenItem(state: PredefinedTokenItemUM, modifier: Modifier = Modifier) {
val tokenRowUM = remember(state) {
val iconState = CurrencyIconState.TokenIcon(
url = state.iconUrl,
topBadgeIconResId = getActiveIconRes(Blockchain.fromNetworkId(state.networkId) ?: Blockchain.Unknown),
isGrayscale = false,
shouldShowCustomBadge = false,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
)
TangemTokenRowUM.Content(
id = state.id,
headIconUM = TangemIconUM.Currency(currencyIconState = iconState),
titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(state.symbol)),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = resourceReference(
id = R.string.domain_receive_assets_onboarding_network_name,
formatArgs = wrappedList(state.networkName),
),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
tailUM = TangemRowTailUM.Empty,
onItemClick = null,
onItemLongClick = null,
)
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
TangemTokenRow(
tokenRowUM = tokenRowUM,
isBalanceHidden = false,
modifier = Modifier.weight(1f),
)
SecondaryTangemButton(
onClick = state.onAddClick,
modifier = Modifier.padding(start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x4),
text = resourceReference(R.string.common_add),
size = TangemButtonSize.X9,
shape = TangemButtonShape.Default,
)
}
}
private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) {
item("EmptyTokensList") {
Box(
@ -829,6 +936,25 @@ private val wallets
),
)
private val predefinedTokens = persistentListOf(
PredefinedTokenItemUM(
id = "usdc-ethereum",
symbol = "USDC",
networkName = stringReference("Ethereum"),
networkId = "ethereum",
iconUrl = null,
onAddClick = {},
),
PredefinedTokenItemUM(
id = "usdt-tron",
symbol = "USDT",
networkName = stringReference("Tron"),
networkId = "tron",
iconUrl = null,
onAddClick = {},
),
)
private val initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
isAppBarShown = true,
@ -849,7 +975,7 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<Choose
accounts.size,
),
),
marketsBlock = SwapSelectTokenPreviewProvider.marketState,
chooserBlock = ChooserBlockUM.Market(SwapSelectTokenPreviewProvider.marketState),
),
ChooseTokenFullUM(
initialUM = initialUM,
@ -859,7 +985,20 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<Choose
isSearching = false,
tokensListData = TokenListUMData.EmptyList,
),
marketsBlock = null,
chooserBlock = null,
),
ChooseTokenFullUM(
initialUM = initialUM,
portfolioBlock = ChooseTokenPortfolioFullBlockUM(
walletList = WalletListUM(wallets),
isBalanceHidden = false,
isSearching = false,
tokensListData = TokenListUMData.AccountList(
tokensList = accounts,
accounts.size,
),
),
chooserBlock = ChooserBlockUM.Predefined(PredefinedTokensUM(items = predefinedTokens)),
),
)
}

View file

@ -3,12 +3,12 @@ package com.tangem.features.commonfeatures.impl.choosetoken.ui
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
internal data class ChooseTokenFullUM(
val initialUM: ChooseTokenInitialUM,
val portfolioBlock: ChooseTokenPortfolioFullBlockUM?,
val marketsBlock: SwapMarketState?,
val chooserBlock: ChooserBlockUM? = null,
)
internal data class ChooseTokenInitialUM(

View file

@ -0,0 +1,15 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui.state
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokensUM
/**
* The "add a token" block shown below the portfolio. Exactly one variant is active per chooser,
* decided by [com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock].
*/
internal sealed interface ChooserBlockUM {
data class Market(val state: SwapMarketState) : ChooserBlockUM
data class Predefined(val state: PredefinedTokensUM) : ChooserBlockUM
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.card.WalletData
@ -45,7 +46,7 @@ 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 addToPortfolioManager: AddToPortfolioManager = mockk(relaxed = true)
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val defaultManager: MarketsListBatchFlowManager = mockk(relaxed = true)
@ -61,7 +62,6 @@ internal class MarketBlockDelegateTest {
fun setup() {
clearMocks(
marketsListBatchFlowManagerFactory,
addToPortfolioManagerFactory,
singleAccountStatusListSupplier,
defaultManager,
searchManager,
@ -86,7 +86,6 @@ internal class MarketBlockDelegateTest {
any()
)
} returns searchManager
every { addToPortfolioManagerFactory.create(any(), any(), any()) } returns mockk(relaxed = true)
every { defaultManager.uiItems } returns defaultUiItems
every { defaultManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
@ -206,13 +205,13 @@ internal class MarketBlockDelegateTest {
marketsListBatchFlowManagerFactory = marketsListBatchFlowManagerFactory,
excludedBlockchains = excludedBlockchains,
getUserWalletsUseCase = getUserWalletsUseCase,
addToPortfolioManagerFactory = addToPortfolioManagerFactory,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
modelScope = backgroundScope,
searchQueryState = searchQueryState,
screensSourcesName = "test",
selectedWalletFlow = selectedWalletFlow,
shouldShowSingleCurrencyWallets = showSingleCurrencyWallets,
addToPortfolioManager = addToPortfolioManager,
addToPortfolioSlot = SlotNavigation(),
)
}

View file

@ -0,0 +1,290 @@
package com.tangem.features.commonfeatures.impl.choosetoken.predefined
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.RawMarketToken
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokenItemUM
import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokensUM
import com.tangem.test.core.ProvideTestModels
import com.tangem.test.core.getEmittedValues
import io.mockk.CapturingSlot
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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
import org.junit.jupiter.params.ParameterizedTest
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class PredefinedTokensBlockDelegateTest {
private val addToPortfolioManager: AddToPortfolioManager = mockk(relaxUnitFun = true)
private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = mockk(relaxUnitFun = true)
private val account: AccountStatus = mockk()
@BeforeEach
fun setup() {
clearMocks(addToPortfolioManager, addToPortfolioSlot)
}
@Test
fun `GIVEN single token AND blank query WHEN state emitted THEN token mapped to item`() = runTest {
// Arrange
val token = createPredefinedToken(
id = "bitcoin",
symbol = "BTC",
networkId = ETHEREUM_NETWORK_ID,
iconUrl = "https://icon/btc.png",
)
val delegate = createDelegate(predefinedTokens = MutableStateFlow(listOf(token)))
// Act
val actual = lastState(delegate)
// Assert
val actualItem = requireNotNull(actual).items.single()
val expected = PredefinedTokenItemUM(
id = "bitcoin_$ETHEREUM_NETWORK_ID",
symbol = "BTC",
networkName = TextReference.Str("Ethereum"),
networkId = ETHEREUM_NETWORK_ID,
iconUrl = "https://icon/btc.png",
onAddClick = actualItem.onAddClick,
)
assertThat(actualItem).isEqualTo(expected)
}
@Test
fun `GIVEN same token on two networks WHEN state emitted THEN item ids are unique`() = runTest {
// Arrange
val tokens = listOf(
createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = "ethereum"),
createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = "polygon-pos"),
)
val delegate = createDelegate(predefinedTokens = MutableStateFlow(tokens))
// Act
val actualIds = requireNotNull(lastState(delegate)).items.map { it.id }
// Assert
assertThat(actualIds).containsExactly("usd-coin_ethereum", "usd-coin_polygon-pos").inOrder()
}
@Test
fun `GIVEN token network without decimals WHEN state emitted THEN token dropped`() = runTest {
// Arrange
val valid = createPredefinedToken(id = "bitcoin", networkId = "ethereum")
val noDecimals = PredefinedTokenToAdd(
token = RawMarketToken(id = CryptoCurrency.RawID("ghost"), name = "Ghost", symbol = "GHOST"),
network = TokenMarketInfo.Network(
networkId = "ethereum",
isExchangeable = false,
contractAddress = null,
decimalCount = null,
),
iconUrl = null,
)
val delegate = createDelegate(predefinedTokens = MutableStateFlow(listOf(valid, noDecimals)))
// Act
val actual = lastState(delegate)
// Assert
assertThat(actual?.items?.map { it.id }).containsExactly("bitcoin_ethereum")
}
@Test
fun `GIVEN empty predefined list WHEN state emitted THEN emits null`() = runTest {
// Arrange
val delegate = createDelegate(predefinedTokens = MutableStateFlow(emptyList()))
// Act
val actual = lastState(delegate)
// Assert
assertThat(actual).isNull()
}
@ParameterizedTest
@ProvideTestModels
fun filter(model: FilterModel) = runTest {
// Arrange
val tokens = listOf(
createPredefinedToken(id = "bitcoin", name = "Bitcoin", symbol = "BTC"),
createPredefinedToken(id = "ethereum", name = "Ethereum", symbol = "ETH"),
createPredefinedToken(id = "solana", name = "Solana", symbol = "SOL"),
)
val delegate = createDelegate(
predefinedTokens = MutableStateFlow(tokens),
searchQueryState = MutableStateFlow(SearchQuery(model.query)),
)
// Act
val actual = lastState(delegate)
// Assert
val expectedIds = model.expectedIds
if (expectedIds == null) {
assertThat(actual).isNull()
} else {
assertThat(actual?.items?.map { it.id }).containsExactlyElementsIn(expectedIds).inOrder()
}
}
@Test
fun `GIVEN token WHEN onAddClick invoked THEN manager updated AND slot activated`() = runTest {
// Arrange
val rawToken = RawMarketToken(id = CryptoCurrency.RawID("bitcoin"), name = "Bitcoin", symbol = "BTC")
val network = network(ETHEREUM_NETWORK_ID)
val token = PredefinedTokenToAdd(token = rawToken, network = network, iconUrl = null)
val delegate = createDelegate(predefinedTokens = MutableStateFlow(listOf(token)))
val item = requireNotNull(lastState(delegate)).items.single()
// Act
item.onAddClick()
// Assert
val transformer: CapturingSlot<(AddToPortfolioRoute?) -> AddToPortfolioRoute?> = slot()
verify(exactly = 1) { addToPortfolioManager.setTokenParams(rawToken) }
verify(exactly = 1) { addToPortfolioManager.setTokenNetworks(listOf(network)) }
verify(exactly = 1) { addToPortfolioSlot.navigate(capture(transformer), any()) }
assertThat(transformer.captured.invoke(null)).isEqualTo(AddToPortfolioRoute)
}
@Test
fun `GIVEN predefined tokens WHEN emitted THEN tokenFilter matches only those tokens`() = runTest {
// Arrange
val tokenFilter = MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>({ _, _ -> true })
val predefinedTokens = MutableStateFlow<List<PredefinedTokenToAdd>>(emptyList())
createDelegate(predefinedTokens = predefinedTokens, tokenFilter = tokenFilter)
// Act
predefinedTokens.value = listOf(
createPredefinedToken(id = "usd-coin", networkId = "ethereum"),
createPredefinedToken(id = "tether", networkId = "polygon-pos"),
)
advanceUntilIdle()
// Assert
val predicate = tokenFilter.value
assertThat(predicate(account, currency(rawId = "usd-coin", networkId = "ethereum"))).isTrue()
assertThat(predicate(account, currency(rawId = "tether", networkId = "polygon-pos"))).isTrue()
// same network, different token → excluded
assertThat(predicate(account, currency(rawId = "shiba-inu", networkId = "ethereum"))).isFalse()
// same token, different network → excluded
assertThat(predicate(account, currency(rawId = "usd-coin", networkId = "solana"))).isFalse()
}
@Test
fun `GIVEN only invalid predefined tokens WHEN emitted THEN tokenFilter stays pass-through`() = runTest {
// Arrange
val tokenFilter = MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>({ _, _ -> false })
val predefinedTokens = MutableStateFlow<List<PredefinedTokenToAdd>>(emptyList())
createDelegate(predefinedTokens = predefinedTokens, tokenFilter = tokenFilter)
// Act — a token whose network has no decimals is invalid (not addable), so must not constrain the list
predefinedTokens.value = listOf(
PredefinedTokenToAdd(
token = RawMarketToken(id = CryptoCurrency.RawID("usd-coin"), name = "USD Coin", symbol = "USDC"),
network = TokenMarketInfo.Network(
networkId = "ethereum",
isExchangeable = false,
contractAddress = null,
decimalCount = null,
),
iconUrl = null,
),
)
advanceUntilIdle()
// Assert — no valid predefined tokens → filter shows everything
assertThat(tokenFilter.value(account, currency(rawId = "shiba-inu", networkId = "ethereum"))).isTrue()
}
// region Helpers
private fun TestScope.lastState(delegate: PredefinedTokensBlockDelegate): PredefinedTokensUM? {
val emittedValues = getEmittedValues(delegate.stateFlow)
advanceUntilIdle()
return emittedValues.last()
}
private fun TestScope.createDelegate(
predefinedTokens: MutableStateFlow<List<PredefinedTokenToAdd>>,
searchQueryState: MutableStateFlow<SearchQuery> = MutableStateFlow(SearchQuery.Empty),
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow({ _, _ -> true }),
): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate(
predefinedTokens = predefinedTokens,
searchQueryState = searchQueryState,
addToPortfolioManager = addToPortfolioManager,
addToPortfolioSlot = addToPortfolioSlot,
modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)),
tokenFilter = tokenFilter,
)
private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus =
mockk(relaxed = true) {
every { currency.id.rawCurrencyId } returns CryptoCurrency.RawID(rawId)
every { currency.network.rawId } returns networkId
}
private fun createPredefinedToken(
id: String = "bitcoin",
name: String = "Bitcoin",
symbol: String = "BTC",
networkId: String = ETHEREUM_NETWORK_ID,
iconUrl: String? = null,
): PredefinedTokenToAdd = PredefinedTokenToAdd(
token = RawMarketToken(id = CryptoCurrency.RawID(id), name = name, symbol = symbol),
network = network(networkId),
iconUrl = iconUrl,
)
private fun network(networkId: String): TokenMarketInfo.Network = TokenMarketInfo.Network(
networkId = networkId,
isExchangeable = false,
contractAddress = null,
decimalCount = 6,
)
internal data class FilterModel(val query: String, val expectedIds: List<String>?)
@Suppress("UnusedPrivateMember")
private fun provideTestModels() = listOf(
FilterModel(
query = "",
expectedIds = listOf("bitcoin_$ETHEREUM_NETWORK_ID", "ethereum_$ETHEREUM_NETWORK_ID", "solana_$ETHEREUM_NETWORK_ID"),
),
FilterModel(query = "btc", expectedIds = listOf("bitcoin_$ETHEREUM_NETWORK_ID")),
FilterModel(query = "SOL", expectedIds = listOf("solana_$ETHEREUM_NETWORK_ID")),
FilterModel(query = "ethereum", expectedIds = listOf("ethereum_$ETHEREUM_NETWORK_ID")),
FilterModel(query = "zzz", expectedIds = null),
)
// endregion
private companion object {
const val ETHEREUM_NETWORK_ID = "ethereum"
}
}