Updated on 2026-08-14
This commit is contained in:
parent
82038ab514
commit
85158f31dc
15 changed files with 874 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
17
features/marketing/api/build.gradle.kts
Normal file
17
features/marketing/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
50
features/marketing/impl/build.gradle.kts
Normal file
50
features/marketing/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue