Updated on 2026-08-14
This commit is contained in:
parent
c0092847b7
commit
503106376c
27 changed files with 887 additions and 58 deletions
|
|
@ -30,10 +30,12 @@ data class PromotionsResponse(
|
|||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PromoToken(
|
||||
@Json(name = "tokenId") val tokenId: String,
|
||||
@Json(name = "tokenAddress") val tokenAddress: String,
|
||||
@Json(name = "tokenSymbol") val tokenSymbol: String,
|
||||
@Json(name = "tokenName") val tokenName: String,
|
||||
@Json(name = "networkId") val networkId: String,
|
||||
@Json(name = "decimals") val decimals: Int,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,10 +14,12 @@ internal object PromoCampaignConverter {
|
|||
campaign = campaign,
|
||||
payoutTokens = all.tokens.orEmpty().map { token ->
|
||||
PromoPayoutToken(
|
||||
tokenId = token.tokenId,
|
||||
tokenAddress = token.tokenAddress,
|
||||
tokenSymbol = token.tokenSymbol,
|
||||
tokenName = token.tokenName,
|
||||
networkId = token.networkId,
|
||||
decimals = token.decimals,
|
||||
)
|
||||
},
|
||||
timeline = PromoTimeline(
|
||||
|
|
|
|||
|
|
@ -54,7 +54,16 @@ internal class DefaultPromoRepositoryTest {
|
|||
name = campaign.slug,
|
||||
all = All(
|
||||
timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"),
|
||||
tokens = listOf(PromoToken("0xToken", "USDT", "Tether USD", "ethereum")),
|
||||
tokens = listOf(
|
||||
PromoToken(
|
||||
tokenId = "tether",
|
||||
tokenAddress = "0xToken",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether USD",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
status = "active",
|
||||
link = "",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,16 @@ internal class PromoCampaignConverterTest {
|
|||
// Arrange
|
||||
val all = All(
|
||||
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
|
||||
tokens = listOf(PromoToken("0xdac1", "USDT", "Tether USD", "ethereum")),
|
||||
tokens = listOf(
|
||||
PromoToken(
|
||||
tokenId = "tether",
|
||||
tokenAddress = "0xdac1",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether USD",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
status = "active",
|
||||
link = "",
|
||||
)
|
||||
|
|
@ -29,7 +38,14 @@ internal class PromoCampaignConverterTest {
|
|||
// Assert
|
||||
assertThat(result.campaign).isEqualTo(campaign)
|
||||
assertThat(result.payoutTokens).containsExactly(
|
||||
PromoPayoutToken("0xdac1", "USDT", "Tether USD", "ethereum"),
|
||||
PromoPayoutToken(
|
||||
tokenId = "tether",
|
||||
tokenAddress = "0xdac1",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether USD",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
)
|
||||
assertThat(result.timeline.start).isEqualTo(Instant.parse("2026-06-23T00:00:00.000Z"))
|
||||
assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z"))
|
||||
|
|
|
|||
|
|
@ -224,10 +224,12 @@ internal class DefaultYieldPromoRepositoryTest {
|
|||
),
|
||||
tokens = listOf(
|
||||
PromotionsResponse.PromotionDto.PromoToken(
|
||||
tokenId = "usd-coin",
|
||||
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
||||
tokenSymbol = "USDC",
|
||||
tokenName = "USD Coin",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
status = "active",
|
||||
|
|
|
|||
|
|
@ -100,16 +100,20 @@ class YieldBoostPromoConverterTest {
|
|||
),
|
||||
tokens = listOf(
|
||||
PromotionsResponse.PromotionDto.PromoToken(
|
||||
tokenId = "usd-coin",
|
||||
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
||||
tokenSymbol = "USDC",
|
||||
tokenName = "USD Coin",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
PromotionsResponse.PromotionDto.PromoToken(
|
||||
tokenId = "tether",
|
||||
tokenAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether USD",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
status = "active",
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ package com.tangem.domain.promo.models
|
|||
import kotlinx.datetime.Instant
|
||||
|
||||
data class PromoPayoutToken(
|
||||
val tokenId: String,
|
||||
val tokenAddress: String,
|
||||
val tokenSymbol: String,
|
||||
val tokenName: String,
|
||||
val networkId: String,
|
||||
val decimals: Int,
|
||||
)
|
||||
|
||||
data class PromoTimeline(
|
||||
|
|
|
|||
|
|
@ -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.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
|
@ -30,6 +34,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() {
|
||||
|
||||
|
|
@ -38,34 +44,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(
|
||||
|
|
@ -74,7 +103,7 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
initialValue = ChooseTokenFullUM(
|
||||
initialUM = initialState.value,
|
||||
portfolioBlock = bridge.fullPortfolioBlock.value,
|
||||
marketsBlock = null,
|
||||
chooserBlock = null,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -87,10 +116,12 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
}
|
||||
|
||||
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) }
|
||||
|
|
@ -112,7 +143,7 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
bridge.onCurrencyChosen(chooseTokenResult)
|
||||
marketBlockDelegate.addToPortfolioSlot.dismiss()
|
||||
bottomSheetNavigation.dismiss()
|
||||
}
|
||||
|
||||
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
|
||||
|
|
|
|||
|
|
@ -41,13 +41,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())
|
||||
|
|
@ -55,13 +55,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() }
|
||||
|
|
@ -319,9 +312,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,
|
||||
)
|
||||
|
|
@ -38,6 +38,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
|
||||
|
|
@ -59,6 +62,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
|
||||
|
|
@ -70,6 +76,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
|
||||
|
|
@ -84,6 +91,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
|
||||
|
|
@ -99,10 +109,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 &&
|
||||
|
|
@ -112,7 +125,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 &&
|
||||
|
|
@ -192,17 +205,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -700,6 +721,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(
|
||||
|
|
@ -865,6 +972,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,
|
||||
|
|
@ -885,7 +1011,7 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<Choose
|
|||
accounts.size,
|
||||
),
|
||||
),
|
||||
marketsBlock = SwapSelectTokenPreviewProvider.marketState,
|
||||
chooserBlock = ChooserBlockUM.Market(SwapSelectTokenPreviewProvider.marketState),
|
||||
),
|
||||
ChooseTokenFullUM(
|
||||
initialUM = initialUM,
|
||||
|
|
@ -895,7 +1021,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
|
||||
|
|
@ -46,7 +47,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)
|
||||
|
|
@ -62,7 +63,6 @@ internal class MarketBlockDelegateTest {
|
|||
fun setup() {
|
||||
clearMocks(
|
||||
marketsListBatchFlowManagerFactory,
|
||||
addToPortfolioManagerFactory,
|
||||
singleAccountStatusListSupplier,
|
||||
defaultManager,
|
||||
searchManager,
|
||||
|
|
@ -87,7 +87,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)
|
||||
|
|
@ -246,13 +245,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"
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,10 @@ dependencies {
|
|||
implementation(projects.domain.appCurrency)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.promo.models)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Core */
|
||||
api(projects.core.configToggles)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.core.ui.ds2.button.Close
|
|||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel
|
||||
import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent
|
||||
|
|
@ -83,6 +84,7 @@ internal class ActivateCampaignBottomSheetComponent(
|
|||
|
||||
data class Params(
|
||||
val campaignType: CampaignType,
|
||||
val userWalletId: UserWalletId,
|
||||
val modelCallbacks: ActivateCampaignModelCallbacks,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
|
|||
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
|
||||
params = ActivateCampaignBottomSheetComponent.Params(
|
||||
campaignType = config.campaignType,
|
||||
userWalletId = config.userWalletId,
|
||||
modelCallbacks = object : ActivateCampaignModelCallbacks {
|
||||
override val onActivated: (CampaignType) -> Unit = model::onActivated
|
||||
override val onAlreadyActivated: (CampaignType) -> Unit = model::onAlreadyActivated
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.entity
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
|
|
@ -16,6 +17,7 @@ internal sealed class CampaignsBottomSheetConfig {
|
|||
@Serializable
|
||||
data class Activate(
|
||||
val campaignType: CampaignType,
|
||||
val userWalletId: UserWalletId,
|
||||
) : CampaignsBottomSheetConfig()
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -23,10 +23,14 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.EnrollResult
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.PromoCampaignState
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
|
||||
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
||||
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent
|
||||
|
|
@ -59,6 +63,8 @@ internal class ActivateCampaignsModel @Inject constructor(
|
|||
private val urlOpener: UrlOpener,
|
||||
@GlobalUiMessageSender private val messageSender: UiMessageSender,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
|
||||
private val predefinedTokenResolver: PredefinedTokenResolver,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<ActivateCampaignBottomSheetComponent.Params>()
|
||||
|
|
@ -68,6 +74,8 @@ internal class ActivateCampaignsModel @Inject constructor(
|
|||
private val campaignId: PromoCampaignId = params.campaignType.toPromoCampaignId()
|
||||
private val campaignContent = CampaignTypeToContentConverter().convert(campaignType)
|
||||
|
||||
private val predefinedTokensFlow = MutableStateFlow<List<PredefinedTokenToAdd>>(emptyList())
|
||||
|
||||
val uiState: StateFlow<ActivateCampaignUM>
|
||||
field = MutableStateFlow(buildInitialModel())
|
||||
|
||||
|
|
@ -75,7 +83,7 @@ internal class ActivateCampaignsModel @Inject constructor(
|
|||
modelScope = modelScope,
|
||||
settings = ChooseTokenBridge.Settings(
|
||||
title = resourceReference(R.string.common_choose_token),
|
||||
isShowMarketBlock = false,
|
||||
chooserBlock = ChooserBlock.Predefined(predefinedTokensFlow),
|
||||
isShowPaymentAccount = false,
|
||||
isShowSingleCurrencyWallets = true,
|
||||
),
|
||||
|
|
@ -95,6 +103,18 @@ internal class ActivateCampaignsModel @Inject constructor(
|
|||
bridge.onClose.receiveAsFlow()
|
||||
.onEach { onChooseTokenDismiss() }
|
||||
.launchIn(modelScope)
|
||||
|
||||
modelScope.launch { loadPredefinedTokens() }
|
||||
}
|
||||
|
||||
private suspend fun loadPredefinedTokens() {
|
||||
getPromoCampaignStateUseCase(campaignId, params.userWalletId)
|
||||
.onLeft { error -> TangemLogger.e("Error loading campaign ${campaignType.campaignId} state", error) }
|
||||
.onRight { state ->
|
||||
if (state is PromoCampaignState.Available) {
|
||||
predefinedTokensFlow.value = predefinedTokenResolver.resolve(state.payoutTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildInitialModel(): ActivateCampaignUM = ActivateCampaignUM(
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ internal class CampaignsModel @Inject constructor(
|
|||
when (campaignState) {
|
||||
is PromoCampaignState.Enrolled,
|
||||
is PromoCampaignState.Available,
|
||||
-> CampaignsBottomSheetConfig.Activate(campaignType)
|
||||
-> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId)
|
||||
is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.markets.RawMarketToken
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.promo.models.PromoPayoutToken
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Maps backend promo payout tokens into [PredefinedTokenToAdd] for the token chooser.
|
||||
*
|
||||
* The backend payload already carries everything needed — `tokenId` (the market raw id), decimals,
|
||||
* symbol, name, contract address and network id — so no catalog lookup is required. The icon url is
|
||||
* derived from the raw id via the canonical host helper, matching what the add-to-portfolio flow uses.
|
||||
*/
|
||||
internal class PredefinedTokenResolver @Inject constructor() {
|
||||
|
||||
fun resolve(payoutTokens: List<PromoPayoutToken>): List<PredefinedTokenToAdd> = payoutTokens
|
||||
.map { payoutToken -> payoutToken.toPredefinedToken() }
|
||||
.distinctBy { it.token.id.value to it.network.networkId }
|
||||
|
||||
private fun PromoPayoutToken.toPredefinedToken(): PredefinedTokenToAdd {
|
||||
val rawId = CryptoCurrency.RawID(tokenId)
|
||||
return PredefinedTokenToAdd(
|
||||
token = RawMarketToken(
|
||||
id = rawId,
|
||||
name = tokenName,
|
||||
symbol = tokenSymbol,
|
||||
),
|
||||
network = TokenMarketInfo.Network(
|
||||
networkId = networkId,
|
||||
isExchangeable = false,
|
||||
contractAddress = tokenAddress,
|
||||
decimalCount = decimals,
|
||||
),
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(rawId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import com.tangem.domain.promo.models.EnrollResult
|
|||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
|
||||
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
||||
|
|
@ -54,6 +55,8 @@ internal class ActivateCampaignsModelTest {
|
|||
private val urlOpener: UrlOpener = mockk(relaxed = true)
|
||||
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk()
|
||||
private val predefinedTokenResolver: PredefinedTokenResolver = mockk(relaxed = true)
|
||||
private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks =
|
||||
mockk(relaxed = true)
|
||||
|
||||
|
|
@ -219,10 +222,12 @@ internal class ActivateCampaignsModelTest {
|
|||
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
|
||||
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
|
||||
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
|
||||
return ActivateCampaignsModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
ActivateCampaignBottomSheetComponent.Params(
|
||||
campaignType = campaignType,
|
||||
userWalletId = userWalletId,
|
||||
modelCallbacks = modelCallbacks,
|
||||
),
|
||||
),
|
||||
|
|
@ -234,6 +239,8 @@ internal class ActivateCampaignsModelTest {
|
|||
urlOpener = urlOpener,
|
||||
messageSender = messageSender,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
getPromoCampaignStateUseCase = getPromoCampaignStateUseCase,
|
||||
predefinedTokenResolver = predefinedTokenResolver,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.markets.RawMarketToken
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.promo.models.PromoPayoutToken
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class PredefinedTokenResolverTest {
|
||||
|
||||
private val resolver = PredefinedTokenResolver()
|
||||
|
||||
@Test
|
||||
fun `GIVEN payout token WHEN resolve THEN mapped from payload`() {
|
||||
// Arrange
|
||||
val payoutToken = createPayoutToken(
|
||||
tokenId = "cat-token",
|
||||
tokenAddress = "0xContract",
|
||||
tokenSymbol = "CAT",
|
||||
tokenName = "Cat Token",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
)
|
||||
|
||||
// Act
|
||||
val actual = resolver.resolve(listOf(payoutToken))
|
||||
|
||||
// Assert
|
||||
val rawId = CryptoCurrency.RawID("cat-token")
|
||||
val expected = PredefinedTokenToAdd(
|
||||
token = RawMarketToken(id = rawId, name = "Cat Token", symbol = "CAT"),
|
||||
network = TokenMarketInfo.Network(
|
||||
networkId = "ethereum",
|
||||
isExchangeable = false,
|
||||
contractAddress = "0xContract",
|
||||
decimalCount = 6,
|
||||
),
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(rawId),
|
||||
)
|
||||
assertThat(actual).containsExactly(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple payout tokens WHEN resolve THEN input order preserved`() {
|
||||
// Arrange
|
||||
val first = createPayoutToken(tokenId = "first-token", tokenSymbol = "AAA", networkId = "ethereum")
|
||||
val second = createPayoutToken(tokenId = "second-token", tokenSymbol = "BBB", networkId = "polygon")
|
||||
|
||||
// Act
|
||||
val actual = resolver.resolve(listOf(first, second)).map { it.token.id.value }
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly("first-token", "second-token").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two payouts with same id on same network WHEN resolve THEN duplicate removed`() {
|
||||
// Arrange
|
||||
val first = createPayoutToken(tokenId = "usd-coin", tokenAddress = "0xFirst", networkId = "ethereum")
|
||||
val second = createPayoutToken(tokenId = "usd-coin", tokenAddress = "0xSecond", networkId = "ethereum")
|
||||
|
||||
// Act
|
||||
val actual = resolver.resolve(listOf(first, second))
|
||||
|
||||
// Assert
|
||||
assertThat(actual).hasSize(1)
|
||||
assertThat(actual.single().network.contractAddress).isEqualTo("0xFirst")
|
||||
}
|
||||
|
||||
private fun createPayoutToken(
|
||||
tokenId: String = "cat-token",
|
||||
tokenAddress: String = "0xContract",
|
||||
tokenSymbol: String = "CAT",
|
||||
tokenName: String = "Cat Token",
|
||||
networkId: String = "ethereum",
|
||||
decimals: Int = 6,
|
||||
) = PromoPayoutToken(
|
||||
tokenId = tokenId,
|
||||
tokenAddress = tokenAddress,
|
||||
tokenSymbol = tokenSymbol,
|
||||
tokenName = tokenName,
|
||||
networkId = networkId,
|
||||
decimals = decimals,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue