Updated on 2026-08-14
This commit is contained in:
parent
5ca696d677
commit
579b3cba32
27 changed files with 887 additions and 58 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue