Updated on 2026-08-14
This commit is contained in:
parent
28f550e633
commit
4754f0677d
28 changed files with 479 additions and 52 deletions
|
|
@ -3,14 +3,15 @@ package com.tangem.domain.marketing.models
|
|||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known;
|
||||
* otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated).
|
||||
* USD min/max eligibility gate (mirrors iOS `satisfiesAmount`). A campaign without min/max bounds is
|
||||
* always eligible. A bounded campaign requires a known [amountUsd] — while the amount is unknown the
|
||||
* campaign is NOT eligible (hidden until a quote/amount arrives), then it must fall within the bounds.
|
||||
*/
|
||||
fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean {
|
||||
val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP
|
||||
if (!isAmountScreen || amountUsd == null) return true
|
||||
if (minAmount == null && maxAmount == null) return true
|
||||
|
||||
if (minAmount != null && amountUsd < minAmount) return false
|
||||
if (maxAmount != null && amountUsd > maxAmount) return false
|
||||
val usd = amountUsd ?: return false
|
||||
if (minAmount != null && usd < minAmount) return false
|
||||
if (maxAmount != null && usd > maxAmount) return false
|
||||
return true
|
||||
}
|
||||
|
|
@ -21,16 +21,24 @@ internal class MarketingCampaignAmountTest {
|
|||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() {
|
||||
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
|
||||
fun `GIVEN no min max bounds WHEN matchesUsdAmount THEN always true`() {
|
||||
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = null, maxAmount = null)
|
||||
assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue()
|
||||
assertThat(c.matchesUsdAmount(null)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap with null amount WHEN matchesUsdAmount THEN true`() {
|
||||
fun `GIVEN bounded campaign of any type WHEN amount out of range THEN false`() {
|
||||
// Bounds apply regardless of screen type (iOS parity): type no longer exempts a bounded campaign.
|
||||
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
|
||||
assertThat(c.matchesUsdAmount(BigDecimal(10))).isFalse()
|
||||
assertThat(c.matchesUsdAmount(BigDecimal(100))).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN bounded campaign with null amount WHEN matchesUsdAmount THEN false`() {
|
||||
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50))
|
||||
assertThat(c.matchesUsdAmount(null)).isTrue()
|
||||
assertThat(c.matchesUsdAmount(null)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ class GetMarketingBannerUseCase(
|
|||
campaigns.asSequence()
|
||||
.filterNot { it.id in dismissed }
|
||||
.filter { matchesTarget(it, screen) }
|
||||
.filter { it.matchesUsdAmount(amountUsd) }
|
||||
// Amount gating runs reactively in the consumer (with the live amount). Skip it here when
|
||||
// no amount is provided, so bounded swap/onramp campaigns aren't dropped on the pre-fetch.
|
||||
.filter { amountUsd == null || it.matchesUsdAmount(amountUsd) }
|
||||
.sortedBy { it.priority }
|
||||
.toList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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%"),
|
||||
|
|
|
|||
|
|
@ -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%"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ internal fun BaseExtension.configureCompose(project: Project) {
|
|||
contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function
|
||||
contains(Regex(pattern = ":features:txhistory:api\$")) || // provides Composable function
|
||||
contains(Regex(pattern = ":features:promo-banners:api\$")) || // provides Composable function
|
||||
contains(Regex(pattern = ":features:marketing:api\$")) || // provides Composable function
|
||||
contains(Regex(pattern = ":impl\$"))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue