Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 13:25:18 +02:00
parent a006a87f9d
commit 21893e1636
15 changed files with 874 additions and 0 deletions

View file

@ -243,6 +243,8 @@ dependencies {
/** Features */
implementation(projects.features.addressBook.api)
implementation(projects.features.addressBook.impl)
implementation(projects.features.marketing.api)
implementation(projects.features.marketing.impl)
implementation(projects.features.rating.impl)
implementation(projects.features.referral.impl)
implementation(projects.features.referral.domain)

View file

@ -0,0 +1,17 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.marketing.api"
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.domain.marketing.models)
implementation(deps.kotlin.coroutines)
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.marketing.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import kotlinx.coroutines.flow.Flow
interface MarketingBannerComponent : ComposableContentComponent {
sealed interface Params {
/**
* STANDALONE carousel; hosted on all 6 screens. `null` in the flow hides the banner.
*
* @param onDeeplinkClick optional interceptor for a tapped banner deeplink. Return `true` when
* the host routed it contextually (e.g. `tangem://swap`/`tangem://buy` for the current token);
* `false`/`null` lets the banner fall back to the generic deeplink launcher (external links).
*/
data class Standalone(
val requestFlow: Flow<MarketingBannerRequest?>,
val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null,
) : Params
/** LINKED_TO_PROVIDER single banner rendered inline next to an onramp provider offer. */
data class LinkedToProvider(val requestFlow: Flow<LinkedBannerRequest?>) : Params
}
interface Factory : ComponentFactory<Params, MarketingBannerComponent>
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.marketing.api
import com.tangem.domain.marketing.models.MarketingScreen
import java.math.BigDecimal
/** Context for a STANDALONE banner request on any of the 6 surfaces. */
data class MarketingBannerRequest(
val screen: MarketingScreen,
val amountUsd: BigDecimal? = null,
)
/** Context for a LINKED_TO_PROVIDER banner request (onramp only), matched against the shown provider. */
data class LinkedBannerRequest(
val screen: MarketingScreen.Onramp,
val amountUsd: BigDecimal?,
val currentProviderId: String,
)

View file

@ -0,0 +1,50 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.marketing.impl"
}
dependencies {
/** Project - API */
implementation(projects.features.marketing.api)
/** Domain */
implementation(projects.domain.marketing)
implementation(projects.domain.marketing.models)
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.lifecycle.compose)
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.coroutines)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(projects.test.core)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.turbine)
testImplementation(deps.test.coroutine)
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.marketing.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.impl.model.MarketingBannerModel
import com.tangem.features.marketing.impl.ui.MarketingBannerContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultMarketingBannerComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: MarketingBannerComponent.Params,
) : MarketingBannerComponent, AppComponentContext by appComponentContext {
private val model: MarketingBannerModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
MarketingBannerContent(
state = state,
onBannerClick = model::onBannerClick,
onDismiss = model::onDismiss,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : MarketingBannerComponent.Factory {
override fun create(
context: AppComponentContext,
params: MarketingBannerComponent.Params,
): DefaultMarketingBannerComponent
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.marketing.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.impl.DefaultMarketingBannerComponent
import com.tangem.features.marketing.impl.model.MarketingBannerModel
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 MarketingComponentModule {
@Binds
@Singleton
fun bindMarketingBannerComponentFactory(
factory: DefaultMarketingBannerComponent.Factory,
): MarketingBannerComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface MarketingModelModule {
@Binds
@IntoMap
@ClassKey(MarketingBannerModel::class)
fun bindMarketingBannerModel(model: MarketingBannerModel): Model
}

View file

@ -0,0 +1,149 @@
package com.tangem.features.marketing.impl.model
import arrow.core.getOrElse
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.navigation.deeplink.DeeplinkLauncher
import com.tangem.domain.marketing.DismissMarketingBannerUseCase
import com.tangem.domain.marketing.GetMarketingBannerUseCase
import com.tangem.domain.marketing.models.MarketingBanner
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.matchesUsdAmount
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@OptIn(FlowPreview::class)
@ModelScoped
internal class MarketingBannerModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val getMarketingBanner: GetMarketingBannerUseCase,
private val dismissMarketingBanner: DismissMarketingBannerUseCase,
private val deeplinkLauncher: DeeplinkLauncher,
) : Model() {
private val params = paramsContainer.require<MarketingBannerComponent.Params>()
private val dismissedIds = MutableStateFlow<Set<Int>>(emptySet())
val uiState: StateFlow<MarketingBannerListUM>
field = MutableStateFlow<MarketingBannerListUM>(MarketingBannerListUM.Hidden)
init {
observeBanners()
}
fun onBannerClick(deeplink: String?) {
if (deeplink.isNullOrBlank()) return
// Let the host route contextual deeplinks (swap/buy for the current token). Fall back to the
// generic launcher for external links and when no interceptor is provided.
val isHandledByHost = (params as? MarketingBannerComponent.Params.Standalone)
?.onDeeplinkClick?.invoke(deeplink) == true
if (!isHandledByHost) deeplinkLauncher.launch(deeplink)
}
fun onDismiss(campaignId: Int) {
dismissedIds.update { it + campaignId }
modelScope.launch { dismissMarketingBanner(campaignId) }
}
private fun observeBanners() {
val requestFlow: Flow<MarketingRequest?> = when (val p = params) {
is MarketingBannerComponent.Params.Standalone ->
p.requestFlow.map { request ->
request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd, providerId = null) }
}
is MarketingBannerComponent.Params.LinkedToProvider ->
p.requestFlow.map { request ->
request?.let { linked ->
MarketingRequest(
screen = linked.screen,
amountUsd = linked.amountUsd,
providerId = linked.currentProviderId,
)
}
}
}
val campaigns: Flow<List<MarketingCampaign>> = requestFlow
.map { it?.screen }
.distinctUntilChanged()
.debounce(REQUEST_DEBOUNCE_MS)
.mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() }
val amountUsd: Flow<BigDecimal?> = requestFlow.map { it?.amountUsd }.distinctUntilChanged()
val providerId: Flow<String?> = requestFlow.map { it?.providerId }.distinctUntilChanged()
modelScope.launch {
combine(
flow = campaigns,
flow2 = amountUsd,
flow3 = providerId,
flow4 = dismissedIds,
) { list, usd, provider, dismissed ->
list.asSequence()
.filterNot { it.id in dismissed }
.filter { it.matchesUsdAmount(usd) }
.filter { matchesUiTypeAndProvider(it, provider) }
.map { it.toUM() }
.toList()
}.collect { banners ->
uiState.value = if (banners.isEmpty()) {
MarketingBannerListUM.Hidden
} else {
MarketingBannerListUM.Content(banners.toImmutableList())
}
}
}
}
private suspend fun fetch(screen: MarketingScreen): List<MarketingCampaign> =
getMarketingBanner(screen, amountUsd = null).getOrElse { emptyList() }
private fun matchesUiTypeAndProvider(campaign: MarketingCampaign, providerId: String?): Boolean = when (params) {
is MarketingBannerComponent.Params.Standalone ->
campaign.banner.uiType == MarketingBanner.UiType.STANDALONE
is MarketingBannerComponent.Params.LinkedToProvider ->
campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER &&
providerId != null && campaign.providerIds?.contains(providerId) == true
}
private data class MarketingRequest(
val screen: MarketingScreen,
val amountUsd: BigDecimal?,
val providerId: String?,
)
private fun MarketingCampaign.toUM() = MarketingBannerUM(
campaignId = id,
text = banner.text,
iconUrl = banner.iconUrl,
iconAlign = when (banner.iconAlign) {
MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT
MarketingBanner.IconAlign.LEFT, null -> MarketingBannerUM.IconAlign.LEFT
},
isDismissible = banner.isDismissible,
deeplink = banner.deeplink,
)
private companion object {
const val REQUEST_DEBOUNCE_MS = 300L
}
}

View file

@ -0,0 +1,145 @@
package com.tangem.features.marketing.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.messagebanner.CloseButton
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
/**
* Marketing banner rendered with the design-system [TangemMessageBanner] (DS3): default variant with
* the "magic" glow ring, a title, an optional icon slot, and a cross-circle dismiss button.
*
* The whole banner is clickable and launches [onClick] (its deeplink) the marketing API exposes no
* banner buttons, only a single deeplink. The API's `bgColor` is intentionally not applied here: the DS
* component drives the background via its fixed [TangemMessageBanner.Variant], matching the design.
*/
@Composable
internal fun MarketingBanner(
banner: MarketingBannerUM,
onClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasDeeplink = !banner.deeplink.isNullOrBlank()
// Hide the icon slot (and its gap) when the image fails to load, so a broken URL leaves no empty gap.
var isIconFailed by remember(banner.iconUrl) { mutableStateOf(false) }
val hasIcon = !banner.iconUrl.isNullOrBlank() && !isIconFailed
val isIconAtStart = hasIcon && banner.iconAlign == MarketingBannerUM.IconAlign.LEFT
val isIconAtEnd = hasIcon && banner.iconAlign == MarketingBannerUM.IconAlign.RIGHT
TangemMessageBanner(
title = stringReference(banner.text.orEmpty()),
modifier = modifier.then(
if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier,
),
variant = TangemMessageBanner.Variant.Default,
showGlowRing = false,
slotStart = if (isIconAtStart) {
{ BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) }
} else {
null
},
slotEnd = if (isIconAtEnd || banner.isDismissible) {
{
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isIconAtEnd) {
BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true })
}
if (banner.isDismissible) {
TangemMessageBanner.CloseButton(
onClick = onDismiss,
contentDescription = stringResourceSafe(R.string.common_close),
)
}
}
}
} else {
null
},
)
}
@Composable
private fun BannerIcon(iconUrl: String?, onLoadError: () -> Unit) {
if (iconUrl.isNullOrBlank()) return
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(iconUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
onError = { onLoadError() },
modifier = Modifier.size(20.dp),
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_MarketingBanner() {
TangemThemePreviewRedesign {
MarketingBanner(
banner = MarketingBannerUM(
campaignId = 1,
text = "1:1 onramp at 0 fees!",
iconUrl = null,
iconAlign = MarketingBannerUM.IconAlign.LEFT,
isDismissible = true,
deeplink = "tangem://promo/1",
),
onClick = {},
onDismiss = {},
modifier = Modifier.padding(16.dp),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_MarketingBanner_NotDismissible() {
TangemThemePreviewRedesign {
MarketingBanner(
banner = MarketingBannerUM(
campaignId = 2,
text = "Earn up to 14% APY by staking your crypto directly from the wallet",
iconUrl = null,
iconAlign = MarketingBannerUM.IconAlign.RIGHT,
isDismissible = false,
deeplink = null,
),
onClick = {},
onDismiss = {},
modifier = Modifier.padding(16.dp),
)
}
}
// endregion

View file

@ -0,0 +1,45 @@
package com.tangem.features.marketing.impl.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.pager.PagerIndicator
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun MarketingBannerCarousel(
banners: ImmutableList<MarketingBannerUM>,
onBannerClick: (String?) -> Unit,
onDismiss: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val pagerState = rememberPagerState(pageCount = { banners.size })
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
pageSpacing = 8.dp,
key = { page -> banners[page].campaignId },
) { page ->
val banner = banners[page]
MarketingBanner(
banner = banner,
onClick = { onBannerClick(banner.deeplink) },
onDismiss = { onDismiss(banner.campaignId) },
)
}
PagerIndicator(pagerState = pagerState, hasBackground = false)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.marketing.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
@Composable
internal fun MarketingBannerContent(
state: MarketingBannerListUM,
onBannerClick: (String?) -> Unit,
onDismiss: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
is MarketingBannerListUM.Hidden -> Unit
is MarketingBannerListUM.Content -> {
val banners = state.banners
if (banners.size == 1) {
val banner = banners.first()
MarketingBanner(
banner = banner,
onClick = { onBannerClick(banner.deeplink) },
onDismiss = { onDismiss(banner.campaignId) },
modifier = modifier,
)
} else {
MarketingBannerCarousel(
banners = banners,
onBannerClick = onBannerClick,
onDismiss = onDismiss,
modifier = modifier,
)
}
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.marketing.impl.ui.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface MarketingBannerListUM {
data object Hidden : MarketingBannerListUM
data class Content(val banners: ImmutableList<MarketingBannerUM>) : MarketingBannerListUM
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.marketing.impl.ui.state
import androidx.compose.runtime.Immutable
@Immutable
internal data class MarketingBannerUM(
val campaignId: Int,
val text: String?,
val iconUrl: String?,
val iconAlign: IconAlign,
val isDismissible: Boolean,
val deeplink: String?,
) {
enum class IconAlign { LEFT, RIGHT }
}

View file

@ -0,0 +1,279 @@
package com.tangem.features.marketing.impl.model
import app.cash.turbine.test
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.domain.marketing.DismissMarketingBannerUseCase
import com.tangem.domain.marketing.GetMarketingBannerUseCase
import com.tangem.domain.marketing.models.MarketingBanner
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import com.tangem.features.marketing.api.LinkedBannerRequest
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
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
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketingBannerModelTest {
private val getMarketingBanner: GetMarketingBannerUseCase = mockk()
private val dismissMarketingBanner: DismissMarketingBannerUseCase = mockk()
private val deeplinkLauncher: DeeplinkLauncher = mockk(relaxed = true)
@BeforeEach
fun setup() {
clearMocks(getMarketingBanner, dismissMarketingBanner, deeplinkLauncher)
}
private fun TestScope.createModel(params: MarketingBannerComponent.Params): MarketingBannerModel {
val dispatcher = StandardTestDispatcher(testScheduler)
val dispatchers = object : CoroutineDispatcherProvider {
override val main = dispatcher
override val mainImmediate = dispatcher
override val io = dispatcher
override val default = dispatcher
override val single = dispatcher
}
return MarketingBannerModel(
dispatchers = dispatchers,
paramsContainer = MutableParamsContainer(params),
getMarketingBanner = getMarketingBanner,
dismissMarketingBanner = dismissMarketingBanner,
deeplinkLauncher = deeplinkLauncher,
)
}
private fun campaign(id: Int, uiType: MarketingBanner.UiType, providerIds: List<String>? = null) =
MarketingCampaign(
id = id,
type = MarketingScreenType.ONRAMP,
priority = id,
startAt = null,
endAt = null,
minAmount = null,
maxAmount = null,
providerIds = providerIds,
banner = MarketingBanner(
uiType = uiType,
text = "text-$id",
iconUrl = null,
iconAlign = null,
bgColor = null,
deeplink = "tangem://promo/$id",
isDismissible = true,
),
targets = emptyList(),
)
private val onrampScreen = MarketingScreen.Onramp("USD", "ethereum", "0xabc")
private fun swapScreen(fromContract: String = "0xF") =
MarketingScreen.Swap(fromNetwork = "eth", fromContractAddress = fromContract, toNetwork = "btc", toContractAddress = "0xT")
private fun gatedCampaign(id: Int) = MarketingCampaign(
id = id, type = MarketingScreenType.SWAP, priority = id, startAt = null, endAt = null,
minAmount = java.math.BigDecimal(50), maxAmount = java.math.BigDecimal(300), providerIds = null,
banner = MarketingBanner(
uiType = MarketingBanner.UiType.STANDALONE, text = "t$id", iconUrl = null,
iconAlign = null, bgColor = null, deeplink = null, isDismissible = false,
),
targets = emptyList(),
)
@Test
fun `GIVEN amount changes WHEN same pair THEN re-filters locally without re-fetch`() = runTest {
// Arrange
val screen = swapScreen()
coEvery { getMarketingBanner(screen, null) } returns listOf(gatedCampaign(1)).right()
val requests = MutableStateFlow<MarketingBannerRequest?>(
MarketingBannerRequest(screen, amountUsd = java.math.BigDecimal(10)), // below min -> hidden
)
val model = createModel(MarketingBannerComponent.Params.Standalone(requests))
// Act + Assert
advanceUntilIdle()
assertThat(model.uiState.value).isEqualTo(MarketingBannerListUM.Hidden) // 10 < 50
requests.value = MarketingBannerRequest(screen, amountUsd = java.math.BigDecimal(100)) // in range
advanceUntilIdle()
val content = model.uiState.value as MarketingBannerListUM.Content
assertThat(content.banners.map { it.campaignId }).containsExactly(1)
// fetched once for the pair, despite two different amounts
coVerify(exactly = 1) { getMarketingBanner(screen, null) }
}
@Test
fun `GIVEN standalone campaigns WHEN request emitted THEN only STANDALONE banners shown`() = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
campaign(1, MarketingBanner.UiType.STANDALONE),
campaign(2, MarketingBanner.UiType.LINKED_TO_PROVIDER),
).right()
val params = MarketingBannerComponent.Params.Standalone(
requestFlow = flowOf(MarketingBannerRequest(onrampScreen, amountUsd = null)),
)
val model = createModel(params)
// Act + Assert
model.uiState.test {
advanceUntilIdle()
val state = expectMostRecentItem()
assertThat(state).isInstanceOf(MarketingBannerListUM.Content::class.java)
val content = state as MarketingBannerListUM.Content
assertThat(content.banners.map { it.campaignId }).containsExactly(1)
}
}
@Test
fun `GIVEN empty result WHEN request emitted THEN Hidden`() = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns emptyList<MarketingCampaign>().right()
val model = createModel(
MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))),
)
// Act + Assert
model.uiState.test {
advanceUntilIdle()
assertThat(expectMostRecentItem()).isEqualTo(MarketingBannerListUM.Hidden)
}
}
@Test
fun `GIVEN use case fails WHEN request emitted THEN Hidden`() = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns RuntimeException("boom").left()
val model = createModel(
MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))),
)
// Act + Assert
model.uiState.test {
advanceUntilIdle()
assertThat(expectMostRecentItem()).isEqualTo(MarketingBannerListUM.Hidden)
}
}
@Test
fun `GIVEN linked campaigns WHEN provider matches THEN only matching LINKED banner shown`() = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")),
campaign(2, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("moonpay")),
campaign(3, MarketingBanner.UiType.STANDALONE),
).right()
val model = createModel(
MarketingBannerComponent.Params.LinkedToProvider(
flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null, currentProviderId = "mercuryo")),
),
)
// Act + Assert
model.uiState.test {
advanceUntilIdle()
val content = expectMostRecentItem() as MarketingBannerListUM.Content
assertThat(content.banners.map { it.campaignId }).containsExactly(1)
}
}
@Test
fun `GIVEN shown banner WHEN dismissed THEN removed from state and use case called`() = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
campaign(1, MarketingBanner.UiType.STANDALONE),
).right()
coEvery { dismissMarketingBanner(1) } returns Unit.right()
val model = createModel(
MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))),
)
// Act
advanceUntilIdle()
model.onDismiss(campaignId = 1)
advanceUntilIdle()
// Assert
assertThat(model.uiState.value).isEqualTo(MarketingBannerListUM.Hidden)
coVerify(exactly = 1) { dismissMarketingBanner(1) }
}
@Test
fun `GIVEN non-blank deeplink WHEN clicked THEN launcher called`() = runTest {
// Arrange
val model = createModel(
MarketingBannerComponent.Params.Standalone(MutableStateFlow(null)),
)
// Act
model.onBannerClick("tangem://promo/1")
// Assert
verify(exactly = 1) { deeplinkLauncher.launch("tangem://promo/1") }
}
@Test
fun `GIVEN blank deeplink WHEN clicked THEN launcher not called`() = runTest {
val model = createModel(MarketingBannerComponent.Params.Standalone(MutableStateFlow(null)))
model.onBannerClick(null)
model.onBannerClick("")
verify(exactly = 0) { deeplinkLauncher.launch(any()) }
}
@Test
fun `GIVEN host handles deeplink WHEN clicked THEN launcher not called`() = runTest {
// Arrange
val model = createModel(
MarketingBannerComponent.Params.Standalone(
requestFlow = MutableStateFlow(null),
onDeeplinkClick = { true },
),
)
// Act
model.onBannerClick("tangem://swap")
// Assert
verify(exactly = 0) { deeplinkLauncher.launch(any()) }
}
@Test
fun `GIVEN host does not handle deeplink WHEN clicked THEN launcher called`() = runTest {
// Arrange
val model = createModel(
MarketingBannerComponent.Params.Standalone(
requestFlow = MutableStateFlow(null),
onDeeplinkClick = { false },
),
)
// Act
model.onBannerClick("https://tangem.com/promo")
// Assert
verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") }
}
}

View file

@ -376,6 +376,9 @@ include(":features:feed:impl")
include(":features:promo-banners:api")
include(":features:promo-banners:impl")
include(":features:marketing:api")
include(":features:marketing:impl")
include(":features:payment:api")
include(":features:payment:impl")