Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 14:50:24 +03:00
commit 50fe9e603d
1919 changed files with 94940 additions and 15178 deletions

View file

@ -9,6 +9,9 @@ android {
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
api(deps.compose.runtime)
api(deps.compose.ui)
api(projects.core.decompose)
api(projects.core.ui)
}

View file

@ -27,6 +27,7 @@ interface PromoBannersBlockComponent {
enum class Placeholder(val value: String) {
MAIN("main"),
FEED("shtorka"),
PAYMENT_ACCOUNT_MAIN("payment_account_main"),
}
interface Factory : ComponentFactory<Params, PromoBannersBlockComponent>

View file

@ -0,0 +1,8 @@
package com.tangem.features.promobanners.api.deeplink
interface CampaignsDeepLinkHandler {
interface Factory {
fun create(queryParams: Map<String, String>): CampaignsDeepLinkHandler
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.promobanners.api.swapcashback
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
/**
* once at app startup (hence [Unit] params) and reacts to campaign requests coming through the
* promo-campaigns bus, not to navigation.
*/
interface CampaignsComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Unit, CampaignsComponent>
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.promobanners.api.toggles
interface PromoBannersFeatureToggles {
val isCampaignsToggleEnabled: Boolean
}

View file

@ -0,0 +1 @@
/build

View file

@ -1,6 +1,7 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
@ -12,29 +13,44 @@ android {
dependencies {
/** Project - API */
implementation(projects.features.promoBanners.api)
api(projects.features.promoBanners.api)
implementation(projects.features.commonFeatures.api)
implementation(projects.common.routing)
implementation(projects.common.ui)
/** Domain */
implementation(projects.domain.common)
api(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.account.status)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
/** Data */
implementation(projects.data.common)
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.analytics)
api(projects.core.configToggles)
api(projects.core.analytics)
api(projects.core.datasource)
api(projects.core.decompose)
api(projects.core.navigation)
api(projects.core.utils)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.core.ui)
/** Compose */
implementation(deps.compose.foundation)
api(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.lifecycle.compose)
/** Other */
implementation(deps.arrow.core)
implementation(deps.androidx.appCompat)
implementation(deps.androidx.core.ktx)
implementation(deps.decompose)
implementation(deps.decompose.ext.compose)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.immutable.collections)
/** DI */
@ -42,6 +58,6 @@ dependencies {
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit5)
testImplementation(deps.test.truth)
testImplementation(projects.test.core)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.promobanners.impl.campaigns.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
internal sealed class PromoCampaignsAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
class PromotionScreenOpened(campaignType: CampaignType) : PromoCampaignsAnalyticsEvent(
event = "Promotion Screen Opened",
params = mapOf("Screen" to campaignType.analyticsName),
)
class EnrollButtonClicked(
campaignType: CampaignType,
token: String,
blockchain: String,
) : PromoCampaignsAnalyticsEvent(
event = "Enroll Button Clicked",
params = mapOf(
"Campaign" to campaignType.analyticsName,
"Token" to token,
"Blockchain" to blockchain,
),
)
class AlreadyEnrolledScreenOpened : PromoCampaignsAnalyticsEvent(event = "Already Enrolled Screen Opened")
}
private val CampaignType.analyticsName: String
get() = when (this) {
is CampaignType.WhaleSwapCashback -> "Cashback"
is CampaignType.ReactivationCashback -> "Reactivation"
}

View file

@ -0,0 +1,95 @@
package com.tangem.features.promobanners.impl.campaigns.component
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
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
import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignFooter
internal class ActivateCampaignBottomSheetComponent(
appComponentContext: AppComponentContext,
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
private val params: Params,
val onDismiss: () -> Unit,
val onFooterExtraHeightReady: (Dp) -> Unit,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: ActivateCampaignsModel = getOrCreateModel(params)
private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create(
context = child(key = "swapCashbackChooseToken"),
params = ChooseTokenComponent.Params(bridge = model.bridge),
)
@Composable
override fun Title() {
TangemTopNavigation(
windowInsets = WindowInsets(0),
blurBackground = false,
endButton = { TangemButton.Close(onClick = onDismiss) },
)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ActivateCampaignContent(um = state, modifier = modifier)
if (state.isChoosingToken) {
ChooseTokenBottomSheet(state.onChooseTokenDismiss)
}
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
ActivateCampaignFooter(
footerUM = state.footerUM,
onFooterTextHeightReady = onFooterExtraHeightReady,
)
}
@Composable
private fun ChooseTokenBottomSheet(onChooseTokenDismiss: () -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onChooseTokenDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
onBack = onChooseTokenDismiss,
content = { chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) },
)
}
data class Params(
val campaignType: CampaignType,
val userWalletId: UserWalletId,
val modelCallbacks: ActivateCampaignModelCallbacks,
)
interface ActivateCampaignModelCallbacks {
val onActivated: (CampaignType) -> Unit
val onAlreadyActivated: (CampaignType) -> Unit
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.promobanners.impl.campaigns.component
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.decompose.ComposableModularContentComponent
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.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter
import com.tangem.features.promobanners.impl.campaigns.ui.AlreadyActivatedCampaignContent
internal class CampaignAlreadyActivatedBottomSheetComponent(
appComponentContext: AppComponentContext,
params: Params,
val onDismiss: () -> Unit,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name
@Composable
override fun Title() {
TangemTopNavigation(
windowInsets = WindowInsets(0),
blurBackground = false,
endButton = { TangemButton.Close(onClick = onDismiss) },
)
}
@Composable
override fun Content(modifier: Modifier) {
AlreadyActivatedCampaignContent(
message = resourceReference(
R.string.promo_campaign_already_activated_title,
wrappedList(campaignName),
),
modifier = modifier,
)
}
@Composable
override fun Footer() {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_close),
onClick = onDismiss,
)
}
data class Params(
val campaignType: CampaignType,
)
}

View file

@ -0,0 +1,59 @@
package com.tangem.features.promobanners.impl.campaigns.component
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.decompose.ComposableModularContentComponent
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.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter
import com.tangem.features.promobanners.impl.campaigns.ui.CampaignEnrolledMessageContent
internal class CampaignEnrolledBottomSheetComponent(
params: Params,
private val onDismissRequest: () -> Unit,
) : ComposableModularContentComponent {
private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name
@Composable
override fun Title() {
TangemTopNavigation(
windowInsets = WindowInsets(0),
blurBackground = false,
endButton = { TangemButton.Close(onClick = onDismissRequest) },
)
}
@Composable
override fun Content(modifier: Modifier) {
CampaignEnrolledMessageContent(
message = resourceReference(
R.string.promo_campaign_enroll_success_title,
wrappedList(campaignName),
),
modifier = modifier,
)
}
@Composable
override fun Footer() {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_close),
onClick = { onDismissRequest() },
)
}
data class Params(
val campaignType: CampaignType,
)
}

View file

@ -0,0 +1,129 @@
package com.tangem.features.promobanners.impl.campaigns.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.DEFAULT_FOOTER_HEIGHT
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.rememberLastNonNull
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig
import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultCampaignsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
) : CampaignsComponent, AppComponentContext by appComponentContext {
private val model: CampaignsModel = getOrCreateModel()
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val activeChild = bottomSheet.child?.instance
val displayedChild = rememberLastNonNull(activeChild)
val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle()
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = activeChild != null,
onDismissRequest = model::onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors3.bg.primary,
footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight,
onBack = model::onDismiss,
title = {
displayedChild?.Title()
},
content = {
Box(modifier = Modifier.animateContentSize()) {
displayedChild?.Content(modifier = Modifier)
}
},
footer = {
Box(
modifier = Modifier
.navigationBarsPadding()
.padding(12.dp),
) {
displayedChild?.Footer()
}
},
)
}
private fun bottomSheetChild(
config: CampaignsBottomSheetConfig,
componentContext: ComponentContext,
): ComposableModularContentComponent {
val context = childByContext(componentContext)
return when (config) {
CampaignsBottomSheetConfig.NotActive -> NotActiveCampaignBottomSheetComponent(
onDismissRequest = model::onDismiss,
)
is CampaignsBottomSheetConfig.Enrolled -> CampaignEnrolledBottomSheetComponent(
params = CampaignEnrolledBottomSheetComponent.Params(
campaignType = config.campaignType,
),
onDismissRequest = model::onDismiss,
)
is CampaignsBottomSheetConfig.Activate -> ActivateCampaignBottomSheetComponent(
appComponentContext = context,
chooseTokenComponentFactory = chooseTokenComponentFactory,
onDismiss = model::onDismiss,
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
},
),
)
is CampaignsBottomSheetConfig.AlreadyActivated -> CampaignAlreadyActivatedBottomSheetComponent(
appComponentContext = context,
params = CampaignAlreadyActivatedBottomSheetComponent.Params(
campaignType = config.campaignType,
),
onDismiss = model::onDismiss,
)
}
}
@AssistedFactory
interface Factory : CampaignsComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultCampaignsComponent
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.promobanners.impl.campaigns.component
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.decompose.ComposableModularContentComponent
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.core.ui.extensions.stringResourceSafe
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.ui.NotActiveCampaignMessageContent
internal class NotActiveCampaignBottomSheetComponent(
private val onDismissRequest: () -> Unit,
) : ComposableModularContentComponent {
@Composable
override fun Title() {
TangemTopNavigation(
windowInsets = WindowInsets(0),
blurBackground = false,
endButton = { TangemButton.Close(onClick = onDismissRequest) },
)
}
@Composable
override fun Content(modifier: Modifier) {
NotActiveCampaignMessageContent(modifier = modifier)
}
@Composable
override fun Footer() {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_close),
onClick = { onDismissRequest() },
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.promobanners.impl.campaigns.converters
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import com.tangem.utils.converter.Converter
import javax.inject.Inject
internal class CampaignIdConverter @Inject constructor() :
Converter<String, CampaignType?> {
override fun convert(value: String): CampaignType? {
return when (value) {
CAMPAIGN_ID_REACTIVATION -> CampaignType.ReactivationCashback(campaignId = value)
CAMPAIGN_ID_WHALE -> CampaignType.WhaleSwapCashback(campaignId = value)
else -> null
}
}
private companion object {
const val CAMPAIGN_ID_WHALE = "1"
const val CAMPAIGN_ID_REACTIVATION = "2"
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.promobanners.impl.campaigns.deeplink
import com.tangem.common.routing.deeplink.DeeplinkConst
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler
import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Handles the campaigns deeplink (`tangem://campaigns?campaignId=1&lang=ru`): extracts the `campaignId` and
* pushes it to the promo-campaigns bus. The always-alive [com.tangem.features.promobanners.api.swapcashback
* .SwapCashbackCampaignComponent] listens to the bus, maps the id to a campaign type, resolves the campaign
* state and shows the right sheet over the current screen.
*/
internal class DefaultCampaignsDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
campaignsService: CampaignsService,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
promoBannersFeatureToggles: PromoBannersFeatureToggles,
) : CampaignsDeepLinkHandler {
init {
if (promoBannersFeatureToggles.isCampaignsToggleEnabled) {
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
getSelectedWalletSyncUseCase().fold(
ifLeft = {
TangemLogger.e("Error on getting user wallet")
},
ifRight = { userWallet ->
val campaignId = queryParams[DeeplinkConst.CAMPAIGN_ID_KEY].orEmpty()
val userWalletId = userWallet.walletId
campaignsService.show(campaignId = campaignId, userWalletId = userWalletId)
},
)
} else {
TangemLogger.i("Campaigns feature is disabled")
}
}
@AssistedFactory
interface Factory : CampaignsDeepLinkHandler.Factory {
override fun create(queryParams: Map<String, String>): DefaultCampaignsDeepLinkHandler
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.features.promobanners.impl.campaigns.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler
import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent
import com.tangem.features.promobanners.impl.campaigns.component.DefaultCampaignsComponent
import com.tangem.features.promobanners.impl.campaigns.deeplink.DefaultCampaignsDeepLinkHandler
import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel
import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
import com.tangem.features.promobanners.impl.campaigns.service.DefaultCampaignsService
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface CampaignsModule {
@Binds
@Singleton
fun bindCampaignsComponentFactory(factory: DefaultCampaignsComponent.Factory): CampaignsComponent.Factory
@Binds
@Singleton
fun bindCampaignsDeepLinkHandlerFactory(
factory: DefaultCampaignsDeepLinkHandler.Factory,
): CampaignsDeepLinkHandler.Factory
@Binds
@Singleton
fun bindCampaignsService(service: DefaultCampaignsService): CampaignsService
}
@Module
@InstallIn(ModelComponent::class)
internal interface CampaignsModelModule {
@Binds
@IntoMap
@ClassKey(CampaignsModel::class)
fun bindCampaignModel(model: CampaignsModel): Model
@Binds
@IntoMap
@ClassKey(ActivateCampaignsModel::class)
fun bindCampaignActivateModel(model: ActivateCampaignsModel): Model
}

View file

@ -0,0 +1,53 @@
package com.tangem.features.promobanners.impl.campaigns.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
/**
* State of the campaign activation bottom sheet.
*
* The intro promo screen (image + title + description) is always shown. When [selectedToken] is `null`
* the footer shows "Select token"; once a token is chosen it shows the account block, the terms agreement
* and the "Enroll" button. When [isChoosingToken] is `true` the token selector is shown on top of the
* intro (as a stacked bottom sheet), not instead of it.
*
* [selectedAccount] is shown above the token only in accounts (multi-account) mode it names the account
* the asset was picked from. It is `null` in single-account mode or before a token is chosen.
*/
@Immutable
internal data class ActivateCampaignUM(
val logo: TangemIconUM,
val title: TextReference,
val description: TextReference,
val selectedToken: TokenItemState?,
val selectedAccount: SelectedAccountUM?,
val isChoosingToken: Boolean,
val footerUM: FooterUM,
val onChooseTokenDismiss: () -> Unit,
val onLearnMoreClick: () -> Unit,
val onChooseTokenClick: () -> Unit,
)
@Immutable
internal data class FooterUM(
val label: TextReference,
val onPrimaryButtonClick: () -> Unit,
val terms: TermsUM? = null,
)
@Immutable
data class TermsUM(
val text: TextReference,
val linkText: TextReference,
val onTermsClick: () -> Unit,
)
@Immutable
internal data class SelectedAccountUM(
val iconState: CurrencyIconState,
val name: TextReference,
)

View file

@ -0,0 +1,15 @@
package com.tangem.features.promobanners.impl.campaigns.entity
import kotlinx.serialization.Serializable
@Serializable
internal sealed class CampaignType {
abstract val campaignId: String
@Serializable
data class ReactivationCashback(override val campaignId: String) : CampaignType()
@Serializable
data class WhaleSwapCashback(override val campaignId: String) : CampaignType()
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.promobanners.impl.campaigns.entity
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.model.CampaignContent
import com.tangem.utils.converter.Converter
internal class CampaignTypeToContentConverter : Converter<CampaignType, CampaignContent> {
override fun convert(value: CampaignType): CampaignContent = when (value) {
is CampaignType.ReactivationCashback -> CampaignContent(
name = "Summer Swap Cashback",
logo = TangemIconUM.Url(
url = "https://s3.dualstack.eu-central-1.amazonaws.com/tangem.api/stories/Reactivation_Cashback.webp",
fallbackRes = R.drawable.ic_alert_24,
),
description = resourceReference(R.string.promo_campaign_reactivation_summary_description),
termsUrl = "https://tangem.com/docs/en/summer-swap-cashback-terms.pdf",
learnMoreUrl = "https://tangem.com/en/blog/post/summer-swap",
)
is CampaignType.WhaleSwapCashback -> CampaignContent(
name = "Whale Swap Cashback",
logo = TangemIconUM.Url(
url = "https://s3.dualstack.eu-central-1.amazonaws.com/tangem.api/stories/Whale_Swap_Cashback.webp",
fallbackRes = R.drawable.ic_alert_24,
),
description = resourceReference(R.string.promo_campaign_whale_swap_summary_description),
termsUrl = "https://tangem.com/docs/en/whale-swap-cashback-terms.pdf",
learnMoreUrl = "https://tangem.com/en/blog/post/whale-swap",
)
}
}
internal fun CampaignType.toPromoCampaignId(): PromoCampaignId = when (this) {
is CampaignType.ReactivationCashback -> PromoCampaignId.ReactivationCashback
is CampaignType.WhaleSwapCashback -> PromoCampaignId.WhaleSwapCashback
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.promobanners.impl.campaigns.entity
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
@Serializable
internal sealed class CampaignsBottomSheetConfig {
@Serializable
data object NotActive : CampaignsBottomSheetConfig()
@Serializable
data class Enrolled(
val campaignType: CampaignType,
) : CampaignsBottomSheetConfig()
@Serializable
data class Activate(
val campaignType: CampaignType,
val userWalletId: UserWalletId,
) : CampaignsBottomSheetConfig()
@Serializable
data class AlreadyActivated(
val campaignType: CampaignType,
) : CampaignsBottomSheetConfig()
}

View file

@ -0,0 +1,236 @@
package com.tangem.features.promobanners.impl.campaigns.model
import com.tangem.common.ui.account.AccountIconItemStateConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
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
import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter
import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class ActivateCampaignsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
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>()
private val campaignType = params.campaignType
private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall)
private var appCurrency: AppCurrency = AppCurrency.Default
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())
val bridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(
modelScope = modelScope,
settings = ChooseTokenBridge.Settings(
title = resourceReference(R.string.common_choose_token),
chooserBlock = ChooserBlock.Predefined(predefinedTokensFlow),
isShowPaymentAccount = false,
isShowSingleCurrencyWallets = true,
),
)
init {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType))
getSelectedAppCurrencyUseCase.invokeOrDefault()
.onEach { appCurrency = it }
.launchIn(modelScope)
bridge.onCurrencyChosen.receiveAsFlow()
.onEach { result -> onTokenChosen(result) }
.launchIn(modelScope)
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(
logo = campaignContent.logo,
title = resourceReference(
R.string.promo_campaign_summary_title,
wrappedList(campaignContent.name),
),
description = campaignContent.description,
selectedToken = null,
selectedAccount = null,
isChoosingToken = false,
footerUM = FooterUM(
label = resourceReference(R.string.promo_campaign_select_token),
onPrimaryButtonClick = ::onChooseTokenClick,
),
onChooseTokenDismiss = ::onChooseTokenDismiss,
onLearnMoreClick = ::onLearnMoreClick,
onChooseTokenClick = ::onChooseTokenClick,
)
private fun onChooseTokenClick() {
uiState.update { it.copy(isChoosingToken = true) }
}
private fun onChooseTokenDismiss() {
uiState.update { it.copy(isChoosingToken = false) }
}
private fun onEnrollClick(selectedWalletId: UserWalletId, selectedToken: CryptoCurrency.Token) {
analyticsEventHandler.send(
PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
campaignType = campaignType,
token = selectedToken.symbol,
blockchain = selectedToken.network.name,
),
)
modelScope.launch {
enrollPromoCampaignUseCase.invoke(
campaign = campaignId,
tokenReward = TokenReward(
tokenAddress = selectedToken.contractAddress,
networkId = selectedToken.network.rawId,
),
walletIds = listOf(selectedWalletId),
).onLeft { error ->
TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error)
messageSender.send(ToastMessage(message = resourceReference(R.string.common_unknown_error)))
}.onRight {
handleEnrollResponse(it)
}
}
}
private fun handleEnrollResponse(enrollResult: EnrollResult) {
when (enrollResult) {
is EnrollResult.AlreadyEnrolled -> params.modelCallbacks.onAlreadyActivated(campaignType)
is EnrollResult.Success -> params.modelCallbacks.onActivated(campaignType)
}
}
private fun onTermsClick() {
urlOpener.openUrl(campaignContent.termsUrl)
}
private fun onLearnMoreClick() {
urlOpener.openUrl(campaignContent.learnMoreUrl)
}
private fun onTokenChosen(result: ChooseTokenResult) {
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
modelScope.launch {
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
when (val account = result.account.account) {
is Account.CryptoPortfolio -> SelectedAccountUM(
iconState = accountIconConverter.convert(account),
name = account.accountName.toUM().value,
)
is Account.Payment -> SelectedAccountUM(
iconState = CurrencyIconState.PaymentAccount(size = AccountIconSize.ExtraSmall),
name = account.accountName.toUM().value,
)
is Account.Virtual -> null
}
} else {
null
}
val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency)
uiState.update { state ->
state.copy(
isChoosingToken = false,
selectedToken = tokenItem,
selectedAccount = selectedAccountUM,
footerUM = FooterUM(
label = resourceReference(R.string.promo_campaign_enroll),
onPrimaryButtonClick = {
onEnrollClick(
selectedWalletId = result.walletId,
selectedToken = selectedToken,
)
},
terms = TermsUM(
text = resourceReference(R.string.promo_campaign_terms_agreement_android),
linkText = resourceReference(
R.string.promo_campaign_terms_link,
wrappedList(campaignContent.name),
),
onTermsClick = ::onTermsClick,
),
),
)
}
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.promobanners.impl.campaigns.model
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
internal data class CampaignContent(
val name: String,
val logo: TangemIconUM,
val description: TextReference,
val termsUrl: String,
val learnMoreUrl: String,
)

View file

@ -0,0 +1,112 @@
package com.tangem.features.promobanners.impl.campaigns.model
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class CampaignsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val campaignIdConverter: CampaignIdConverter,
campaignsService: CampaignsService,
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
@GlobalUiMessageSender private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation()
val footerExtraHeightState: StateFlow<Dp>
field = MutableStateFlow(0.dp)
init {
campaignsService.campaignFlow
.onEach { request ->
resolveStartNavigation(
campaignType = campaignIdConverter.convert(request.campaignId),
userWalletId = request.userWalletId,
)
}
.launchIn(modelScope)
}
private fun resolveStartNavigation(campaignType: CampaignType?, userWalletId: UserWalletId) {
modelScope.launch {
val config = if (campaignType == null) {
CampaignsBottomSheetConfig.NotActive
} else {
checkCampaignState(campaignType, userWalletId)
}
config?.let { bottomSheetNavigation.activate(it) }
}
}
private suspend fun checkCampaignState(
campaignType: CampaignType,
userWalletId: UserWalletId,
): CampaignsBottomSheetConfig? = getPromoCampaignStateUseCase.invoke(
campaign = campaignType.toPromoCampaignId(),
userWalletId = userWalletId,
).fold(
ifLeft = { error ->
TangemLogger.e("Error getting campaign ${campaignType.campaignId} state", error)
messageSender.send(SnackbarMessage(message = resourceReference(R.string.common_unknown_error)))
null
},
ifRight = { campaignState ->
when (campaignState) {
is PromoCampaignState.Enrolled,
is PromoCampaignState.Available,
-> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId)
is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive
}
},
)
fun onFooterExtraHeightReady(height: Dp) {
footerExtraHeightState.value = height
}
fun onDismiss() {
bottomSheetNavigation.dismiss()
}
fun onActivated(campaignType: CampaignType) {
footerExtraHeightState.value = 0.dp
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType))
}
fun onAlreadyActivated(campaignType: CampaignType) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
footerExtraHeightState.value = 0.dp
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType))
}
}

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

