Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 12:53:59 +04:00
parent c0092847b7
commit 503106376c
27 changed files with 887 additions and 58 deletions

View file

@ -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)

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -23,10 +23,14 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.models.EnrollResult
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.models.TokenReward
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent
@ -59,6 +63,8 @@ internal class ActivateCampaignsModel @Inject constructor(
private val urlOpener: UrlOpener,
@GlobalUiMessageSender private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
private val predefinedTokenResolver: PredefinedTokenResolver,
) : Model() {
private val params = paramsContainer.require<ActivateCampaignBottomSheetComponent.Params>()
@ -68,6 +74,8 @@ internal class ActivateCampaignsModel @Inject constructor(
private val campaignId: PromoCampaignId = params.campaignType.toPromoCampaignId()
private val campaignContent = CampaignTypeToContentConverter().convert(campaignType)
private val predefinedTokensFlow = MutableStateFlow<List<PredefinedTokenToAdd>>(emptyList())
val uiState: StateFlow<ActivateCampaignUM>
field = MutableStateFlow(buildInitialModel())
@ -75,7 +83,7 @@ internal class ActivateCampaignsModel @Inject constructor(
modelScope = modelScope,
settings = ChooseTokenBridge.Settings(
title = resourceReference(R.string.common_choose_token),
isShowMarketBlock = false,
chooserBlock = ChooserBlock.Predefined(predefinedTokensFlow),
isShowPaymentAccount = false,
isShowSingleCurrencyWallets = true,
),
@ -95,6 +103,18 @@ internal class ActivateCampaignsModel @Inject constructor(
bridge.onClose.receiveAsFlow()
.onEach { onChooseTokenDismiss() }
.launchIn(modelScope)
modelScope.launch { loadPredefinedTokens() }
}
private suspend fun loadPredefinedTokens() {
getPromoCampaignStateUseCase(campaignId, params.userWalletId)
.onLeft { error -> TangemLogger.e("Error loading campaign ${campaignType.campaignId} state", error) }
.onRight { state ->
if (state is PromoCampaignState.Available) {
predefinedTokensFlow.value = predefinedTokenResolver.resolve(state.payoutTokens)
}
}
}
private fun buildInitialModel(): ActivateCampaignUM = ActivateCampaignUM(

View file

@ -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
}
},

View file

@ -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),
)
}
}

View file

@ -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,
)
}

View file

@ -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,
)
}