Updated on 2026-08-14
This commit is contained in:
parent
5ca696d677
commit
579b3cba32
27 changed files with 887 additions and 58 deletions
|
|
@ -30,10 +30,12 @@ data class PromotionsResponse(
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class PromoToken(
|
data class PromoToken(
|
||||||
|
@Json(name = "tokenId") val tokenId: String,
|
||||||
@Json(name = "tokenAddress") val tokenAddress: String,
|
@Json(name = "tokenAddress") val tokenAddress: String,
|
||||||
@Json(name = "tokenSymbol") val tokenSymbol: String,
|
@Json(name = "tokenSymbol") val tokenSymbol: String,
|
||||||
@Json(name = "tokenName") val tokenName: String,
|
@Json(name = "tokenName") val tokenName: String,
|
||||||
@Json(name = "networkId") val networkId: String,
|
@Json(name = "networkId") val networkId: String,
|
||||||
|
@Json(name = "decimals") val decimals: Int,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,10 +14,12 @@ internal object PromoCampaignConverter {
|
||||||
campaign = campaign,
|
campaign = campaign,
|
||||||
payoutTokens = all.tokens.orEmpty().map { token ->
|
payoutTokens = all.tokens.orEmpty().map { token ->
|
||||||
PromoPayoutToken(
|
PromoPayoutToken(
|
||||||
|
tokenId = token.tokenId,
|
||||||
tokenAddress = token.tokenAddress,
|
tokenAddress = token.tokenAddress,
|
||||||
tokenSymbol = token.tokenSymbol,
|
tokenSymbol = token.tokenSymbol,
|
||||||
tokenName = token.tokenName,
|
tokenName = token.tokenName,
|
||||||
networkId = token.networkId,
|
networkId = token.networkId,
|
||||||
|
decimals = token.decimals,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeline = PromoTimeline(
|
timeline = PromoTimeline(
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,16 @@ internal class DefaultPromoRepositoryTest {
|
||||||
name = campaign.slug,
|
name = campaign.slug,
|
||||||
all = All(
|
all = All(
|
||||||
timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"),
|
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",
|
status = "active",
|
||||||
link = "",
|
link = "",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,16 @@ internal class PromoCampaignConverterTest {
|
||||||
// Arrange
|
// Arrange
|
||||||
val all = All(
|
val all = All(
|
||||||
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
|
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",
|
status = "active",
|
||||||
link = "",
|
link = "",
|
||||||
)
|
)
|
||||||
|
|
@ -29,7 +38,14 @@ internal class PromoCampaignConverterTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(result.campaign).isEqualTo(campaign)
|
assertThat(result.campaign).isEqualTo(campaign)
|
||||||
assertThat(result.payoutTokens).containsExactly(
|
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.start).isEqualTo(Instant.parse("2026-06-23T00:00:00.000Z"))
|
||||||
assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z"))
|
assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z"))
|
||||||
|
|
|
||||||
|
|
@ -224,10 +224,12 @@ internal class DefaultYieldPromoRepositoryTest {
|
||||||
),
|
),
|
||||||
tokens = listOf(
|
tokens = listOf(
|
||||||
PromotionsResponse.PromotionDto.PromoToken(
|
PromotionsResponse.PromotionDto.PromoToken(
|
||||||
|
tokenId = "usd-coin",
|
||||||
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
||||||
tokenSymbol = "USDC",
|
tokenSymbol = "USDC",
|
||||||
tokenName = "USD Coin",
|
tokenName = "USD Coin",
|
||||||
networkId = "ethereum",
|
networkId = "ethereum",
|
||||||
|
decimals = 6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
status = "active",
|
status = "active",
|
||||||
|
|
|
||||||
|
|
@ -100,16 +100,20 @@ class YieldBoostPromoConverterTest {
|
||||||
),
|
),
|
||||||
tokens = listOf(
|
tokens = listOf(
|
||||||
PromotionsResponse.PromotionDto.PromoToken(
|
PromotionsResponse.PromotionDto.PromoToken(
|
||||||
|
tokenId = "usd-coin",
|
||||||
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
||||||
tokenSymbol = "USDC",
|
tokenSymbol = "USDC",
|
||||||
tokenName = "USD Coin",
|
tokenName = "USD Coin",
|
||||||
networkId = "ethereum",
|
networkId = "ethereum",
|
||||||
|
decimals = 6,
|
||||||
),
|
),
|
||||||
PromotionsResponse.PromotionDto.PromoToken(
|
PromotionsResponse.PromotionDto.PromoToken(
|
||||||
|
tokenId = "tether",
|
||||||
tokenAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7",
|
tokenAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7",
|
||||||
tokenSymbol = "USDT",
|
tokenSymbol = "USDT",
|
||||||
tokenName = "Tether USD",
|
tokenName = "Tether USD",
|
||||||
networkId = "ethereum",
|
networkId = "ethereum",
|
||||||
|
decimals = 6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
status = "active",
|
status = "active",
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ package com.tangem.domain.promo.models
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
data class PromoPayoutToken(
|
data class PromoPayoutToken(
|
||||||
|
val tokenId: String,
|
||||||
val tokenAddress: String,
|
val tokenAddress: String,
|
||||||
val tokenSymbol: String,
|
val tokenSymbol: String,
|
||||||
val tokenName: String,
|
val tokenName: String,
|
||||||
val networkId: String,
|
val networkId: String,
|
||||||
|
val decimals: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PromoTimeline(
|
data class PromoTimeline(
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
|
||||||
|
|
||||||
data class Settings(
|
data class Settings(
|
||||||
val title: TextReference,
|
val title: TextReference,
|
||||||
val isShowMarketBlock: Boolean,
|
val chooserBlock: ChooserBlock,
|
||||||
val isShowPaymentAccount: Boolean,
|
val isShowPaymentAccount: Boolean,
|
||||||
val isAppBarShown: Boolean = true,
|
val isAppBarShown: Boolean = true,
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,24 +40,24 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
|
||||||
companion object {
|
companion object {
|
||||||
val SwapFrom = Settings(
|
val SwapFrom = Settings(
|
||||||
title = resourceReference(R.string.swapping_from_title),
|
title = resourceReference(R.string.swapping_from_title),
|
||||||
isShowMarketBlock = true,
|
chooserBlock = ChooserBlock.Market,
|
||||||
isShowPaymentAccount = true,
|
isShowPaymentAccount = true,
|
||||||
)
|
)
|
||||||
val SwapTo = Settings(
|
val SwapTo = Settings(
|
||||||
title = resourceReference(R.string.swapping_to_title),
|
title = resourceReference(R.string.swapping_to_title),
|
||||||
isShowMarketBlock = true,
|
chooserBlock = ChooserBlock.Market,
|
||||||
isShowPaymentAccount = true,
|
isShowPaymentAccount = true,
|
||||||
)
|
)
|
||||||
val AddFunds = Settings(
|
val AddFunds = Settings(
|
||||||
title = resourceReference(R.string.swapping_to_title),
|
title = resourceReference(R.string.swapping_to_title),
|
||||||
isShowMarketBlock = true,
|
chooserBlock = ChooserBlock.Market,
|
||||||
isShowPaymentAccount = false,
|
isShowPaymentAccount = false,
|
||||||
isAppBarShown = false,
|
isAppBarShown = false,
|
||||||
isShowSingleCurrencyWallets = true,
|
isShowSingleCurrencyWallets = true,
|
||||||
)
|
)
|
||||||
val Transfer = Settings(
|
val Transfer = Settings(
|
||||||
title = resourceReference(R.string.common_transfer),
|
title = resourceReference(R.string.common_transfer),
|
||||||
isShowMarketBlock = false,
|
chooserBlock = ChooserBlock.None,
|
||||||
isShowPaymentAccount = false,
|
isShowPaymentAccount = false,
|
||||||
isAppBarShown = false,
|
isAppBarShown = false,
|
||||||
isShowSingleCurrencyWallets = true,
|
isShowSingleCurrencyWallets = true,
|
||||||
|
|
@ -116,6 +116,20 @@ data class ChooseTokenResult(
|
||||||
.any { it.value }
|
.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 {
|
sealed interface ChooseTokenAnalyticsPayload {
|
||||||
|
|
||||||
@Suppress("BooleanPropertyNaming")
|
@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
|
package com.tangem.features.commonfeatures.impl.choosetoken.model
|
||||||
|
|
||||||
|
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||||
import com.arkivanov.decompose.router.slot.dismiss
|
import com.arkivanov.decompose.router.slot.dismiss
|
||||||
import com.tangem.core.decompose.di.ModelScoped
|
import com.tangem.core.decompose.di.ModelScoped
|
||||||
import com.tangem.core.decompose.model.Model
|
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.addtoportfolio.AddToPortfolioManager
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
|
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
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
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
|
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.ChooseTokenComponent
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
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.SearchBarToggleTransformer
|
||||||
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer
|
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.ChooseTokenFullUM
|
||||||
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
|
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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -28,6 +32,8 @@ import javax.inject.Inject
|
||||||
internal class ChooseTokenModel @Inject constructor(
|
internal class ChooseTokenModel @Inject constructor(
|
||||||
override val dispatchers: CoroutineDispatcherProvider,
|
override val dispatchers: CoroutineDispatcherProvider,
|
||||||
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
|
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
|
||||||
|
predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory,
|
||||||
|
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
|
||||||
paramsContainer: ParamsContainer,
|
paramsContainer: ParamsContainer,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
|
|
@ -36,34 +42,57 @@ internal class ChooseTokenModel @Inject constructor(
|
||||||
|
|
||||||
private val searchQueryState: StateFlow<SearchQuery> = bridge.searchQueryState
|
private val searchQueryState: StateFlow<SearchQuery> = bridge.searchQueryState
|
||||||
private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState
|
private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState
|
||||||
private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create(
|
private val screensSourcesName: String = bridge.analyticsPayload
|
||||||
modelScope = modelScope,
|
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
|
||||||
searchQueryState = searchQueryState,
|
.firstOrNull()?.value.orEmpty()
|
||||||
screensSourcesName = bridge.analyticsPayload
|
|
||||||
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
|
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
|
||||||
.firstOrNull()?.value.orEmpty(),
|
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
|
||||||
selectedWalletFlow = bridge.selectedWalletFlow,
|
scope = modelScope,
|
||||||
shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets,
|
settings = AddToPortfolioManager.Settings.ChooseToken,
|
||||||
|
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
|
||||||
)
|
)
|
||||||
|
|
||||||
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
|
private val marketBlockDelegate: MarketBlockDelegate by lazy {
|
||||||
val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager
|
marketBlockDelegateFactory.create(
|
||||||
private val marketsStateFlow: Flow<SwapMarketState?> = if (bridge.settings.isShowMarketBlock) {
|
modelScope = modelScope,
|
||||||
marketBlockDelegate.marketsStateFlow
|
searchQueryState = searchQueryState,
|
||||||
} else {
|
selectedWalletFlow = bridge.selectedWalletFlow,
|
||||||
flowOf(null)
|
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())
|
private val initialState: MutableStateFlow<ChooseTokenInitialUM> = MutableStateFlow(getInitState())
|
||||||
val state: StateFlow<ChooseTokenFullUM> = combine(
|
val state: StateFlow<ChooseTokenFullUM> = combine(
|
||||||
flow = initialState,
|
flow = initialState,
|
||||||
flow2 = bridge.fullPortfolioBlock,
|
flow2 = bridge.fullPortfolioBlock,
|
||||||
flow3 = marketsStateFlow,
|
flow3 = chooserBlockFlow,
|
||||||
transform = { initial, content, marketBlock ->
|
transform = { initial, content, chooserBlock ->
|
||||||
ChooseTokenFullUM(
|
ChooseTokenFullUM(
|
||||||
initialUM = initial,
|
initialUM = initial,
|
||||||
portfolioBlock = content,
|
portfolioBlock = content,
|
||||||
marketsBlock = marketBlock,
|
chooserBlock = chooserBlock,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
).stateIn(
|
).stateIn(
|
||||||
|
|
@ -72,16 +101,18 @@ internal class ChooseTokenModel @Inject constructor(
|
||||||
initialValue = ChooseTokenFullUM(
|
initialValue = ChooseTokenFullUM(
|
||||||
initialUM = initialState.value,
|
initialUM = initialState.value,
|
||||||
portfolioBlock = bridge.fullPortfolioBlock.value,
|
portfolioBlock = bridge.fullPortfolioBlock.value,
|
||||||
marketsBlock = null,
|
chooserBlock = null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addToPortfolioManager.onDismiss.receiveAsFlow()
|
addToPortfolioManager.onDismiss.receiveAsFlow()
|
||||||
.onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() }
|
.onEach { bottomSheetNavigation.dismiss() }
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
|
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
|
||||||
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = true) }
|
.onEach {
|
||||||
|
notifyCurrencyChosen(it, isMarketTokenSelected = bridge.settings.chooserBlock == ChooserBlock.Market)
|
||||||
|
}
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
addToPortfolioManager.onAddedTokenClick.receiveAsFlow()
|
addToPortfolioManager.onAddedTokenClick.receiveAsFlow()
|
||||||
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = false) }
|
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = false) }
|
||||||
|
|
@ -103,7 +134,7 @@ internal class ChooseTokenModel @Inject constructor(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
bridge.onCurrencyChosen(chooseTokenResult)
|
bridge.onCurrencyChosen(chooseTokenResult)
|
||||||
marketBlockDelegate.addToPortfolioSlot.dismiss()
|
bottomSheetNavigation.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
|
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,13 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
||||||
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
|
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
|
||||||
private val excludedBlockchains: ExcludedBlockchains,
|
private val excludedBlockchains: ExcludedBlockchains,
|
||||||
private val getUserWalletsUseCase: GetWalletsUseCase,
|
private val getUserWalletsUseCase: GetWalletsUseCase,
|
||||||
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
|
|
||||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||||
@Assisted private val modelScope: CoroutineScope,
|
@Assisted private val modelScope: CoroutineScope,
|
||||||
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
|
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
|
||||||
@Assisted private val screensSourcesName: String,
|
|
||||||
@Assisted private val selectedWalletFlow: SharedFlow<UserWallet>,
|
@Assisted private val selectedWalletFlow: SharedFlow<UserWallet>,
|
||||||
@Assisted private val shouldShowSingleCurrencyWallets: Boolean,
|
@Assisted private val shouldShowSingleCurrencyWallets: Boolean,
|
||||||
|
@Assisted private val addToPortfolioManager: AddToPortfolioManager,
|
||||||
|
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
|
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
|
||||||
|
|
@ -53,13 +53,6 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
||||||
|
|
||||||
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.MarketCap)
|
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
|
private val baseMarketsStateFlow: Flow<SwapMarketState> = searchQueryState
|
||||||
// Switch between default and search market flows
|
// Switch between default and search market flows
|
||||||
.map { it.value.isEmpty() }
|
.map { it.value.isEmpty() }
|
||||||
|
|
@ -301,9 +294,10 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
||||||
fun create(
|
fun create(
|
||||||
searchQueryState: StateFlow<SearchQuery>,
|
searchQueryState: StateFlow<SearchQuery>,
|
||||||
modelScope: CoroutineScope,
|
modelScope: CoroutineScope,
|
||||||
screensSourcesName: String,
|
|
||||||
selectedWalletFlow: SharedFlow<UserWallet>,
|
selectedWalletFlow: SharedFlow<UserWallet>,
|
||||||
shouldShowSingleCurrencyWallets: Boolean,
|
shouldShowSingleCurrencyWallets: Boolean,
|
||||||
|
addToPortfolioManager: AddToPortfolioManager,
|
||||||
|
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
|
||||||
): MarketBlockDelegate
|
): 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.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import androidx.compose.ui.unit.dp
|
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.NonContentItemContent
|
||||||
import com.tangem.common.ui.tokens.SlideInItemVisibility
|
import com.tangem.common.ui.tokens.SlideInItemVisibility
|
||||||
import com.tangem.core.ui.components.SpacerH
|
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.PortfolioTokensListItemUM
|
||||||
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
|
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
|
||||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
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.DeviceIconUM
|
||||||
import com.tangem.core.ui.ds.image.TangemDeviceIcon
|
import com.tangem.core.ui.ds.image.TangemDeviceIcon
|
||||||
import com.tangem.core.ui.ds.image.TangemIcon
|
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.ds.row.token.internal.TokenRowTitle
|
||||||
import com.tangem.core.ui.extensions.*
|
import com.tangem.core.ui.extensions.*
|
||||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
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.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreview
|
import com.tangem.core.ui.res.TangemThemePreview
|
||||||
import com.tangem.core.ui.test.BuyTokenScreenTestTags
|
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.api.choosetoken.model.WalletTabUM
|
||||||
import com.tangem.features.commonfeatures.impl.R
|
import com.tangem.features.commonfeatures.impl.R
|
||||||
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
|
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 com.tangem.utils.StringsSigns.DOT
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
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_CONTENT_ANIM_DELAY_MS = 90
|
||||||
private const val ACCOUNT_BOUNDS_ANIM_MS = 250
|
private const val ACCOUNT_BOUNDS_ANIM_MS = 250
|
||||||
|
|
||||||
|
private val ChooseTokenFullUM.marketState: SwapMarketState?
|
||||||
|
get() = (chooserBlock as? ChooserBlockUM.Market)?.state
|
||||||
|
|
||||||
private val ChooseTokenFullUM.isNotFoundState: Boolean
|
private val ChooseTokenFullUM.isNotFoundState: Boolean
|
||||||
get() {
|
get() {
|
||||||
if (portfolioBlock == null) return false
|
if (portfolioBlock == null) return false
|
||||||
if (marketsBlock == null) return false
|
val marketsBlock = marketState ?: return false
|
||||||
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
|
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
|
||||||
portfolioBlock.isSearching &&
|
portfolioBlock.isSearching &&
|
||||||
marketsBlock !is SwapMarketState.Content &&
|
marketsBlock !is SwapMarketState.Content &&
|
||||||
|
|
@ -110,7 +123,7 @@ private val ChooseTokenFullUM.isNotFoundState: Boolean
|
||||||
private val ChooseTokenFullUM.isEmptyState: Boolean
|
private val ChooseTokenFullUM.isEmptyState: Boolean
|
||||||
get() {
|
get() {
|
||||||
if (portfolioBlock == null) return false
|
if (portfolioBlock == null) return false
|
||||||
if (marketsBlock == null) return false
|
val marketsBlock = marketState ?: return false
|
||||||
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
|
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
|
||||||
!portfolioBlock.isSearching &&
|
!portfolioBlock.isSearching &&
|
||||||
marketsBlock !is SwapMarketState.Content &&
|
marketsBlock !is SwapMarketState.Content &&
|
||||||
|
|
@ -190,17 +203,25 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
|
||||||
isBalanceHidden = state.portfolioBlock.isBalanceHidden,
|
isBalanceHidden = state.portfolioBlock.isBalanceHidden,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.marketsBlock != null) {
|
when (val block = state.chooserBlock) {
|
||||||
item("markets_title_spacer") { SpacerH(height = 40.dp) }
|
is ChooserBlockUM.Market -> {
|
||||||
swapMarketsListItems(state.marketsBlock)
|
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) {
|
val marketsBlock = state.marketState
|
||||||
SetupMarketScrollTracker(state.marketsBlock, lazyListState)
|
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) {
|
private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) {
|
||||||
item("EmptyTokensList") {
|
item("EmptyTokensList") {
|
||||||
Box(
|
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(
|
private val initialUM = ChooseTokenInitialUM(
|
||||||
screenTitle = stringReference("Choose token"),
|
screenTitle = stringReference("Choose token"),
|
||||||
isAppBarShown = true,
|
isAppBarShown = true,
|
||||||
|
|
@ -849,7 +975,7 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<Choose
|
||||||
accounts.size,
|
accounts.size,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
marketsBlock = SwapSelectTokenPreviewProvider.marketState,
|
chooserBlock = ChooserBlockUM.Market(SwapSelectTokenPreviewProvider.marketState),
|
||||||
),
|
),
|
||||||
ChooseTokenFullUM(
|
ChooseTokenFullUM(
|
||||||
initialUM = initialUM,
|
initialUM = initialUM,
|
||||||
|
|
@ -859,7 +985,20 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<Choose
|
||||||
isSearching = false,
|
isSearching = false,
|
||||||
tokensListData = TokenListUMData.EmptyList,
|
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.components.fields.entity.SearchBarUM
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
|
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(
|
internal data class ChooseTokenFullUM(
|
||||||
val initialUM: ChooseTokenInitialUM,
|
val initialUM: ChooseTokenInitialUM,
|
||||||
val portfolioBlock: ChooseTokenPortfolioFullBlockUM?,
|
val portfolioBlock: ChooseTokenPortfolioFullBlockUM?,
|
||||||
val marketsBlock: SwapMarketState?,
|
val chooserBlock: ChooserBlockUM? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal data class ChooseTokenInitialUM(
|
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
|
package com.tangem.features.commonfeatures.impl.choosetoken.model
|
||||||
|
|
||||||
|
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||||
import com.tangem.common.card.WalletData
|
import com.tangem.common.card.WalletData
|
||||||
|
|
@ -45,7 +46,7 @@ internal class MarketBlockDelegateTest {
|
||||||
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory = mockk()
|
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory = mockk()
|
||||||
private val excludedBlockchains: ExcludedBlockchains = mockk(relaxed = true)
|
private val excludedBlockchains: ExcludedBlockchains = mockk(relaxed = true)
|
||||||
private val getUserWalletsUseCase: GetWalletsUseCase = 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 singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
|
||||||
|
|
||||||
private val defaultManager: MarketsListBatchFlowManager = mockk(relaxed = true)
|
private val defaultManager: MarketsListBatchFlowManager = mockk(relaxed = true)
|
||||||
|
|
@ -61,7 +62,6 @@ internal class MarketBlockDelegateTest {
|
||||||
fun setup() {
|
fun setup() {
|
||||||
clearMocks(
|
clearMocks(
|
||||||
marketsListBatchFlowManagerFactory,
|
marketsListBatchFlowManagerFactory,
|
||||||
addToPortfolioManagerFactory,
|
|
||||||
singleAccountStatusListSupplier,
|
singleAccountStatusListSupplier,
|
||||||
defaultManager,
|
defaultManager,
|
||||||
searchManager,
|
searchManager,
|
||||||
|
|
@ -86,7 +86,6 @@ internal class MarketBlockDelegateTest {
|
||||||
any()
|
any()
|
||||||
)
|
)
|
||||||
} returns searchManager
|
} returns searchManager
|
||||||
every { addToPortfolioManagerFactory.create(any(), any(), any()) } returns mockk(relaxed = true)
|
|
||||||
|
|
||||||
every { defaultManager.uiItems } returns defaultUiItems
|
every { defaultManager.uiItems } returns defaultUiItems
|
||||||
every { defaultManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
|
every { defaultManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
|
||||||
|
|
@ -206,13 +205,13 @@ internal class MarketBlockDelegateTest {
|
||||||
marketsListBatchFlowManagerFactory = marketsListBatchFlowManagerFactory,
|
marketsListBatchFlowManagerFactory = marketsListBatchFlowManagerFactory,
|
||||||
excludedBlockchains = excludedBlockchains,
|
excludedBlockchains = excludedBlockchains,
|
||||||
getUserWalletsUseCase = getUserWalletsUseCase,
|
getUserWalletsUseCase = getUserWalletsUseCase,
|
||||||
addToPortfolioManagerFactory = addToPortfolioManagerFactory,
|
|
||||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||||
modelScope = backgroundScope,
|
modelScope = backgroundScope,
|
||||||
searchQueryState = searchQueryState,
|
searchQueryState = searchQueryState,
|
||||||
screensSourcesName = "test",
|
|
||||||
selectedWalletFlow = selectedWalletFlow,
|
selectedWalletFlow = selectedWalletFlow,
|
||||||
shouldShowSingleCurrencyWallets = showSingleCurrencyWallets,
|
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.appCurrency)
|
||||||
implementation(projects.domain.account.status)
|
implementation(projects.domain.account.status)
|
||||||
implementation(projects.domain.promo)
|
implementation(projects.domain.promo)
|
||||||
|
implementation(projects.domain.promo.models)
|
||||||
|
|
||||||
|
/** Data */
|
||||||
|
implementation(projects.data.common)
|
||||||
|
|
||||||
/** Core */
|
/** Core */
|
||||||
api(projects.core.configToggles)
|
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.button.TangemButton
|
||||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
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.entity.CampaignType
|
||||||
import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel
|
import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel
|
||||||
import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent
|
import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent
|
||||||
|
|
@ -83,6 +84,7 @@ internal class ActivateCampaignBottomSheetComponent(
|
||||||
|
|
||||||
data class Params(
|
data class Params(
|
||||||
val campaignType: CampaignType,
|
val campaignType: CampaignType,
|
||||||
|
val userWalletId: UserWalletId,
|
||||||
val modelCallbacks: ActivateCampaignModelCallbacks,
|
val modelCallbacks: ActivateCampaignModelCallbacks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
|
||||||
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
|
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
|
||||||
params = ActivateCampaignBottomSheetComponent.Params(
|
params = ActivateCampaignBottomSheetComponent.Params(
|
||||||
campaignType = config.campaignType,
|
campaignType = config.campaignType,
|
||||||
|
userWalletId = config.userWalletId,
|
||||||
modelCallbacks = object : ActivateCampaignModelCallbacks {
|
modelCallbacks = object : ActivateCampaignModelCallbacks {
|
||||||
override val onActivated: (CampaignType) -> Unit = model::onActivated
|
override val onActivated: (CampaignType) -> Unit = model::onActivated
|
||||||
override val onAlreadyActivated: (CampaignType) -> Unit = model::onAlreadyActivated
|
override val onAlreadyActivated: (CampaignType) -> Unit = model::onAlreadyActivated
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.tangem.features.promobanners.impl.campaigns.entity
|
package com.tangem.features.promobanners.impl.campaigns.entity
|
||||||
|
|
||||||
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -16,6 +17,7 @@ internal sealed class CampaignsBottomSheetConfig {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Activate(
|
data class Activate(
|
||||||
val campaignType: CampaignType,
|
val campaignType: CampaignType,
|
||||||
|
val userWalletId: UserWalletId,
|
||||||
) : CampaignsBottomSheetConfig()
|
) : CampaignsBottomSheetConfig()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,14 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.domain.promo.models.EnrollResult
|
import com.tangem.domain.promo.models.EnrollResult
|
||||||
import com.tangem.domain.promo.models.PromoCampaignId
|
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.models.TokenReward
|
||||||
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
|
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.ChooseTokenBridge
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
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.R
|
||||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
||||||
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent
|
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent
|
||||||
|
|
@ -59,6 +63,8 @@ internal class ActivateCampaignsModel @Inject constructor(
|
||||||
private val urlOpener: UrlOpener,
|
private val urlOpener: UrlOpener,
|
||||||
@GlobalUiMessageSender private val messageSender: UiMessageSender,
|
@GlobalUiMessageSender private val messageSender: UiMessageSender,
|
||||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||||
|
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
|
||||||
|
private val predefinedTokenResolver: PredefinedTokenResolver,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
private val params = paramsContainer.require<ActivateCampaignBottomSheetComponent.Params>()
|
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 campaignId: PromoCampaignId = params.campaignType.toPromoCampaignId()
|
||||||
private val campaignContent = CampaignTypeToContentConverter().convert(campaignType)
|
private val campaignContent = CampaignTypeToContentConverter().convert(campaignType)
|
||||||
|
|
||||||
|
private val predefinedTokensFlow = MutableStateFlow<List<PredefinedTokenToAdd>>(emptyList())
|
||||||
|
|
||||||
val uiState: StateFlow<ActivateCampaignUM>
|
val uiState: StateFlow<ActivateCampaignUM>
|
||||||
field = MutableStateFlow(buildInitialModel())
|
field = MutableStateFlow(buildInitialModel())
|
||||||
|
|
||||||
|
|
@ -75,7 +83,7 @@ internal class ActivateCampaignsModel @Inject constructor(
|
||||||
modelScope = modelScope,
|
modelScope = modelScope,
|
||||||
settings = ChooseTokenBridge.Settings(
|
settings = ChooseTokenBridge.Settings(
|
||||||
title = resourceReference(R.string.common_choose_token),
|
title = resourceReference(R.string.common_choose_token),
|
||||||
isShowMarketBlock = false,
|
chooserBlock = ChooserBlock.Predefined(predefinedTokensFlow),
|
||||||
isShowPaymentAccount = false,
|
isShowPaymentAccount = false,
|
||||||
isShowSingleCurrencyWallets = true,
|
isShowSingleCurrencyWallets = true,
|
||||||
),
|
),
|
||||||
|
|
@ -95,6 +103,18 @@ internal class ActivateCampaignsModel @Inject constructor(
|
||||||
bridge.onClose.receiveAsFlow()
|
bridge.onClose.receiveAsFlow()
|
||||||
.onEach { onChooseTokenDismiss() }
|
.onEach { onChooseTokenDismiss() }
|
||||||
.launchIn(modelScope)
|
.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(
|
private fun buildInitialModel(): ActivateCampaignUM = ActivateCampaignUM(
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ internal class CampaignsModel @Inject constructor(
|
||||||
when (campaignState) {
|
when (campaignState) {
|
||||||
is PromoCampaignState.Enrolled,
|
is PromoCampaignState.Enrolled,
|
||||||
is PromoCampaignState.Available,
|
is PromoCampaignState.Available,
|
||||||
-> CampaignsBottomSheetConfig.Activate(campaignType)
|
-> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId)
|
||||||
is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive
|
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.PromoCampaignId
|
||||||
import com.tangem.domain.promo.models.TokenReward
|
import com.tangem.domain.promo.models.TokenReward
|
||||||
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
|
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.ChooseTokenBridge
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
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 urlOpener: UrlOpener = mockk(relaxed = true)
|
||||||
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||||
private val analyticsEventHandler: AnalyticsEventHandler = 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 =
|
private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks =
|
||||||
mockk(relaxed = true)
|
mockk(relaxed = true)
|
||||||
|
|
||||||
|
|
@ -219,10 +222,12 @@ internal class ActivateCampaignsModelTest {
|
||||||
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
|
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
|
||||||
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
|
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
|
||||||
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||||
|
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
|
||||||
return ActivateCampaignsModel(
|
return ActivateCampaignsModel(
|
||||||
paramsContainer = MutableParamsContainer(
|
paramsContainer = MutableParamsContainer(
|
||||||
ActivateCampaignBottomSheetComponent.Params(
|
ActivateCampaignBottomSheetComponent.Params(
|
||||||
campaignType = campaignType,
|
campaignType = campaignType,
|
||||||
|
userWalletId = userWalletId,
|
||||||
modelCallbacks = modelCallbacks,
|
modelCallbacks = modelCallbacks,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -234,6 +239,8 @@ internal class ActivateCampaignsModelTest {
|
||||||
urlOpener = urlOpener,
|
urlOpener = urlOpener,
|
||||||
messageSender = messageSender,
|
messageSender = messageSender,
|
||||||
analyticsEventHandler = analyticsEventHandler,
|
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