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 */
|
/** Features */
|
||||||
implementation(projects.features.addressBook.api)
|
implementation(projects.features.addressBook.api)
|
||||||
implementation(projects.features.addressBook.impl)
|
implementation(projects.features.addressBook.impl)
|
||||||
|
implementation(projects.features.marketing.api)
|
||||||
|
implementation(projects.features.marketing.impl)
|
||||||
implementation(projects.features.rating.impl)
|
implementation(projects.features.rating.impl)
|
||||||
implementation(projects.features.referral.impl)
|
implementation(projects.features.referral.impl)
|
||||||
implementation(projects.features.referral.domain)
|
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:api")
|
||||||
include(":features:promo-banners:impl")
|
include(":features:promo-banners:impl")
|
||||||
|
|
||||||
|
include(":features:marketing:api")
|
||||||
|
include(":features:marketing:impl")
|
||||||
|
|
||||||
include(":features:payment:api")
|
include(":features:payment:api")
|
||||||
include(":features:payment:impl")
|
include(":features:payment:impl")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue