From 85158f31dc51cfa8286603077510129b996e145a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 13:25:18 +0200 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + features/marketing/api/build.gradle.kts | 17 ++ .../marketing/api/MarketingBannerComponent.kt | 28 ++ .../marketing/api/MarketingBannerRequest.kt | 17 ++ features/marketing/impl/build.gradle.kts | 50 ++++ .../impl/DefaultMarketingBannerComponent.kt | 41 +++ .../impl/di/MarketingComponentModule.kt | 35 +++ .../impl/model/MarketingBannerModel.kt | 149 ++++++++++ .../marketing/impl/ui/MarketingBanner.kt | 145 +++++++++ .../impl/ui/MarketingBannerCarousel.kt | 45 +++ .../impl/ui/MarketingBannerContent.kt | 36 +++ .../impl/ui/state/MarketingBannerListUM.kt | 12 + .../impl/ui/state/MarketingBannerUM.kt | 15 + .../impl/model/MarketingBannerModelTest.kt | 279 ++++++++++++++++++ settings.gradle.kts | 3 + 15 files changed, 874 insertions(+) create mode 100644 features/marketing/api/build.gradle.kts create mode 100644 features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt create mode 100644 features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt create mode 100644 features/marketing/impl/build.gradle.kts create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt create mode 100644 features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06af283694..6e701b940a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -240,6 +240,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) diff --git a/features/marketing/api/build.gradle.kts b/features/marketing/api/build.gradle.kts new file mode 100644 index 0000000000..3cfd5dcf11 --- /dev/null +++ b/features/marketing/api/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt new file mode 100644 index 0000000000..9cd5052a66 --- /dev/null +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt @@ -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, + 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) : Params + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt new file mode 100644 index 0000000000..3c64911cd0 --- /dev/null +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt @@ -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, +) \ No newline at end of file diff --git a/features/marketing/impl/build.gradle.kts b/features/marketing/impl/build.gradle.kts new file mode 100644 index 0000000000..242266738e --- /dev/null +++ b/features/marketing/impl/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt new file mode 100644 index 0000000000..3de80d23c6 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt new file mode 100644 index 0000000000..b1caeffec9 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt new file mode 100644 index 0000000000..6844151314 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt @@ -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() + private val dismissedIds = MutableStateFlow>(emptySet()) + + val uiState: StateFlow + field = MutableStateFlow(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 = 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> = requestFlow + .map { it?.screen } + .distinctUntilChanged() + .debounce(REQUEST_DEBOUNCE_MS) + .mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() } + + val amountUsd: Flow = requestFlow.map { it?.amountUsd }.distinctUntilChanged() + val providerId: Flow = 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 = + 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 + } +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt new file mode 100644 index 0000000000..c24afa21d0 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt @@ -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 \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt new file mode 100644 index 0000000000..c2ea8ef617 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt @@ -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, + 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) + } +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt new file mode 100644 index 0000000000..6982742d79 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt @@ -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, + ) + } + } + } +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt new file mode 100644 index 0000000000..02cda2679a --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt @@ -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) : MarketingBannerListUM +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt new file mode 100644 index 0000000000..c4bb2acc88 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt @@ -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 } +} \ No newline at end of file diff --git a/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt new file mode 100644 index 0000000000..38145f1145 --- /dev/null +++ b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt @@ -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? = 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(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().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") } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 11a94fe444..46d255012d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -339,6 +339,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")