@ -0,0 +1,24 @@
package com.tangem.features.promobanners.impl.campaigns.service
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* App-wide bus that decouples the promo-campaigns deeplink handler from the UI that shows the campaign
* bottom sheet. A producer (deeplink handler) calls [show]; the always-alive campaign component listens
* to [campaignFlow] and activates the appropriate sheet over the current screen.
*/
internal interface CampaignsService {
/** Emits the campaign requested via [show]. */
val campaignFlow: Flow<CampaignRequest>
/** Requests showing the campaign identified by [campaignId] for the given [userWalletId]. */
fun show(campaignId: String, userWalletId: UserWalletId)
}
/** Payload of the campaigns bus: the campaign id and the wallet the campaign should be activated for. */
internal data class CampaignRequest(
val campaignId: String,
val userWalletId: UserWalletId,
)

View file

@ -0,0 +1,19 @@
package com.tangem.features.promobanners.impl.campaigns.service
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultCampaignsService @Inject constructor() : CampaignsService {
private val _campaignFlow: Channel<CampaignRequest> = Channel(Channel.BUFFERED)
override val campaignFlow: Flow<CampaignRequest> = _campaignFlow.receiveAsFlow()
override fun show(campaignId: String, userWalletId: UserWalletId) {
_campaignFlow.trySend(CampaignRequest(campaignId = campaignId, userWalletId = userWalletId))
}
}

View file

@ -0,0 +1,145 @@
package com.tangem.features.promobanners.impl.campaigns.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
@Composable
internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemIcon(
tangemIconUM = um.logo,
modifier = Modifier
.size(80.dp)
.clip(CircleShape),
)
SpacerH32()
Text(
text = um.title.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
SpacerH8()
Text(
text = um.description.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
modifier = Modifier.fillMaxWidth(),
)
SpacerH12()
Text(
text = stringResourceSafe(R.string.common_learn_more),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.primary,
modifier = Modifier
.align(Alignment.Start)
.clickable(onClick = um.onLearnMoreClick),
)
SelectedTokenContent(
selectedToken = um.selectedToken,
selectedAccount = um.selectedAccount,
onChooseTokenClick = um.onChooseTokenClick,
)
SpacerH32()
}
}
@Composable
private fun SelectedTokenContent(
selectedToken: TokenItemState?,
selectedAccount: SelectedAccountUM?,
onChooseTokenClick: () -> Unit,
) {
if (selectedToken != null) {
SpacerH24()
Text(
text = stringResourceSafe(R.string.promo_campaign_select_cashback_account),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.fillMaxWidth(),
)
SpacerH12()
PromoCampaignTokenItem(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors3.bg.tertiary)
.clickable {
onChooseTokenClick.invoke()
},
selectedToken = selectedToken,
selectedAccount = selectedAccount,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ActivateCampaignContent_WithToken() {
TangemThemePreviewRedesign {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
ActivateCampaignContent(um = CampaignPreviewData.activateCampaign)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ActivateCampaignContent_NoToken() {
TangemThemePreviewRedesign {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
ActivateCampaignContent(
um = CampaignPreviewData.activateCampaign.copy(
selectedToken = null,
selectedAccount = null,
),
)
}
}
}
// endregion

View file

@ -0,0 +1,121 @@
package com.tangem.features.promobanners.impl.campaigns.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
@Composable
internal fun ActivateCampaignFooter(
footerUM: FooterUM,
onFooterTextHeightReady: (Dp) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
val terms = footerUM.terms
if (terms != null) {
val density = LocalDensity.current
Text(
text = termsAnnotatedString(terms),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.onSizeChanged {
val termsBlockHeight = with(density) { it.height.toDp() } + 12.dp
onFooterTextHeightReady.invoke(termsBlockHeight)
},
)
SpacerH12()
}
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = footerUM.label.resolveReference(),
onClick = footerUM.onPrimaryButtonClick,
)
}
}
@Composable
private fun termsAnnotatedString(terms: TermsUM) = buildAnnotatedString {
val startText = terms.text.resolveReference()
val linkText = terms.linkText.resolveReference()
append(startText)
append(" ")
withLink(
link = LinkAnnotation.Clickable(
tag = "CAMPAIGN_TERMS",
linkInteractionListener = { terms.onTermsClick() },
),
) {
withStyle(
SpanStyle(
color = TangemTheme.colors3.text.primary,
textDecoration = TextDecoration.None,
),
) {
append(linkText)
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ActivateCampaignFooter_WithTerms() {
TangemThemePreviewRedesign {
ActivateCampaignFooter(
footerUM = CampaignPreviewData.footer,
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ActivateCampaignFooter_NoTerms() {
TangemThemePreviewRedesign {
ActivateCampaignFooter(
footerUM = CampaignPreviewData.footer.copy(terms = null),
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}
// endregion

View file

@ -0,0 +1,73 @@
package com.tangem.features.promobanners.impl.campaigns.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_info_24
@Composable
internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.infoSubtle),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.ic_info_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.status.info,
)
}
SpacerH32()
Text(
text = message.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SpacerH32()
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_AlreadyActivatedCampaignContent() {
TangemThemePreviewRedesign {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
AlreadyActivatedCampaignContent(message = stringReference("Youre already enrolled in Whale Swap Cashback"))
}
}
}
// endregion

View file

@ -0,0 +1,79 @@
package com.tangem.features.promobanners.impl.campaigns.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_success_24
@Composable
fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.successSubtle),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.ic_success_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.status.success,
)
}
SpacerH32()
Text(
text = message.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_CampaignEnrolledMessageContent() {
TangemThemePreviewRedesign {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
CampaignEnrolledMessageContent(
message = stringReference("Youre successfully enrolled in Enroll in Whale Swap Cashback"),
)
}
}
}
// endregion

View file

@ -0,0 +1,68 @@
package com.tangem.features.promobanners.impl.campaigns.ui
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.promobanners.impl.R
import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM
import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
/**
* Shared preview fixtures for the campaign UI `@Preview`s. Not used in production code.
*/
internal object CampaignPreviewData {
val tokenItem: TokenItemState.Content = TokenItemState.Content(
id = "preview-token",
iconState = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = stringReference("Polygon")),
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("MATIC")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"),
onItemClick = {},
onItemLongClick = {},
)
val selectedAccount: SelectedAccountUM = SelectedAccountUM(
iconState = CurrencyIconState.CryptoPortfolio.Icon(
resId = com.tangem.core.ui.R.drawable.ic_rounded_star_24,
color = Color(color = 0xFF0099FF),
isGrayscale = false,
),
name = stringReference("Main account"),
)
val footer: FooterUM = FooterUM(
label = stringReference("Enroll"),
onPrimaryButtonClick = {},
terms = TermsUM(
text = stringReference("By enrolling you agree to the"),
linkText = stringReference("Terms & Conditions"),
onTermsClick = {},
),
)
val activateCampaign: ActivateCampaignUM = ActivateCampaignUM(
logo = TangemIconUM.Icon(R.drawable.ic_alert_24),
title = stringReference("Whale Swap Cashback"),
description = stringReference(
"Get cashback on every swap. Pick a token and the account where your rewards will be paid out.",
),
selectedToken = tokenItem,
selectedAccount = selectedAccount,
isChoosingToken = false,
footerUM = footer,
onChooseTokenDismiss = {},
onLearnMoreClick = {},
onChooseTokenClick = {},
)
}

View file

@ -0,0 +1,71 @@
package com.tangem.features.promobanners.impl.campaigns.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.features.promobanners.impl.R
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_warning_24
@Composable
fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.warningSubtle),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.ic_warning_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.status.warning,
)
}
SpacerH32()
Text(
text = stringResourceSafe(R.string.promo_campaign_not_active_title),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH8()
Text(
text = stringResourceSafe(R.string.promo_campaign_not_active_subtitle),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.features.promobanners.impl.campaigns.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.account.AccountCharIcon
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.AccountResIcon
import com.tangem.core.ui.components.account.PaymentAccountIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
@Composable
internal fun PromoCampaignTokenItem(
selectedToken: TokenItemState,
selectedAccount: SelectedAccountUM?,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
if (selectedAccount != null) {
Row(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (val icon = selectedAccount.iconState) {
is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(size = icon.size)
is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon(
resId = icon.resId,
color = icon.color,
size = AccountIconSize.ExtraSmall,
)
is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon(
char = icon.char.resolveReference().first(),
color = icon.color,
size = AccountIconSize.ExtraSmall,
)
is CurrencyIconState.CoinIcon,
is CurrencyIconState.CustomTokenIcon,
is CurrencyIconState.Empty,
is CurrencyIconState.FiatIcon,
CurrencyIconState.Loading,
CurrencyIconState.Locked,
is CurrencyIconState.TokenIcon,
-> Unit
}
SpacerW4()
Text(
modifier = Modifier
.padding(vertical = 2.dp)
.alignByBaseline(),
text = selectedAccount.name.resolveReference(),
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.caption1,
)
}
SpacerH8()
}
TokenItem(
state = selectedToken,
isBalanceHidden = false,
itemPaddingValues = PaddingValues(horizontal = 16.dp),
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_PromoCampaignTokenItem_WithAccount() {
TangemThemePreviewRedesign {
PromoCampaignTokenItem(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors.background.primary),
selectedToken = CampaignPreviewData.tokenItem,
selectedAccount = CampaignPreviewData.selectedAccount,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_PromoCampaignTokenItem_NoAccount() {
TangemThemePreviewRedesign {
PromoCampaignTokenItem(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors.background.primary),
selectedToken = CampaignPreviewData.tokenItem,
selectedAccount = null,
)
}
}
// endregion

View file

@ -5,10 +5,12 @@ import com.tangem.core.decompose.model.Model
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles
import com.tangem.features.promobanners.impl.DefaultPromoBannersBlockComponent
import com.tangem.features.promobanners.impl.model.PromoBannersBlockModel
import com.tangem.features.promobanners.impl.repository.DefaultPromoBannersRepository
import com.tangem.features.promobanners.impl.repository.PromoBannersRepository
import com.tangem.features.promobanners.impl.toggles.DefaultPromoBannersFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
@ -29,6 +31,10 @@ internal interface PromoBannersFeatureModule {
factory: DefaultPromoBannersBlockComponent.Factory,
): PromoBannersBlockComponent.Factory
@Binds
@Singleton
fun bindPromoBannersFeatureToggles(impl: DefaultPromoBannersFeatureToggles): PromoBannersFeatureToggles
companion object {
@Provides

View file

@ -0,0 +1,16 @@
package com.tangem.features.promobanners.impl.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles
import javax.inject.Inject
class DefaultPromoBannersFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : PromoBannersFeatureToggles {
override val isCampaignsToggleEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED,
)
}

View file

@ -71,10 +71,13 @@ private fun bannerContainerColor(placeholder: Placeholder): Color = if (LocalRed
when (placeholder) {
Placeholder.MAIN -> TangemTheme.colors2.surface.level1
Placeholder.FEED -> TangemTheme.colors2.surface.level3
Placeholder.PAYMENT_ACCOUNT_MAIN -> TangemTheme.colors3.bg.opaque.primary
}
} else {
when (placeholder) {
Placeholder.MAIN -> TangemTheme.colors.background.primary
Placeholder.MAIN,
Placeholder.PAYMENT_ACCOUNT_MAIN,
-> TangemTheme.colors.background.primary
Placeholder.FEED -> TangemTheme.colors.background.action
}
}

View file

@ -0,0 +1,90 @@
package com.tangem.features.promobanners.impl.campaigns.analytics
import com.google.common.truth.Truth.assertThat
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class PromoCampaignsAnalyticsEventTest {
@Test
fun `GIVEN any event WHEN created THEN category is Promotion`() {
// Arrange
val events = listOf(
PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = whaleSwap),
PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
campaignType = whaleSwap,
token = "USDT",
blockchain = "Ethereum",
),
PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened(),
)
// Assert
assertThat(events.map { it.category }).containsExactly("Promotion", "Promotion", "Promotion")
}
@ParameterizedTest
@MethodSource("provideCampaignTypes")
fun `GIVEN campaign type WHEN PromotionScreenOpened THEN event name and Screen param are correct`(
model: CampaignNameModel,
) {
// Act
val event = PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = model.campaignType)
// Assert
assertThat(event.event).isEqualTo("Promotion Screen Opened")
assertThat(event.params).containsExactly("Screen", model.expectedAnalyticsName)
}
@ParameterizedTest
@MethodSource("provideCampaignTypes")
fun `GIVEN campaign type WHEN EnrollButtonClicked THEN event name and params are correct`(
model: CampaignNameModel,
) {
// Act
val event = PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
campaignType = model.campaignType,
token = "USDT",
blockchain = "Ethereum",
)
// Assert
assertThat(event.event).isEqualTo("Enroll Button Clicked")
assertThat(event.params).containsExactly(
"Campaign", model.expectedAnalyticsName,
"Token", "USDT",
"Blockchain", "Ethereum",
)
}
@Test
fun `GIVEN AlreadyEnrolledScreenOpened WHEN created THEN event name is correct and no params`() {
// Act
val event = PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened()
// Assert
assertThat(event.event).isEqualTo("Already Enrolled Screen Opened")
assertThat(event.params).isEmpty()
}
private fun provideCampaignTypes() = listOf(
CampaignNameModel(campaignType = whaleSwap, expectedAnalyticsName = "Cashback"),
CampaignNameModel(campaignType = reactivation, expectedAnalyticsName = "Reactivation"),
)
internal data class CampaignNameModel(
val campaignType: CampaignType,
val expectedAnalyticsName: String,
) {
override fun toString(): String = "${campaignType::class.simpleName} -> $expectedAnalyticsName"
}
private companion object {
val whaleSwap = CampaignType.WhaleSwapCashback(campaignId = "whale")
val reactivation = CampaignType.ReactivationCashback(campaignId = "reactivation")
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.promobanners.impl.campaigns.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CampaignIdConverterTest {
private val converter = CampaignIdConverter()
@ParameterizedTest
@MethodSource("provideConvertModels")
fun `GIVEN campaign id WHEN convert THEN correct campaign type is returned`(model: ConvertModel) {
// Act
val actual = converter.convert(model.id)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
private fun provideConvertModels() = listOf(
ConvertModel(id = "1", expected = CampaignType.WhaleSwapCashback(campaignId = "1")),
ConvertModel(id = "2", expected = CampaignType.ReactivationCashback(campaignId = "2")),
ConvertModel(id = "0", expected = null),
ConvertModel(id = "unknown", expected = null),
ConvertModel(id = "", expected = null),
)
internal data class ConvertModel(
val id: String,
val expected: CampaignType?,
) {
override fun toString(): String = "\"$id\" -> ${expected?.let { it::class.simpleName } ?: "null"}"
}
}

View file

@ -0,0 +1,273 @@
package com.tangem.features.promobanners.impl.campaigns.model
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
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.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
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
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
import org.junit.jupiter.params.provider.MethodSource
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ActivateCampaignsModelTest {
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk()
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)
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private lateinit var onCurrencyChosen: Channel<ChooseTokenResult>
@BeforeEach
fun setup() {
clearMocks(
getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase,
enrollPromoCampaignUseCase,
messageSender,
analyticsEventHandler,
modelCallbacks,
)
}
@ParameterizedTest
@MethodSource("provideCampaignTypes")
fun `GIVEN campaign type WHEN model created THEN PromotionScreenOpened is sent`(campaignType: CampaignType) =
runTest {
// Act
val model = createModel(campaignType)
advanceUntilIdle()
// Assert
verify(exactly = 1) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType))
}
model.onDestroy()
}
@Test
fun `WHEN choose token clicked and dismissed THEN isChoosingToken toggles`() = runTest {
// Arrange
val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1"))
advanceUntilIdle()
// Act & Assert
model.uiState.value.onChooseTokenClick()
assertThat(model.uiState.value.isChoosingToken).isTrue()
model.uiState.value.onChooseTokenDismiss()
assertThat(model.uiState.value.isChoosingToken).isFalse()
model.onDestroy()
}
@Test
fun `GIVEN a coin is chosen WHEN currency chosen THEN it is ignored`() = runTest {
// Arrange
val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1"))
advanceUntilIdle()
// Act
onCurrencyChosen.send(chooseTokenResult(currency = coin()))
advanceUntilIdle()
// Assert
assertThat(model.uiState.value.selectedToken).isNull()
model.onDestroy()
}
@Test
fun `GIVEN a token chosen and enroll succeeds WHEN enroll clicked THEN event carries symbol and blockchain`() =
runTest {
// Arrange
val token = token()
val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1")
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns
Either.Right(EnrollResult.Success(TokenReward(tokenAddress = "a", networkId = "b")))
val model = createModel(campaignType)
advanceUntilIdle()
// Act
onCurrencyChosen.send(chooseTokenResult(currency = token))
advanceUntilIdle()
model.uiState.value.footerUM.onPrimaryButtonClick()
advanceUntilIdle()
// Assert — Token param is the symbol (TTK), Blockchain param is the network name (Ethereum)
val events = mutableListOf<AnalyticsEvent>()
verify { analyticsEventHandler.send(capture(events)) }
val enrollEvent = events.filterIsInstance<PromoCampaignsAnalyticsEvent.EnrollButtonClicked>().single()
assertThat(enrollEvent.event).isEqualTo("Enroll Button Clicked")
assertThat(enrollEvent.params).containsExactly(
"Campaign", "Cashback",
"Token", "TTK",
"Blockchain", "Ethereum",
)
coVerify(exactly = 1) {
enrollPromoCampaignUseCase.invoke(
campaign = PromoCampaignId.WhaleSwapCashback,
tokenReward = TokenReward(tokenAddress = token.contractAddress, networkId = token.network.rawId),
walletIds = listOf(userWalletId),
)
}
verify(exactly = 1) { modelCallbacks.onActivated(campaignType) }
model.onDestroy()
}
@Test
fun `GIVEN enroll returns AlreadyEnrolled WHEN enroll clicked THEN onAlreadyActivated called`() = runTest {
// Arrange
val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1")
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns
Either.Right(EnrollResult.AlreadyEnrolled(TokenReward(tokenAddress = "a", networkId = "b")))
val model = createModel(campaignType)
advanceUntilIdle()
// Act
onCurrencyChosen.send(chooseTokenResult(currency = token()))
advanceUntilIdle()
model.uiState.value.footerUM.onPrimaryButtonClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { modelCallbacks.onAlreadyActivated(campaignType) }
verify(exactly = 0) { modelCallbacks.onActivated(any()) }
model.onDestroy()
}
@Test
fun `GIVEN enroll fails WHEN enroll clicked THEN error message is sent and no callback`() = runTest {
// Arrange
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns
Either.Left(RuntimeException("network"))
val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1"))
advanceUntilIdle()
// Act
onCurrencyChosen.send(chooseTokenResult(currency = token()))
advanceUntilIdle()
model.uiState.value.footerUM.onPrimaryButtonClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { messageSender.send(any()) }
verify(exactly = 0) { modelCallbacks.onActivated(any()) }
verify(exactly = 0) { modelCallbacks.onAlreadyActivated(any()) }
model.onDestroy()
}
private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult {
val status = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null),
)
val wallet: UserWallet = mockk {
every { walletId } returns userWalletId
}
return ChooseTokenResult(currency = status, account = mockk(relaxed = true), wallet = wallet)
}
private fun TestScope.createModel(campaignType: CampaignType): ActivateCampaignsModel {
onCurrencyChosen = Channel(capacity = Channel.UNLIMITED)
val bridge = mockk<ChooseTokenBridge>(relaxed = true) {
every { onCurrencyChosen } returns this@ActivateCampaignsModelTest.onCurrencyChosen
every { onClose } returns Channel()
}
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,
),
),
dispatchers = createTestingCoroutineDispatcherProvider(),
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
enrollPromoCampaignUseCase = enrollPromoCampaignUseCase,
urlOpener = urlOpener,
messageSender = messageSender,
analyticsEventHandler = analyticsEventHandler,
getPromoCampaignStateUseCase = getPromoCampaignStateUseCase,
predefinedTokenResolver = predefinedTokenResolver,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
// Override name/symbol so analytics assertions pin the symbol ("TTK"), not the name ("TEST_TOKEN").
private fun token(): CryptoCurrency.Token = cryptoCurrencyFactory
.createToken(blockchain = Blockchain.Ethereum, contractAddress = "0xToken")
.copy(name = "TEST_TOKEN", symbol = "TTK")
private fun coin(): CryptoCurrency.Coin = cryptoCurrencyFactory.ethereum
private fun provideCampaignTypes() = listOf(
CampaignType.WhaleSwapCashback(campaignId = "1"),
CampaignType.ReactivationCashback(campaignId = "2"),
)
private companion object {
val userWalletId = UserWalletId("0011223344556677")
}
}

View file

@ -0,0 +1,197 @@
package com.tangem.features.promobanners.impl.campaigns.model
import androidx.compose.ui.unit.dp
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.models.PromoCampaignId
import com.tangem.domain.promo.models.PromoCampaignState
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
import com.tangem.features.promobanners.impl.campaigns.service.CampaignRequest
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
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
import org.junit.jupiter.params.provider.MethodSource
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CampaignsModelTest {
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk()
private val messageSender: UiMessageSender = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
// Real converter: "1" -> WhaleSwapCashback, "2" -> ReactivationCashback, else null.
private val campaignIdConverter = CampaignIdConverter()
@BeforeEach
fun setup() {
clearMocks(getPromoCampaignStateUseCase, messageSender, analyticsEventHandler)
}
@ParameterizedTest
@MethodSource("provideKnownCampaignModels")
fun `GIVEN known campaign request WHEN emitted THEN campaign state is checked with mapped id`(
model: KnownCampaignModel,
) = runTest {
// Arrange
coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns
Either.Right(PromoCampaignState.NotActive(model.expectedPromoId))
val campaignsModel = createModel(
campaignFlow = flowOf(CampaignRequest(campaignId = model.campaignId, userWalletId = userWalletId)),
)
// Act
advanceUntilIdle()
// Assert
coVerify(exactly = 1) {
getPromoCampaignStateUseCase.invoke(
campaign = model.expectedPromoId,
userWalletId = userWalletId,
forceRefresh = any(),
)
}
verify { messageSender wasNot Called }
campaignsModel.onDestroy()
}
@Test
fun `GIVEN campaign state fails WHEN emitted THEN error message is sent`() = runTest {
// Arrange
coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns
Either.Left(RuntimeException("network"))
val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "1", userWalletId = userWalletId)))
// Act
advanceUntilIdle()
// Assert
verify(exactly = 1) { messageSender.send(any()) }
model.onDestroy()
}
@Test
fun `GIVEN unknown campaign id WHEN emitted THEN campaign state is not checked`() = runTest {
// Arrange
val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "unknown", userWalletId = userWalletId)))
// Act
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { getPromoCampaignStateUseCase.invoke(any(), any(), any()) }
verify { messageSender wasNot Called }
model.onDestroy()
}
@Test
fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
model.onFooterExtraHeightReady(100.dp)
// Act
model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
// Assert
verify(exactly = 1) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
}
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
model.onDestroy()
}
@Test
fun `GIVEN footer height set WHEN onActivated THEN no analytics and height reset`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
model.onFooterExtraHeightReady(100.dp)
// Act
model.onActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
// Assert
verify { analyticsEventHandler wasNot Called }
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
model.onDestroy()
}
@Test
fun `WHEN onFooterExtraHeightReady THEN height state is updated`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
// Act
model.onFooterExtraHeightReady(42.dp)
// Assert
assertThat(model.footerExtraHeightState.value).isEqualTo(42.dp)
model.onDestroy()
}
private fun TestScope.createModel(campaignFlow: Flow<CampaignRequest>): CampaignsModel {
val campaignsService: CampaignsService = mockk {
every { this@mockk.campaignFlow } returns campaignFlow
}
return CampaignsModel(
dispatchers = createTestingCoroutineDispatcherProvider(),
campaignIdConverter = campaignIdConverter,
campaignsService = campaignsService,
getPromoCampaignStateUseCase = getPromoCampaignStateUseCase,
messageSender = messageSender,
analyticsEventHandler = analyticsEventHandler,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private fun provideKnownCampaignModels() = listOf(
KnownCampaignModel(campaignId = "1", expectedPromoId = PromoCampaignId.WhaleSwapCashback),
KnownCampaignModel(campaignId = "2", expectedPromoId = PromoCampaignId.ReactivationCashback),
)
internal data class KnownCampaignModel(
val campaignId: String,
val expectedPromoId: PromoCampaignId,
) {
override fun toString(): String = "\"$campaignId\" -> $expectedPromoId"
}
private companion object {
val userWalletId = UserWalletId("0011223344556677")
}
}

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