From 579b3cba32d9d3be405931c00682d36365a72fe6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 12:53:59 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../promotion/models/PromotionsResponse.kt | 2 + .../promo/converter/PromoCampaignConverter.kt | 2 + .../data/promo/DefaultPromoRepositoryTest.kt | 11 +- .../converter/PromoCampaignConverterTest.kt | 20 +- .../promo/DefaultYieldPromoRepositoryTest.kt | 2 + .../converter/YieldBoostPromoConverterTest.kt | 4 + .../tangem/domain/promo/models/PromoModels.kt | 2 + .../api/choosetoken/ChooseTokenBridge.kt | 24 +- .../api/choosetoken/PredefinedTokenToAdd.kt | 19 ++ .../choosetoken/model/ChooseTokenModel.kt | 75 +++-- .../choosetoken/model/MarketBlockDelegate.kt | 14 +- .../PredefinedTokensBlockDelegate.kt | 109 +++++++ .../predefined/state/PredefinedTokensUM.kt | 17 + .../impl/choosetoken/ui/ChooseTokenScreen.kt | 157 +++++++++- .../impl/choosetoken/ui/ChooseTokenUM.kt | 4 +- .../choosetoken/ui/state/ChooserBlockUM.kt | 15 + .../model/MarketBlockDelegateTest.kt | 9 +- .../PredefinedTokensBlockDelegateTest.kt | 290 ++++++++++++++++++ features/promo-banners/impl/build.gradle.kts | 4 + .../ActivateCampaignBottomSheetComponent.kt | 2 + .../component/DefaultCampaignsComponent.kt | 1 + .../entity/CampaignsBottomSheetConfig.kt | 2 + .../campaigns/model/ActivateCampaignsModel.kt | 22 +- .../impl/campaigns/model/CampaignsModel.kt | 2 +- .../model/PredefinedTokenResolver.kt | 41 +++ .../model/ActivateCampaignsModelTest.kt | 7 + .../model/PredefinedTokenResolverTest.kt | 88 ++++++ 27 files changed, 887 insertions(+), 58 deletions(-) create mode 100644 features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/state/ChooserBlockUM.kt create mode 100644 features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegateTest.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt index 6d9dea62b9..0558cfce9e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt @@ -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, ) } } \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt index 4db59a14f2..91aec47f55 100644 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt @@ -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( diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt index 061e43c88c..f2586d9b50 100644 --- a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt @@ -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 = "", ), diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt index 522ff2a3cf..ab330fd49b 100644 --- a/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt @@ -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")) diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt index 4b1562c0c8..091069392f 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt @@ -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", diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt index 8d1c034fba..f6f2c99542 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt @@ -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", diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt index 8d72a7c755..e2a3013893 100644 --- a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt @@ -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( diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt index 0ba2e187cf..68a1de2d6e 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt @@ -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>) : ChooserBlock +} + sealed interface ChooseTokenAnalyticsPayload { @Suppress("BooleanPropertyNaming") diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt new file mode 100644 index 0000000000..ec88e8bd8c --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt @@ -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, +) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt index 214422ed0a..50f79c7a7a 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.commonfeatures.impl.choosetoken.model +import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -10,15 +11,18 @@ import com.tangem.features.commonfeatures.api.R import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer -import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.PredefinedTokensBlockDelegate import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -28,6 +32,8 @@ import javax.inject.Inject internal class ChooseTokenModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, marketBlockDelegateFactory: MarketBlockDelegate.Factory, + predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory, + addToPortfolioManagerFactory: AddToPortfolioManager.Factory, paramsContainer: ParamsContainer, ) : Model() { @@ -36,34 +42,57 @@ internal class ChooseTokenModel @Inject constructor( private val searchQueryState: StateFlow = bridge.searchQueryState private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState - private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create( - modelScope = modelScope, - searchQueryState = searchQueryState, - screensSourcesName = bridge.analyticsPayload - .filterIsInstance() - .firstOrNull()?.value.orEmpty(), - selectedWalletFlow = bridge.selectedWalletFlow, - shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets, + private val screensSourcesName: String = bridge.analyticsPayload + .filterIsInstance() + .firstOrNull()?.value.orEmpty() + + val bottomSheetNavigation: SlotNavigation = 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 = 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 = 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 = MutableStateFlow(getInitState()) val state: StateFlow = combine( flow = initialState, flow2 = bridge.fullPortfolioBlock, - flow3 = marketsStateFlow, - transform = { initial, content, marketBlock -> + flow3 = chooserBlockFlow, + transform = { initial, content, chooserBlock -> ChooseTokenFullUM( initialUM = initial, portfolioBlock = content, - marketsBlock = marketBlock, + chooserBlock = chooserBlock, ) }, ).stateIn( @@ -72,16 +101,18 @@ internal class ChooseTokenModel @Inject constructor( initialValue = ChooseTokenFullUM( initialUM = initialState.value, portfolioBlock = bridge.fullPortfolioBlock.value, - marketsBlock = null, + chooserBlock = null, ), ) init { addToPortfolioManager.onDismiss.receiveAsFlow() - .onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() } + .onEach { bottomSheetNavigation.dismiss() } .launchIn(modelScope) addToPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { notifyCurrencyChosen(it, isMarketTokenSelected = true) } + .onEach { + notifyCurrencyChosen(it, isMarketTokenSelected = bridge.settings.chooserBlock == ChooserBlock.Market) + } .launchIn(modelScope) addToPortfolioManager.onAddedTokenClick.receiveAsFlow() .onEach { notifyCurrencyChosen(it, isMarketTokenSelected = false) } @@ -103,7 +134,7 @@ internal class ChooseTokenModel @Inject constructor( ), ) bridge.onCurrencyChosen(chooseTokenResult) - marketBlockDelegate.addToPortfolioSlot.dismiss() + bottomSheetNavigation.dismiss() } private fun getInitialSearchBar(): SearchBarUM = SearchBarUM( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index b5f33f004b..3c08434a2b 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -39,13 +39,13 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory, private val excludedBlockchains: ExcludedBlockchains, private val getUserWalletsUseCase: GetWalletsUseCase, - private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, @Assisted private val modelScope: CoroutineScope, @Assisted private val searchQueryState: StateFlow, - @Assisted private val screensSourcesName: String, @Assisted private val selectedWalletFlow: SharedFlow, @Assisted private val shouldShowSingleCurrencyWallets: Boolean, + @Assisted private val addToPortfolioManager: AddToPortfolioManager, + @Assisted private val addToPortfolioSlot: SlotNavigation, ) { private val visibleMarketItemIds = MutableStateFlow>(emptyList()) @@ -53,13 +53,6 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.MarketCap) - val addToPortfolioSlot: SlotNavigation = SlotNavigation() - val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.ChooseToken, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName), - ) - private val baseMarketsStateFlow: Flow = searchQueryState // Switch between default and search market flows .map { it.value.isEmpty() } @@ -301,9 +294,10 @@ internal class MarketBlockDelegate @AssistedInject constructor( fun create( searchQueryState: StateFlow, modelScope: CoroutineScope, - screensSourcesName: String, selectedWalletFlow: SharedFlow, shouldShowSingleCurrencyWallets: Boolean, + addToPortfolioManager: AddToPortfolioManager, + addToPortfolioSlot: SlotNavigation, ): MarketBlockDelegate } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt new file mode 100644 index 0000000000..2b69771852 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt @@ -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>, + @Assisted private val searchQueryState: StateFlow, + @Assisted private val addToPortfolioManager: AddToPortfolioManager, + @Assisted private val addToPortfolioSlot: SlotNavigation, + @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 = 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, + ): (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>, + searchQueryState: StateFlow, + addToPortfolioManager: AddToPortfolioManager, + addToPortfolioSlot: SlotNavigation, + modelScope: CoroutineScope, + tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>, + ): PredefinedTokensBlockDelegate + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt new file mode 100644 index 0000000000..fd9ed6a18e --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt @@ -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, +) + +internal data class PredefinedTokenItemUM( + val id: String, + val symbol: String, + val networkName: TextReference, + val networkId: String, + val iconUrl: String?, + val onAddClick: () -> Unit, +) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 65166caad5..c9d9dad95c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -37,6 +37,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.common.ui.tokens.NonContentItemContent import com.tangem.common.ui.tokens.SlideInItemVisibility import com.tangem.core.ui.components.SpacerH @@ -58,6 +61,9 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.ds.image.TangemDeviceIcon import com.tangem.core.ui.ds.image.TangemIcon @@ -69,6 +75,7 @@ import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags @@ -83,6 +90,9 @@ import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokenItemUM +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokensUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM import com.tangem.utils.StringsSigns.DOT import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -97,10 +107,13 @@ private const val ACCOUNT_CONTENT_ANIM_MS = 350 private const val ACCOUNT_CONTENT_ANIM_DELAY_MS = 90 private const val ACCOUNT_BOUNDS_ANIM_MS = 250 +private val ChooseTokenFullUM.marketState: SwapMarketState? + get() = (chooserBlock as? ChooserBlockUM.Market)?.state + private val ChooseTokenFullUM.isNotFoundState: Boolean get() { if (portfolioBlock == null) return false - if (marketsBlock == null) return false + val marketsBlock = marketState ?: return false return portfolioBlock.tokensListData.tokensList.isEmpty() && portfolioBlock.isSearching && marketsBlock !is SwapMarketState.Content && @@ -110,7 +123,7 @@ private val ChooseTokenFullUM.isNotFoundState: Boolean private val ChooseTokenFullUM.isEmptyState: Boolean get() { if (portfolioBlock == null) return false - if (marketsBlock == null) return false + val marketsBlock = marketState ?: return false return portfolioBlock.tokensListData.tokensList.isEmpty() && !portfolioBlock.isSearching && marketsBlock !is SwapMarketState.Content && @@ -190,17 +203,25 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { isBalanceHidden = state.portfolioBlock.isBalanceHidden, ) - if (state.marketsBlock != null) { - item("markets_title_spacer") { SpacerH(height = 40.dp) } - swapMarketsListItems(state.marketsBlock) + when (val block = state.chooserBlock) { + is ChooserBlockUM.Market -> { + item("markets_title_spacer") { SpacerH(height = 40.dp) } + swapMarketsListItems(block.state) + } + is ChooserBlockUM.Predefined -> { + item("predefined_title_spacer") { SpacerH(height = 40.dp) } + predefinedTokensListItems(block.state) + } + null -> Unit } } } } } } - if (state.marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) { - SetupMarketScrollTracker(state.marketsBlock, lazyListState) + val marketsBlock = state.marketState + if (marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) { + SetupMarketScrollTracker(marketsBlock, lazyListState) } } @@ -664,6 +685,92 @@ private fun buildAccountSubtitle(tokensCount: TextReference?, balance: String?): } } +private fun LazyListScope.predefinedTokensListItems(state: PredefinedTokensUM) { + item(key = "predefined_title") { + Text( + text = stringResourceSafe(R.string.markets_portfolio_eligible_block_title), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing8, + ), + ) + } + itemsIndexed( + items = state.items, + key = { _, item -> "predefined_${item.id}" }, + contentType = { _, _ -> PredefinedTokenItemUM::class.java }, + itemContent = { index, item -> + PredefinedTokenItem( + state = item, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = if (LocalRedesignEnabled.current) { + TangemTheme.colors2.surface.level1 + } else { + TangemTheme.colors.background.primary + }, + ) + .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + .semantics { lazyListItemPosition = index }, + ) + }, + ) +} + +@Composable +private fun PredefinedTokenItem(state: PredefinedTokenItemUM, modifier: Modifier = Modifier) { + val tokenRowUM = remember(state) { + val iconState = CurrencyIconState.TokenIcon( + url = state.iconUrl, + topBadgeIconResId = getActiveIconRes(Blockchain.fromNetworkId(state.networkId) ?: Blockchain.Unknown), + isGrayscale = false, + shouldShowCustomBadge = false, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + ) + TangemTokenRowUM.Content( + id = state.id, + headIconUM = TangemIconUM.Currency(currencyIconState = iconState), + titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(state.symbol)), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = resourceReference( + id = R.string.domain_receive_assets_onboarding_network_name, + formatArgs = wrappedList(state.networkName), + ), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Empty, + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty, + tailUM = TangemRowTailUM.Empty, + onItemClick = null, + onItemLongClick = null, + ) + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + TangemTokenRow( + tokenRowUM = tokenRowUM, + isBalanceHidden = false, + modifier = Modifier.weight(1f), + ) + SecondaryTangemButton( + onClick = state.onAddClick, + modifier = Modifier.padding(start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x4), + text = resourceReference(R.string.common_add), + size = TangemButtonSize.X9, + shape = TangemButtonShape.Default, + ) + } +} + private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) { item("EmptyTokensList") { Box( @@ -829,6 +936,25 @@ private val wallets ), ) +private val predefinedTokens = persistentListOf( + PredefinedTokenItemUM( + id = "usdc-ethereum", + symbol = "USDC", + networkName = stringReference("Ethereum"), + networkId = "ethereum", + iconUrl = null, + onAddClick = {}, + ), + PredefinedTokenItemUM( + id = "usdt-tron", + symbol = "USDT", + networkName = stringReference("Tron"), + networkId = "tron", + iconUrl = null, + onAddClick = {}, + ), +) + private val initialUM = ChooseTokenInitialUM( screenTitle = stringReference("Choose token"), isAppBarShown = true, @@ -849,7 +975,7 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider = 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>(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>(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>, + searchQueryState: MutableStateFlow = 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?) + + @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" + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index fc8fdc8ffb..680a1bdd04 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -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) diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt index c9068a7b07..0f312d3dba 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt @@ -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, ) diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt index 15ab2f6054..444542e29e 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt @@ -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 diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt index 3bc363ef98..cbfec595c7 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt @@ -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 diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt index 2a1f4b9dfc..083f621067 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt @@ -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() @@ -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>(emptyList()) + val uiState: StateFlow 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( diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt index e309a9d8f1..fd1a8721a9 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt @@ -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 } }, diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt new file mode 100644 index 0000000000..d1ca926493 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt @@ -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): List = 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), + ) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt index 0c3f8c94c0..a1c0568648 100644 --- a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt @@ -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, ) } diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt new file mode 100644 index 0000000000..e11b87ee57 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt @@ -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, + ) +} \ No newline at end of file