Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 15:26:55 +02:00
parent 28f550e633
commit 4754f0677d
28 changed files with 479 additions and 52 deletions

View file

@ -14,4 +14,9 @@ dependencies {
implementation(projects.domain.marketing.models)
implementation(deps.kotlin.coroutines)
// The interface exposes a @Composable LinkedContent function, so the module needs the Compose compiler
// (enabled via the module allowlist in the configuration convention plugin) and these APIs.
api(deps.compose.runtime)
api(deps.compose.ui)
}

View file

@ -1,11 +1,29 @@
package com.tangem.features.marketing.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import kotlinx.coroutines.flow.Flow
interface MarketingBannerComponent : ComposableContentComponent {
/**
* Renders the LINKED_TO_PROVIDER banner for the offer identified by [providerId] (the row this sits
* next to). Shows nothing when no linked campaign targets that provider. No-op for standalone banners.
*/
@Composable
fun LinkedContent(providerId: String, modifier: Modifier) {
// Default no-op: only the LINKED_TO_PROVIDER implementation renders a banner.
}
/**
* Whether a LINKED_TO_PROVIDER banner is available for [providerId]. The host uses this to glue the
* banner to the offer (e.g. squaring the offer's bottom corners). Always `false` for standalone banners.
*/
@Composable
fun hasLinkedBanner(providerId: String): Boolean = false
sealed interface Params {
/**
@ -20,8 +38,8 @@ interface MarketingBannerComponent : ComposableContentComponent {
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
/** LINKED single banner rendered inline next to a host item (currently an onramp provider offer). */
data class Linked(val requestFlow: Flow<LinkedBannerRequest?>) : Params
}
interface Factory : ComponentFactory<Params, MarketingBannerComponent>

View file

@ -9,9 +9,11 @@ data class MarketingBannerRequest(
val amountUsd: BigDecimal? = null,
)
/** Context for a LINKED_TO_PROVIDER banner request (onramp only), matched against the shown provider. */
/**
* Context for LINKED_TO_PROVIDER banner requests (onramp only). Provider matching happens per offer at
* render time via [MarketingBannerComponent.LinkedContent], so the request carries no provider id.
*/
data class LinkedBannerRequest(
val screen: MarketingScreen.Onramp,
val amountUsd: BigDecimal?,
val currentProviderId: String,
)

View file

@ -8,7 +8,9 @@ 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.LinkedMarketingBanner
import com.tangem.features.marketing.impl.ui.MarketingBannerContent
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -31,6 +33,26 @@ internal class DefaultMarketingBannerComponent @AssistedInject constructor(
)
}
@Composable
override fun LinkedContent(providerId: String, modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val banner = (state as? MarketingBannerListUM.Content)
?.banners
?.firstOrNull { providerId in it.providerIds }
?: return
LinkedMarketingBanner(
banner = banner,
onClick = { model.onBannerClick(banner.deeplink) },
modifier = modifier,
)
}
@Composable
override fun hasLinkedBanner(providerId: String): Boolean {
val state by model.uiState.collectAsStateWithLifecycle()
return (state as? MarketingBannerListUM.Content)?.banners?.any { providerId in it.providerIds } == true
}
@AssistedFactory
interface Factory : MarketingBannerComponent.Factory {
override fun create(

View file

@ -68,17 +68,11 @@ internal class MarketingBannerModel @Inject constructor(
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) }
request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) }
}
is MarketingBannerComponent.Params.LinkedToProvider ->
is MarketingBannerComponent.Params.Linked ->
p.requestFlow.map { request ->
request?.let { linked ->
MarketingRequest(
screen = linked.screen,
amountUsd = linked.amountUsd,
providerId = linked.currentProviderId,
)
}
request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) }
}
}
@ -89,19 +83,17 @@ internal class MarketingBannerModel @Inject constructor(
.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 ->
flow3 = dismissedIds,
) { list, usd, dismissed ->
list.asSequence()
.filterNot { it.id in dismissed }
.filter { it.matchesUsdAmount(usd) }
.filter { matchesUiTypeAndProvider(it, provider) }
.filter { matchesUiType(it) }
.map { it.toUM() }
.toList()
}.collect { banners ->
@ -117,18 +109,16 @@ internal class MarketingBannerModel @Inject constructor(
private suspend fun fetch(screen: MarketingScreen): List<MarketingCampaign> =
getMarketingBanner(screen, amountUsd = null).getOrElse { emptyList() }
private fun matchesUiTypeAndProvider(campaign: MarketingCampaign, providerId: String?): Boolean = when (params) {
private fun matchesUiType(campaign: MarketingCampaign): 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
is MarketingBannerComponent.Params.Linked ->
campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER
}
private data class MarketingRequest(
val screen: MarketingScreen,
val amountUsd: BigDecimal?,
val providerId: String?,
)
private fun MarketingCampaign.toUM() = MarketingBannerUM(
@ -141,6 +131,7 @@ internal class MarketingBannerModel @Inject constructor(
},
isDismissible = banner.isDismissible,
deeplink = banner.deeplink,
providerIds = providerIds?.toSet().orEmpty(),
)
private companion object {

View file

@ -0,0 +1,105 @@
package com.tangem.features.marketing.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.graphics.ColorFilter
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.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
private val BOTTOM_CORNER_RADIUS = 20.dp
private val ICON_SIZE = 16.dp
// DS3 has no dedicated "blue 10%" background token; derive it from the accent blue to match Figma
// (rgba(0,153,255,0.1)).
private const val BACKGROUND_ALPHA = 0.1f
/**
* LINKED_TO_PROVIDER marketing banner a compact accent strip glued to the bottom of an onramp provider
* offer. Distinct from the standalone [MarketingBanner]: blue accent background, bottom-only rounded
* corners, a 16dp icon and blue title, no dismiss button.
*
* [Figma](https://www.figma.com/design/GhMZiR8xGeGSmaLinuE5qq/Onramp?node-id=401-84213&m=dev)
*/
@Composable
internal fun LinkedMarketingBanner(banner: MarketingBannerUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
val hasDeeplink = !banner.deeplink.isNullOrBlank()
// Collapse the icon slot 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
Row(
modifier = modifier
.clip(RoundedCornerShape(bottomStart = BOTTOM_CORNER_RADIUS, bottomEnd = BOTTOM_CORNER_RADIUS))
.then(if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier)
.background(TangemTheme.colors3.bg.accent.blue.copy(alpha = BACKGROUND_ALPHA))
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasIcon) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(banner.iconUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(TangemTheme.colors3.icon.accent.blue),
onError = { isIconFailed = true },
modifier = Modifier.size(ICON_SIZE),
)
}
Text(
text = banner.text.orEmpty(),
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors3.text.accent.blue,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_LinkedMarketingBanner() {
TangemThemePreviewRedesign {
LinkedMarketingBanner(
banner = MarketingBannerUM(
campaignId = 1,
text = "1:1 onramp at 0 fees!",
iconUrl = null,
iconAlign = MarketingBannerUM.IconAlign.LEFT,
isDismissible = false,
deeplink = "tangem://buy",
providerIds = setOf("mercuryo"),
),
onClick = {},
modifier = Modifier.padding(16.dp),
)
}
}
// endregion

View file

@ -10,6 +10,7 @@ internal data class MarketingBannerUM(
val iconAlign: IconAlign,
val isDismissible: Boolean,
val deeplink: String?,
val providerIds: Set<String> = emptySet(),
) {
enum class IconAlign { LEFT, RIGHT }
}

View file

@ -177,7 +177,7 @@ internal class MarketingBannerModelTest {
}
@Test
fun `GIVEN linked campaigns WHEN provider matches THEN only matching LINKED banner shown`() = runTest {
fun `GIVEN linked campaigns WHEN request emitted THEN all LINKED banners shown with providerIds`() = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")),
@ -185,16 +185,20 @@ internal class MarketingBannerModelTest {
campaign(3, MarketingBanner.UiType.STANDALONE),
).right()
val model = createModel(
MarketingBannerComponent.Params.LinkedToProvider(
flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null, currentProviderId = "mercuryo")),
MarketingBannerComponent.Params.Linked(
flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null)),
),
)
// Act + Assert
// Model no longer filters by provider: it emits all LINKED banners (not STANDALONE), carrying their
// providerIds; per-offer provider matching happens at render time in LinkedContent(providerId).
model.uiState.test {
advanceUntilIdle()
val content = expectMostRecentItem() as MarketingBannerListUM.Content
assertThat(content.banners.map { it.campaignId }).containsExactly(1)
assertThat(content.banners.map { it.campaignId }).containsExactly(1, 2)
assertThat(content.banners.first { it.campaignId == 1 }.providerIds).containsExactly("mercuryo")
assertThat(content.banners.first { it.campaignId == 2 }.providerIds).containsExactly("moonpay")
}
}

View file

@ -16,6 +16,9 @@ dependencies {
/** Project - API */
api(projects.features.commonFeatures.api)
api(projects.features.onramp.api)
implementation(projects.features.marketing.api)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
/** Project - Core */
api(projects.core.analytics)

View file

@ -154,6 +154,7 @@ internal class AllOffersStateFactory(
category = OnrampOfferCategoryUM.Recommended,
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = quote.paymentMethod,
providerId = quote.provider.id,
providerName = quote.provider.info.name,
rate = formatCryptoAmount(quote.toAmount),
diff = formatRateDiff(offer.rateDif),
@ -185,6 +186,7 @@ internal class AllOffersStateFactory(
category = OnrampOfferCategoryUM.Recommended,
advantages = advantages,
paymentMethod = quote.paymentMethod,
providerId = quote.provider.id,
providerName = quote.provider.info.name,
rate = formatRequiredAmount(quote, currencyCode),
diff = formatRateDiff(offer.rateDif),

View file

@ -205,6 +205,7 @@ private fun AllOffersContentSheetPaymentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -219,6 +220,7 @@ private fun AllOffersContentSheetPaymentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -270,6 +272,7 @@ private fun AllOffersContentSheetOffersPreview() {
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -285,6 +288,7 @@ private fun AllOffersContentSheetOffersPreview() {
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),

View file

@ -273,6 +273,7 @@ private fun PaymentMethodsContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -287,6 +288,7 @@ private fun PaymentMethodsContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),

View file

@ -10,9 +10,11 @@ import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
@ -29,10 +31,24 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory,
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params)
private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create(
context = child("marketing_banner"),
params = MarketingBannerComponent.Params.Standalone(
requestFlow = model.marketingRequest,
onDeeplinkClick = model::onMarketingBannerDeeplink,
),
)
private val linkedMarketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create(
context = child("marketing_banner_linked"),
params = MarketingBannerComponent.Params.Linked(requestFlow = model.linkedMarketingRequest),
)
init {
lifecycle.subscribe(onStop = model::onStop)
}
@ -49,7 +65,12 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampMainScreen(modifier = modifier, state = state)
OnrampMainScreen(
modifier = modifier,
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -24,6 +24,7 @@ internal data class OnrampOfferUM(
val category: OnrampOfferCategoryUM,
val advantages: OnrampOfferAdvantagesUM,
val paymentMethod: OnrampPaymentMethod,
val providerId: String,
val providerName: String,
val rate: String,
val diff: TextReference?,

View file

@ -44,6 +44,7 @@ internal class OnrampOffersStateFactory(
category = category,
advantages = advantages,
paymentMethod = currentQuote.paymentMethod,
providerId = currentQuote.provider.id,
providerName = currentQuote.provider.info.name,
rate = currentQuote.toAmount.value.format {
crypto(

View file

@ -3,7 +3,10 @@ package com.tangem.features.onramp.main.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.deeplink.resolveMarketingDeeplink
import com.tangem.common.routing.deeplink.toContextualRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
@ -11,6 +14,8 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
@ -18,8 +23,11 @@ import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.marketing.api.LinkedBannerRequest
import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
@ -38,6 +46,7 @@ import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -56,6 +65,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents {
@ -85,6 +95,68 @@ internal class OnrampMainComponentModel @Inject constructor(
),
)
/**
* Expected received crypto amount, taken from the first [OnrampQuote.Data] quote's [OnrampQuote.Data.toAmount].
* Used to derive [amountUsd][MarketingBannerRequest.amountUsd] for the marketing banner request flows below,
* since the campaign min/max amount gating is expressed in USD while the user only enters a fiat amount here.
*
* Seeded with an initial `null` via [onStart] so the downstream [combine] can emit immediately on cold start
* (before any quote is available, e.g. when the user has not entered an amount yet). Without this seed the
* underlying quotes flow stays silent until a quote is stored, which would keep the banner requests from
* emitting at all.
*/
private val expectedCryptoAmount: Flow<BigDecimal?> = getOnrampQuotesUseCase.invoke()
.map { either ->
either.getOrNull()
?.filterIsInstance<OnrampQuote.Data>()
?.firstOrNull()
?.toAmount?.value
}
.distinctUntilChanged()
.onStart { emit(null) }
/**
* Request flow for the standalone marketing banner.
* [fromFiat] is the fiat currency code the user is paying in (only available once the screen is in Content state).
* [toNetwork] is the backend network id of the target crypto currency.
* [toContractAddress] is the contract address of the target token (empty string for coins).
* [amountUsd] is derived from the expected received crypto amount (see [expectedCryptoAmount]) converted to USD.
* On cold start (no quote yet, e.g. the user has not entered an amount) it is null, so the request emits
* immediately with `amountUsd = null` and the domain shows the banner ungated; once a quote arrives the request
* re-emits with the real USD amount so the min/max filter applies. It is also null if the target currency has no
* USD rate, again skipping the amount filter.
*/
val marketingRequest: Flow<MarketingBannerRequest?> = combine(state, expectedCryptoAmount) { s, crypto ->
val contentState = s as? OnrampMainComponentUM.Content ?: return@combine null
MarketingBannerRequest(
screen = MarketingScreen.Onramp(
fromFiat = contentState.amountBlockState.currencyUM.code,
toNetwork = params.cryptoCurrency.network.rawId,
toContractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
),
amountUsd = computeAmountUsd(crypto),
)
}
/**
* Request flow for the LINKED_TO_PROVIDER marketing banner shown inline next to onramp provider offers.
* Carries no provider id: provider matching is done per offer at render time (each offer row asks for its
* banner via [MarketingBannerComponent.LinkedContent]), mirroring iOS.
* [amountUsd] follows the same rules as in [marketingRequest]: null on cold start (banner shown ungated) or when
* the target currency has no USD rate, and the real USD amount once a quote is available.
*/
val linkedMarketingRequest: Flow<LinkedBannerRequest?> = combine(state, expectedCryptoAmount) { s, crypto ->
val contentState = s as? OnrampMainComponentUM.Content ?: return@combine null
LinkedBannerRequest(
screen = MarketingScreen.Onramp(
fromFiat = contentState.amountBlockState.currencyUM.code,
toNetwork = params.cryptoCurrency.network.rawId,
toContractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
),
amountUsd = computeAmountUsd(crypto),
)
}
private val amountStateFactory: OnrampAmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountStateFactory(
currentStateProvider = Provider { state.value },
@ -125,6 +197,16 @@ internal class OnrampMainComponentModel @Inject constructor(
super.onDestroy()
}
fun onMarketingBannerDeeplink(deeplink: String): Boolean {
val route = resolveMarketingDeeplink(deeplink).toContextualRoute(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
screenSource = AnalyticsParam.ScreensSources.Buy,
) ?: return false
router.push(route)
return true
}
override fun onAmountValueChanged(value: String) {
state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
@ -412,6 +494,14 @@ internal class OnrampMainComponentModel @Inject constructor(
}
}
/** Converts the expected received [crypto] amount into its USD value using the target currency's USD rate. */
private suspend fun computeAmountUsd(crypto: BigDecimal?): BigDecimal? {
val amount = crypto ?: return null
val rawCurrencyId = params.cryptoCurrency.id.rawCurrencyId ?: return null
val rate = getCurrencyUSDQuoteUseCase(rawCurrencyId) ?: return null
return amount * rate
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {

View file

@ -17,11 +17,18 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
@Composable
internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
internal fun OnrampMainScreen(
state: OnrampMainComponentUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier.systemBarsPadding(),
topBar = {
@ -36,13 +43,20 @@ internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier =
) { scaffoldPaddings ->
OnrampMainComponentContent(
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
internal fun OnrampMainComponentContent(
state: OnrampMainComponentUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
@ -56,7 +70,11 @@ internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier:
) {
when (state) {
is OnrampMainComponentUM.InitialLoading -> InitialLoading(state = state)
is OnrampMainComponentUM.Content -> Content(state = state)
is OnrampMainComponentUM.Content -> Content(
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
}
}
@ -118,7 +136,12 @@ private fun OnrampAmountContentLoading() {
}
@Composable
private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
private fun Content(
state: OnrampMainComponentUM.Content,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
@ -133,7 +156,14 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M
) {
OnrampAmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState)
TangemThemeRedesign {
marketingBannerComponent.Content(Modifier.fillMaxWidth())
}
OnrampOffersContent(
state = state.offersBlockState,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
OnrampNotifications(state = state)
}

View file

@ -29,15 +29,17 @@ import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.test.OnrampOffersBlockTestTags
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.*
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
when (state) {
is OnrampOffersBlockUM.Content -> {
Column(modifier = Modifier.fillMaxWidth()) {
@ -52,7 +54,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
SpacerH(8.dp)
Offer(recentOffer)
OfferWithLinkedBanner(recentOffer, linkedMarketingBannerComponent)
SpacerH(16.dp)
}
@ -74,7 +76,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
state.recommended.fastForEach { offer ->
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
Offer(offer)
OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
SpacerH(8.dp)
}
}
@ -102,13 +104,34 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
}
@Composable
internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) {
private fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
val hasBanner = linkedMarketingBannerComponent.hasLinkedBanner(offer.providerId)
// Square the offer's bottom corners so the bottom-rounded banner glues to it as one card.
Offer(offer, roundBottom = !hasBanner)
if (hasBanner) {
TangemThemeRedesign {
linkedMarketingBannerComponent.LinkedContent(
providerId = offer.providerId,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier, roundBottom: Boolean = true) {
// Square the bottom corners when a linked marketing banner is glued below, so they read as one card.
val shape = if (roundBottom) {
RoundedCornerShape(14.dp)
} else {
RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
}
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(14.dp),
shape = shape,
)
.padding(12.dp),
) {
@ -386,6 +409,7 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00045334 BTC",
diff = stringReference("27%"),
@ -401,6 +425,7 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,0245334 BTC",
diff = null,
@ -415,6 +440,7 @@ private fun OnrampOffersContentPreview() {
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
type = PaymentMethodType.CARD,
),
providerId = "simplex",
providerName = "Simplex",
rate = "0,00145334 BTC",
diff = stringReference("0.07%"),
@ -427,7 +453,10 @@ private fun OnrampOffersContentPreview() {
),
)
TangemThemePreview {
OnrampOffersContent(state)
OnrampOffersContent(state = state, linkedMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
})
}
}
@ -436,6 +465,12 @@ private fun OnrampOffersContentPreview() {
@Composable
private fun OnrampOffersLoadingPreview() {
TangemThemePreview {
OnrampOffersContent(OnrampOffersBlockUM.Loading)
OnrampOffersContent(
state = OnrampOffersBlockUM.Loading,
linkedMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
},
)
}
}

View file

@ -17,6 +17,7 @@ dependencies {
api(projects.features.send.api)
api(projects.features.swap.api)
api(projects.features.swap.domain)
implementation(projects.features.marketing.api)
implementation(projects.features.send.impl)
/** Core modules */
@ -59,7 +60,9 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.core)
implementation(projects.domain.feedback.models)
implementation(projects.domain.marketing.models)
implementation(projects.domain.markets.models)
implementation(projects.domain.quotes)
implementation(projects.domain.stories.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.visa.models)

View file

@ -17,6 +17,7 @@ import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
@ -31,6 +32,7 @@ import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent
import com.tangem.utils.isNullOrZero
@ -46,6 +48,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SwapRoute>()
@ -56,6 +59,14 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val model: SwapModel = getOrCreateModel(params, router = innerRouter)
private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create(
context = child("marketing_banner"),
params = MarketingBannerComponent.Params.Standalone(
requestFlow = model.marketingRequest,
onDeeplinkClick = model::onMarketingBannerDeeplink,
),
)
private val childStack = childStack(
key = STACK_KEY,
source = stackNavigation,
@ -215,6 +226,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
marketingBannerComponent = marketingBannerComponent,
)
}
}
@ -236,6 +248,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
marketingBannerComponent = marketingBannerComponent,
)
}
}

View file

@ -16,6 +16,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.resolveMarketingDeeplink
import com.tangem.common.routing.deeplink.toContextualRoute
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -55,6 +57,7 @@ import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.derivationIndex
@ -65,6 +68,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
@ -110,6 +114,7 @@ import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
@ -175,6 +180,7 @@ internal class SwapModel @Inject constructor(
private val setSwapUiModeUseCase: SetSwapUiModeUseCase,
private val calculateAmountUseCase: CalculateAmountUseCase,
private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase,
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -225,6 +231,30 @@ internal class SwapModel @Inject constructor(
dataStateStateFlow.value = value
}
/**
* Request flow for the STANDALONE marketing banner shown on the swap screen.
* Derives [MarketingScreen.Swap] from the live [dataStateStateFlow]; emits null until both the FROM and TO
* currencies are chosen. [amountUsd] is the entered FROM amount converted via the FROM token's USD rate
* ([getCurrencyUSDQuoteUseCase]); it stays null until both the amount and the USD quote are available.
*/
val marketingRequest: Flow<MarketingBannerRequest?> = dataStateStateFlow.map { data ->
val fromCurrency = data.fromSwapCurrencyStatus?.currency ?: return@map null
val toCurrency = data.toSwapCurrencyStatus?.currency ?: return@map null
val amountUsd = data.amount?.toBigDecimalOrNull()?.let { fromAmount ->
val rawCurrencyId = fromCurrency.id.rawCurrencyId ?: return@let null
getCurrencyUSDQuoteUseCase(rawCurrencyId)?.let { usdRate -> fromAmount * usdRate }
}
MarketingBannerRequest(
screen = MarketingScreen.Swap(
fromNetwork = fromCurrency.network.rawId,
fromContractAddress = (fromCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
toNetwork = toCurrency.network.rawId,
toContractAddress = (toCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
),
amountUsd = amountUsd,
)
}
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState())
internal set
@ -354,6 +384,17 @@ internal class SwapModel @Inject constructor(
}
}
fun onMarketingBannerDeeplink(deeplink: String): Boolean {
val fromCurrency = dataState.fromSwapCurrencyStatus?.currency ?: return false
val route = resolveMarketingDeeplink(deeplink).toContextualRoute(
userWalletId = params.userWalletId,
currency = fromCurrency,
screenSource = ScreensSources.Swap,
) ?: return false
appRouter.push(route)
return true
}
fun onStart() {
startLoadingQuotesFromLastState(true)
}

View file

@ -31,6 +31,7 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
@ -38,9 +39,14 @@ import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.presentation.R
import com.tangem.features.marketing.api.MarketingBannerComponent
@Composable
internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) {
internal fun SwapScreen(
stateHolder: SwapStateHolder,
feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?,
marketingBannerComponent: MarketingBannerComponent? = null,
) {
BackHandler(onBack = stateHolder.onBackClicked)
Scaffold(
@ -63,6 +69,15 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
} else {
null
},
marketingBanner = if (marketingBannerComponent != null) {
@Composable { modifier: Modifier ->
TangemThemeRedesign {
marketingBannerComponent.Content(modifier)
}
}
} else {
null
},
modifier = Modifier
.padding(scaffoldPaddings)
.testTag(SwapTokenScreenTestTags.CONTAINER),

View file

@ -63,6 +63,7 @@ internal fun SwapScreenContent(
state: SwapStateHolder,
modifier: Modifier = Modifier,
feeBlock: @Composable ((Modifier) -> Unit)? = null,
marketingBanner: @Composable ((Modifier) -> Unit)? = null,
) {
val keyboard by keyboardAsState()
@ -86,6 +87,8 @@ internal fun SwapScreenContent(
) {
MainInfo(state)
marketingBanner?.invoke(Modifier.fillMaxWidth())
if (state.swapUIMode == SwapUIMode.Simple) {
ProviderItemBlockSimple(state = state.providerState)
} else {

View file

@ -26,6 +26,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
@ -105,6 +106,7 @@ internal abstract class SwapModelTestBase {
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
protected val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase = mockk(relaxed = true)
protected val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase = mockk(relaxed = true)
protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true)
protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
@ -178,6 +180,7 @@ internal abstract class SwapModelTestBase {
setSwapUiModeUseCase = setSwapUiModeUseCase,
calculateAmountUseCase = calculateAmountUseCase,
isHighNetworkFeeUseCase = isHighNetworkFeeUseCase,
getCurrencyUSDQuoteUseCase = getCurrencyUSDQuoteUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
)