Updated on 2026-08-14
This commit is contained in:
parent
85158f31dc
commit
e99ffe39f0
28 changed files with 480 additions and 52 deletions
|
|
@ -3,14 +3,15 @@ package com.tangem.domain.marketing.models
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known;
|
* USD min/max eligibility gate (mirrors iOS `satisfiesAmount`). A campaign without min/max bounds is
|
||||||
* otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated).
|
* 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 {
|
fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean {
|
||||||
val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP
|
if (minAmount == null && maxAmount == null) return true
|
||||||
if (!isAmountScreen || amountUsd == null) return true
|
|
||||||
|
|
||||||
if (minAmount != null && amountUsd < minAmount) return false
|
val usd = amountUsd ?: return false
|
||||||
if (maxAmount != null && amountUsd > maxAmount) return false
|
if (minAmount != null && usd < minAmount) return false
|
||||||
|
if (maxAmount != null && usd > maxAmount) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -21,16 +21,24 @@ internal class MarketingCampaignAmountTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() {
|
fun `GIVEN no min max bounds WHEN matchesUsdAmount THEN always true`() {
|
||||||
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300))
|
val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = null, maxAmount = null)
|
||||||
assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue()
|
assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue()
|
||||||
assertThat(c.matchesUsdAmount(null)).isTrue()
|
assertThat(c.matchesUsdAmount(null)).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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))
|
val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50))
|
||||||
assertThat(c.matchesUsdAmount(null)).isTrue()
|
assertThat(c.matchesUsdAmount(null)).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ class GetMarketingBannerUseCase(
|
||||||
campaigns.asSequence()
|
campaigns.asSequence()
|
||||||
.filterNot { it.id in dismissed }
|
.filterNot { it.id in dismissed }
|
||||||
.filter { matchesTarget(it, screen) }
|
.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 }
|
.sortedBy { it.priority }
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,9 @@ dependencies {
|
||||||
implementation(projects.domain.marketing.models)
|
implementation(projects.domain.marketing.models)
|
||||||
|
|
||||||
implementation(deps.kotlin.coroutines)
|
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
|
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.decompose.factory.ComponentFactory
|
||||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface MarketingBannerComponent : ComposableContentComponent {
|
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 {
|
sealed interface Params {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,8 +38,8 @@ interface MarketingBannerComponent : ComposableContentComponent {
|
||||||
val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null,
|
val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null,
|
||||||
) : Params
|
) : Params
|
||||||
|
|
||||||
/** LINKED_TO_PROVIDER single banner rendered inline next to an onramp provider offer. */
|
/** LINKED single banner rendered inline next to a host item (currently an onramp provider offer). */
|
||||||
data class LinkedToProvider(val requestFlow: Flow<LinkedBannerRequest?>) : Params
|
data class Linked(val requestFlow: Flow<LinkedBannerRequest?>) : Params
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Factory : ComponentFactory<Params, MarketingBannerComponent>
|
interface Factory : ComponentFactory<Params, MarketingBannerComponent>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ data class MarketingBannerRequest(
|
||||||
val amountUsd: BigDecimal? = null,
|
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(
|
data class LinkedBannerRequest(
|
||||||
val screen: MarketingScreen.Onramp,
|
val screen: MarketingScreen.Onramp,
|
||||||
val amountUsd: BigDecimal?,
|
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.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.features.marketing.api.MarketingBannerComponent
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.marketing.impl.model.MarketingBannerModel
|
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.MarketingBannerContent
|
||||||
|
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
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
|
@AssistedFactory
|
||||||
interface Factory : MarketingBannerComponent.Factory {
|
interface Factory : MarketingBannerComponent.Factory {
|
||||||
override fun create(
|
override fun create(
|
||||||
|
|
|
||||||
|
|
@ -68,17 +68,11 @@ internal class MarketingBannerModel @Inject constructor(
|
||||||
val requestFlow: Flow<MarketingRequest?> = when (val p = params) {
|
val requestFlow: Flow<MarketingRequest?> = when (val p = params) {
|
||||||
is MarketingBannerComponent.Params.Standalone ->
|
is MarketingBannerComponent.Params.Standalone ->
|
||||||
p.requestFlow.map { request ->
|
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 ->
|
p.requestFlow.map { request ->
|
||||||
request?.let { linked ->
|
request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) }
|
||||||
MarketingRequest(
|
|
||||||
screen = linked.screen,
|
|
||||||
amountUsd = linked.amountUsd,
|
|
||||||
providerId = linked.currentProviderId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,19 +83,17 @@ internal class MarketingBannerModel @Inject constructor(
|
||||||
.mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() }
|
.mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() }
|
||||||
|
|
||||||
val amountUsd: Flow<BigDecimal?> = requestFlow.map { it?.amountUsd }.distinctUntilChanged()
|
val amountUsd: Flow<BigDecimal?> = requestFlow.map { it?.amountUsd }.distinctUntilChanged()
|
||||||
val providerId: Flow<String?> = requestFlow.map { it?.providerId }.distinctUntilChanged()
|
|
||||||
|
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
combine(
|
combine(
|
||||||
flow = campaigns,
|
flow = campaigns,
|
||||||
flow2 = amountUsd,
|
flow2 = amountUsd,
|
||||||
flow3 = providerId,
|
flow3 = dismissedIds,
|
||||||
flow4 = dismissedIds,
|
) { list, usd, dismissed ->
|
||||||
) { list, usd, provider, dismissed ->
|
|
||||||
list.asSequence()
|
list.asSequence()
|
||||||
.filterNot { it.id in dismissed }
|
.filterNot { it.id in dismissed }
|
||||||
.filter { it.matchesUsdAmount(usd) }
|
.filter { it.matchesUsdAmount(usd) }
|
||||||
.filter { matchesUiTypeAndProvider(it, provider) }
|
.filter { matchesUiType(it) }
|
||||||
.map { it.toUM() }
|
.map { it.toUM() }
|
||||||
.toList()
|
.toList()
|
||||||
}.collect { banners ->
|
}.collect { banners ->
|
||||||
|
|
@ -117,18 +109,16 @@ internal class MarketingBannerModel @Inject constructor(
|
||||||
private suspend fun fetch(screen: MarketingScreen): List<MarketingCampaign> =
|
private suspend fun fetch(screen: MarketingScreen): List<MarketingCampaign> =
|
||||||
getMarketingBanner(screen, amountUsd = null).getOrElse { emptyList() }
|
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 ->
|
is MarketingBannerComponent.Params.Standalone ->
|
||||||
campaign.banner.uiType == MarketingBanner.UiType.STANDALONE
|
campaign.banner.uiType == MarketingBanner.UiType.STANDALONE
|
||||||
is MarketingBannerComponent.Params.LinkedToProvider ->
|
is MarketingBannerComponent.Params.Linked ->
|
||||||
campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER &&
|
campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER
|
||||||
providerId != null && campaign.providerIds?.contains(providerId) == true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class MarketingRequest(
|
private data class MarketingRequest(
|
||||||
val screen: MarketingScreen,
|
val screen: MarketingScreen,
|
||||||
val amountUsd: BigDecimal?,
|
val amountUsd: BigDecimal?,
|
||||||
val providerId: String?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun MarketingCampaign.toUM() = MarketingBannerUM(
|
private fun MarketingCampaign.toUM() = MarketingBannerUM(
|
||||||
|
|
@ -141,6 +131,7 @@ internal class MarketingBannerModel @Inject constructor(
|
||||||
},
|
},
|
||||||
isDismissible = banner.isDismissible,
|
isDismissible = banner.isDismissible,
|
||||||
deeplink = banner.deeplink,
|
deeplink = banner.deeplink,
|
||||||
|
providerIds = providerIds?.toSet().orEmpty(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
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 iconAlign: IconAlign,
|
||||||
val isDismissible: Boolean,
|
val isDismissible: Boolean,
|
||||||
val deeplink: String?,
|
val deeplink: String?,
|
||||||
|
val providerIds: Set<String> = emptySet(),
|
||||||
) {
|
) {
|
||||||
enum class IconAlign { LEFT, RIGHT }
|
enum class IconAlign { LEFT, RIGHT }
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +177,7 @@ internal class MarketingBannerModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
// Arrange
|
||||||
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
|
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
|
||||||
campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")),
|
campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")),
|
||||||
|
|
@ -185,16 +185,20 @@ internal class MarketingBannerModelTest {
|
||||||
campaign(3, MarketingBanner.UiType.STANDALONE),
|
campaign(3, MarketingBanner.UiType.STANDALONE),
|
||||||
).right()
|
).right()
|
||||||
val model = createModel(
|
val model = createModel(
|
||||||
MarketingBannerComponent.Params.LinkedToProvider(
|
MarketingBannerComponent.Params.Linked(
|
||||||
flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null, currentProviderId = "mercuryo")),
|
flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act + Assert
|
// 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 {
|
model.uiState.test {
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
val content = expectMostRecentItem() as MarketingBannerListUM.Content
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ dependencies {
|
||||||
implementation(projects.features.swap.domain.api)
|
implementation(projects.features.swap.domain.api)
|
||||||
implementation(projects.features.swap.domain.models)
|
implementation(projects.features.swap.domain.models)
|
||||||
implementation(projects.features.feed.api)
|
implementation(projects.features.feed.api)
|
||||||
|
implementation(projects.features.marketing.api)
|
||||||
|
|
||||||
|
|
||||||
/** Project - Core */
|
/** Project - Core */
|
||||||
implementation(projects.core.analytics)
|
implementation(projects.core.analytics)
|
||||||
|
|
@ -57,6 +59,8 @@ dependencies {
|
||||||
implementation(projects.domain.appTheme.models)
|
implementation(projects.domain.appTheme.models)
|
||||||
implementation(projects.data.common)
|
implementation(projects.data.common)
|
||||||
implementation(projects.domain.markets)
|
implementation(projects.domain.markets)
|
||||||
|
implementation(projects.domain.marketing.models)
|
||||||
|
implementation(projects.domain.quotes)
|
||||||
|
|
||||||
/** DI */
|
/** DI */
|
||||||
implementation(deps.hilt.android)
|
implementation(deps.hilt.android)
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ internal class AllOffersStateFactory(
|
||||||
category = OnrampOfferCategoryUM.Recommended,
|
category = OnrampOfferCategoryUM.Recommended,
|
||||||
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
|
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
|
||||||
paymentMethod = quote.paymentMethod,
|
paymentMethod = quote.paymentMethod,
|
||||||
|
providerId = quote.provider.id,
|
||||||
providerName = quote.provider.info.name,
|
providerName = quote.provider.info.name,
|
||||||
rate = formatCryptoAmount(quote.toAmount),
|
rate = formatCryptoAmount(quote.toAmount),
|
||||||
diff = formatRateDiff(offer.rateDif),
|
diff = formatRateDiff(offer.rateDif),
|
||||||
|
|
@ -185,6 +186,7 @@ internal class AllOffersStateFactory(
|
||||||
category = OnrampOfferCategoryUM.Recommended,
|
category = OnrampOfferCategoryUM.Recommended,
|
||||||
advantages = advantages,
|
advantages = advantages,
|
||||||
paymentMethod = quote.paymentMethod,
|
paymentMethod = quote.paymentMethod,
|
||||||
|
providerId = quote.provider.id,
|
||||||
providerName = quote.provider.info.name,
|
providerName = quote.provider.info.name,
|
||||||
rate = formatRequiredAmount(quote, currencyCode),
|
rate = formatRequiredAmount(quote, currencyCode),
|
||||||
diff = formatRateDiff(offer.rateDif),
|
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",
|
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,0245334 BTC",
|
rate = "0,0245334 BTC",
|
||||||
diff = null,
|
diff = null,
|
||||||
|
|
@ -219,6 +220,7 @@ private fun AllOffersContentSheetPaymentPreview() {
|
||||||
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,00145334 BTC",
|
rate = "0,00145334 BTC",
|
||||||
diff = stringReference("–0.07%"),
|
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",
|
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,0245334 BTC",
|
rate = "0,0245334 BTC",
|
||||||
diff = null,
|
diff = null,
|
||||||
|
|
@ -285,6 +288,7 @@ private fun AllOffersContentSheetOffersPreview() {
|
||||||
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
"https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,00145334 BTC",
|
rate = "0,00145334 BTC",
|
||||||
diff = stringReference("–0.07%"),
|
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",
|
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,0245334 BTC",
|
rate = "0,0245334 BTC",
|
||||||
diff = null,
|
diff = null,
|
||||||
|
|
@ -287,6 +288,7 @@ private fun PaymentMethodsContentPreview() {
|
||||||
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,00145334 BTC",
|
rate = "0,00145334 BTC",
|
||||||
diff = stringReference("–0.07%"),
|
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.decompose.router.slot.dismiss
|
||||||
import com.arkivanov.essenty.lifecycle.subscribe
|
import com.arkivanov.essenty.lifecycle.subscribe
|
||||||
import com.tangem.core.decompose.context.AppComponentContext
|
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.context.childByContext
|
||||||
import com.tangem.core.decompose.model.getOrCreateModel
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
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.alloffers.AllOffersComponent
|
||||||
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
|
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
|
||||||
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
|
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
|
||||||
|
|
@ -29,10 +31,24 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
|
||||||
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
|
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
|
||||||
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
|
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
|
||||||
private val allOffersComponentFactory: AllOffersComponent.Factory,
|
private val allOffersComponentFactory: AllOffersComponent.Factory,
|
||||||
|
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
|
||||||
) : OnrampMainComponent, AppComponentContext by appComponentContext {
|
) : OnrampMainComponent, AppComponentContext by appComponentContext {
|
||||||
|
|
||||||
private val model: OnrampMainComponentModel = getOrCreateModel(params)
|
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 {
|
init {
|
||||||
lifecycle.subscribe(onStop = model::onStop)
|
lifecycle.subscribe(onStop = model::onStop)
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +65,12 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
|
||||||
val state by model.state.collectAsState()
|
val state by model.state.collectAsState()
|
||||||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||||
|
|
||||||
OnrampMainScreen(modifier = modifier, state = state)
|
OnrampMainScreen(
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
|
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||||
|
)
|
||||||
bottomSheet.child?.instance?.BottomSheet()
|
bottomSheet.child?.instance?.BottomSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ internal data class OnrampOfferUM(
|
||||||
val category: OnrampOfferCategoryUM,
|
val category: OnrampOfferCategoryUM,
|
||||||
val advantages: OnrampOfferAdvantagesUM,
|
val advantages: OnrampOfferAdvantagesUM,
|
||||||
val paymentMethod: OnrampPaymentMethod,
|
val paymentMethod: OnrampPaymentMethod,
|
||||||
|
val providerId: String,
|
||||||
val providerName: String,
|
val providerName: String,
|
||||||
val rate: String,
|
val rate: String,
|
||||||
val diff: TextReference?,
|
val diff: TextReference?,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ internal class OnrampOffersStateFactory(
|
||||||
category = category,
|
category = category,
|
||||||
advantages = advantages,
|
advantages = advantages,
|
||||||
paymentMethod = currentQuote.paymentMethod,
|
paymentMethod = currentQuote.paymentMethod,
|
||||||
|
providerId = currentQuote.provider.id,
|
||||||
providerName = currentQuote.provider.info.name,
|
providerName = currentQuote.provider.info.name,
|
||||||
rate = currentQuote.toAmount.value.format {
|
rate = currentQuote.toAmount.value.format {
|
||||||
crypto(
|
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.SlotNavigation
|
||||||
import com.arkivanov.decompose.router.slot.activate
|
import com.arkivanov.decompose.router.slot.activate
|
||||||
import com.arkivanov.decompose.router.slot.dismiss
|
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.api.AnalyticsEventHandler
|
||||||
|
import com.tangem.core.analytics.models.AnalyticsParam
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
import com.tangem.core.decompose.model.ParamsContainer
|
import com.tangem.core.decompose.model.ParamsContainer
|
||||||
import com.tangem.core.decompose.navigation.Router
|
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.core.ui.components.fields.InputManager
|
||||||
import com.tangem.domain.demo.IsDemoCardUseCase
|
import com.tangem.domain.demo.IsDemoCardUseCase
|
||||||
import com.tangem.domain.exchange.RampStateManager
|
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.*
|
||||||
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
|
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
|
||||||
import com.tangem.domain.onramp.model.OnrampAvailability
|
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.OnrampProviderWithQuote
|
||||||
import com.tangem.domain.onramp.model.OnrampQuote
|
import com.tangem.domain.onramp.model.OnrampQuote
|
||||||
import com.tangem.domain.onramp.model.error.OnrampError
|
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.tokens.model.ScenarioUnavailabilityReason
|
||||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
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.OnrampMainComponent
|
||||||
import com.tangem.features.onramp.main.entity.*
|
import com.tangem.features.onramp.main.entity.*
|
||||||
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
|
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 com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.math.BigDecimal
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Suppress("LongParameterList", "LargeClass")
|
@Suppress("LongParameterList", "LargeClass")
|
||||||
|
|
@ -56,6 +65,7 @@ internal class OnrampMainComponentModel @Inject constructor(
|
||||||
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
|
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
|
||||||
private val isDemoCardUseCase: IsDemoCardUseCase,
|
private val isDemoCardUseCase: IsDemoCardUseCase,
|
||||||
private val messageSender: UiMessageSender,
|
private val messageSender: UiMessageSender,
|
||||||
|
private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
|
||||||
paramsContainer: ParamsContainer,
|
paramsContainer: ParamsContainer,
|
||||||
getWalletsUseCase: GetWalletsUseCase,
|
getWalletsUseCase: GetWalletsUseCase,
|
||||||
) : Model(), OnrampIntents {
|
) : 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) {
|
private val amountStateFactory: OnrampAmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
OnrampAmountStateFactory(
|
OnrampAmountStateFactory(
|
||||||
currentStateProvider = Provider { state.value },
|
currentStateProvider = Provider { state.value },
|
||||||
|
|
@ -125,6 +197,16 @@ internal class OnrampMainComponentModel @Inject constructor(
|
||||||
super.onDestroy()
|
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) {
|
override fun onAmountValueChanged(value: String) {
|
||||||
state.update { amountStateFactory.getOnAmountValueChange(value) }
|
state.update { amountStateFactory.getOnAmountValueChange(value) }
|
||||||
modelScope.launch { amountInputManager.update(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>) {
|
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
|
||||||
quotes.forEach { errorState ->
|
quotes.forEach { errorState ->
|
||||||
when (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.ds.message.TangemMessage
|
||||||
import com.tangem.core.ui.extensions.resolveReference
|
import com.tangem.core.ui.extensions.resolveReference
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
import com.tangem.core.ui.res.TangemThemeRedesign
|
||||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
|
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
|
internal fun OnrampMainScreen(
|
||||||
|
state: OnrampMainComponentUM,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
|
linkedMarketingBannerComponent: MarketingBannerComponent,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.systemBarsPadding(),
|
modifier = modifier.systemBarsPadding(),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -36,13 +43,20 @@ internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier =
|
||||||
) { scaffoldPaddings ->
|
) { scaffoldPaddings ->
|
||||||
OnrampMainComponentContent(
|
OnrampMainComponentContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
|
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||||
modifier = Modifier.padding(scaffoldPaddings),
|
modifier = Modifier.padding(scaffoldPaddings),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
|
internal fun OnrampMainComponentContent(
|
||||||
|
state: OnrampMainComponentUM,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
|
linkedMarketingBannerComponent: MarketingBannerComponent,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -56,7 +70,11 @@ internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier:
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
is OnrampMainComponentUM.InitialLoading -> InitialLoading(state = 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
|
@Composable
|
||||||
private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
|
private fun Content(
|
||||||
|
state: OnrampMainComponentUM.Content,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
|
linkedMarketingBannerComponent: MarketingBannerComponent,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -133,7 +156,14 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M
|
||||||
) {
|
) {
|
||||||
OnrampAmountContent(state = state)
|
OnrampAmountContent(state = state)
|
||||||
|
|
||||||
OnrampOffersContent(state = state.offersBlockState)
|
TangemThemeRedesign {
|
||||||
|
marketingBannerComponent.Content(Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
OnrampOffersContent(
|
||||||
|
state = state.offersBlockState,
|
||||||
|
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||||
|
)
|
||||||
|
|
||||||
OnrampNotifications(state = state)
|
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.extensions.*
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreview
|
import com.tangem.core.ui.res.TangemThemePreview
|
||||||
|
import com.tangem.core.ui.res.TangemThemeRedesign
|
||||||
import com.tangem.core.ui.test.OnrampOffersBlockTestTags
|
import com.tangem.core.ui.test.OnrampOffersBlockTestTags
|
||||||
import com.tangem.domain.onramp.model.OnrampPaymentMethod
|
import com.tangem.domain.onramp.model.OnrampPaymentMethod
|
||||||
import com.tangem.domain.onramp.model.PaymentMethodType
|
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.impl.R
|
||||||
import com.tangem.features.onramp.main.entity.*
|
import com.tangem.features.onramp.main.entity.*
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
|
internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
|
||||||
when (state) {
|
when (state) {
|
||||||
is OnrampOffersBlockUM.Content -> {
|
is OnrampOffersBlockUM.Content -> {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|
@ -52,7 +54,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
|
||||||
|
|
||||||
SpacerH(8.dp)
|
SpacerH(8.dp)
|
||||||
|
|
||||||
Offer(recentOffer)
|
OfferWithLinkedBanner(recentOffer, linkedMarketingBannerComponent)
|
||||||
|
|
||||||
SpacerH(16.dp)
|
SpacerH(16.dp)
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +76,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
|
||||||
|
|
||||||
state.recommended.fastForEach { offer ->
|
state.recommended.fastForEach { offer ->
|
||||||
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
|
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
|
||||||
Offer(offer)
|
OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
|
||||||
SpacerH(8.dp)
|
SpacerH(8.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,13 +104,34 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(
|
.background(
|
||||||
color = TangemTheme.colors.background.action,
|
color = TangemTheme.colors.background.action,
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = shape,
|
||||||
)
|
)
|
||||||
.padding(12.dp),
|
.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",
|
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,00045334 BTC",
|
rate = "0,00045334 BTC",
|
||||||
diff = stringReference("–27%"),
|
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",
|
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,0245334 BTC",
|
rate = "0,0245334 BTC",
|
||||||
diff = null,
|
diff = null,
|
||||||
|
|
@ -415,6 +440,7 @@ private fun OnrampOffersContentPreview() {
|
||||||
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
|
||||||
type = PaymentMethodType.CARD,
|
type = PaymentMethodType.CARD,
|
||||||
),
|
),
|
||||||
|
providerId = "simplex",
|
||||||
providerName = "Simplex",
|
providerName = "Simplex",
|
||||||
rate = "0,00145334 BTC",
|
rate = "0,00145334 BTC",
|
||||||
diff = stringReference("–0.07%"),
|
diff = stringReference("–0.07%"),
|
||||||
|
|
@ -427,7 +453,10 @@ private fun OnrampOffersContentPreview() {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
TangemThemePreview {
|
TangemThemePreview {
|
||||||
OnrampOffersContent(state)
|
OnrampOffersContent(state = state, linkedMarketingBannerComponent = object : MarketingBannerComponent {
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) = Unit
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,6 +465,12 @@ private fun OnrampOffersContentPreview() {
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnrampOffersLoadingPreview() {
|
private fun OnrampOffersLoadingPreview() {
|
||||||
TangemThemePreview {
|
TangemThemePreview {
|
||||||
OnrampOffersContent(OnrampOffersBlockUM.Loading)
|
OnrampOffersContent(
|
||||||
|
state = OnrampOffersBlockUM.Loading,
|
||||||
|
linkedMarketingBannerComponent = object : MarketingBannerComponent {
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) = Unit
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +50,9 @@ dependencies {
|
||||||
implementation(projects.domain.feedback)
|
implementation(projects.domain.feedback)
|
||||||
implementation(projects.domain.feedback.models)
|
implementation(projects.domain.feedback.models)
|
||||||
implementation(projects.domain.stories)
|
implementation(projects.domain.stories)
|
||||||
|
implementation(projects.domain.marketing.models)
|
||||||
|
implementation(projects.domain.markets.models)
|
||||||
|
implementation(projects.domain.quotes)
|
||||||
implementation(projects.domain.stories.models)
|
implementation(projects.domain.stories.models)
|
||||||
implementation(projects.domain.txhistory)
|
implementation(projects.domain.txhistory)
|
||||||
implementation(projects.domain.txhistory.models)
|
implementation(projects.domain.txhistory.models)
|
||||||
|
|
@ -71,6 +74,7 @@ dependencies {
|
||||||
implementation(projects.features.send.api)
|
implementation(projects.features.send.api)
|
||||||
implementation(projects.features.send.impl)
|
implementation(projects.features.send.impl)
|
||||||
implementation(projects.features.feed.api)
|
implementation(projects.features.feed.api)
|
||||||
|
implementation(projects.features.marketing.api)
|
||||||
|
|
||||||
/** AndroidX */
|
/** AndroidX */
|
||||||
implementation(deps.androidx.activity.compose)
|
implementation(deps.androidx.activity.compose)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import com.arkivanov.decompose.router.stack.childStack
|
||||||
import com.arkivanov.decompose.router.stack.pop
|
import com.arkivanov.decompose.router.stack.pop
|
||||||
import com.arkivanov.essenty.lifecycle.subscribe
|
import com.arkivanov.essenty.lifecycle.subscribe
|
||||||
import com.tangem.core.decompose.context.AppComponentContext
|
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.context.childByContext
|
||||||
import com.tangem.core.decompose.model.getOrCreateModel
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.core.decompose.navigation.inner.InnerRouter
|
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.feature.swap.ui.SwapSuccessScreen
|
||||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
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.send.api.analytics.CommonSendAnalyticEvents
|
||||||
import com.tangem.features.swap.SwapComponent
|
import com.tangem.features.swap.SwapComponent
|
||||||
import com.tangem.utils.isNullOrZero
|
import com.tangem.utils.isNullOrZero
|
||||||
|
|
@ -46,6 +48,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
||||||
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
|
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
|
||||||
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
|
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
|
||||||
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||||
|
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
|
||||||
) : SwapComponent, AppComponentContext by appComponentContext {
|
) : SwapComponent, AppComponentContext by appComponentContext {
|
||||||
|
|
||||||
private val stackNavigation = StackNavigation<SwapRoute>()
|
private val stackNavigation = StackNavigation<SwapRoute>()
|
||||||
|
|
@ -56,6 +59,14 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
||||||
|
|
||||||
private val model: SwapModel = getOrCreateModel(params, router = innerRouter)
|
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(
|
private val childStack = childStack(
|
||||||
key = STACK_KEY,
|
key = STACK_KEY,
|
||||||
source = stackNavigation,
|
source = stackNavigation,
|
||||||
|
|
@ -215,6 +226,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
||||||
SwapScreen(
|
SwapScreen(
|
||||||
stateHolder = model.uiState,
|
stateHolder = model.uiState,
|
||||||
feeSelectorBlockComponent = feeSelectorBlockComponent,
|
feeSelectorBlockComponent = feeSelectorBlockComponent,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -236,6 +248,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
||||||
SwapScreen(
|
SwapScreen(
|
||||||
stateHolder = model.uiState,
|
stateHolder = model.uiState,
|
||||||
feeSelectorBlockComponent = feeSelectorBlockComponent,
|
feeSelectorBlockComponent = feeSelectorBlockComponent,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee
|
||||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||||
import com.tangem.common.routing.AppRoute
|
import com.tangem.common.routing.AppRoute
|
||||||
import com.tangem.common.routing.AppRouter
|
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.common.ui.bottomsheet.permission.state.ApproveType
|
||||||
import com.tangem.core.analytics.api.AnalyticsErrorHandler
|
import com.tangem.core.analytics.api.AnalyticsErrorHandler
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
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.SendFeedbackEmailUseCase
|
||||||
import com.tangem.domain.feedback.models.BlockchainErrorInfo
|
import com.tangem.domain.feedback.models.BlockchainErrorInfo
|
||||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
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.Account
|
||||||
import com.tangem.domain.models.account.AccountStatus
|
import com.tangem.domain.models.account.AccountStatus
|
||||||
import com.tangem.domain.models.account.derivationIndex
|
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.models.wallet.isHotWallet
|
||||||
import com.tangem.domain.pay.WithdrawalResult
|
import com.tangem.domain.pay.WithdrawalResult
|
||||||
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
|
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
|
||||||
|
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
|
||||||
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
||||||
import com.tangem.domain.settings.usercountry.models.UserCountry
|
import com.tangem.domain.settings.usercountry.models.UserCountry
|
||||||
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
|
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
|
||||||
|
|
@ -109,6 +113,7 @@ import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
|
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerRequest
|
||||||
import com.tangem.features.send.api.entity.FeeItem
|
import com.tangem.features.send.api.entity.FeeItem
|
||||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||||
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
||||||
|
|
@ -224,6 +229,30 @@ internal class SwapModel @Inject constructor(
|
||||||
dataStateStateFlow.value = value
|
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())
|
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState())
|
||||||
internal set
|
internal set
|
||||||
|
|
||||||
|
|
@ -353,6 +382,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() {
|
fun onStart() {
|
||||||
startLoadingQuotesFromLastState(true)
|
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.components.dropdownmenu.TangemDropdownMenu
|
||||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
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.test.SwapTokenScreenTestTags
|
||||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||||
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
|
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.SwapStateHolder
|
||||||
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
|
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
|
||||||
import com.tangem.feature.swap.presentation.R
|
import com.tangem.feature.swap.presentation.R
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) {
|
internal fun SwapScreen(
|
||||||
|
stateHolder: SwapStateHolder,
|
||||||
|
feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent? = null,
|
||||||
|
) {
|
||||||
BackHandler(onBack = stateHolder.onBackClicked)
|
BackHandler(onBack = stateHolder.onBackClicked)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -63,6 +69,15 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
marketingBanner = if (marketingBannerComponent != null) {
|
||||||
|
@Composable { modifier: Modifier ->
|
||||||
|
TangemThemeRedesign {
|
||||||
|
marketingBannerComponent.Content(modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(scaffoldPaddings)
|
.padding(scaffoldPaddings)
|
||||||
.testTag(SwapTokenScreenTestTags.CONTAINER),
|
.testTag(SwapTokenScreenTestTags.CONTAINER),
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ internal fun SwapScreenContent(
|
||||||
state: SwapStateHolder,
|
state: SwapStateHolder,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
feeBlock: @Composable ((Modifier) -> Unit)? = null,
|
feeBlock: @Composable ((Modifier) -> Unit)? = null,
|
||||||
|
marketingBanner: @Composable ((Modifier) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val keyboard by keyboardAsState()
|
val keyboard by keyboardAsState()
|
||||||
|
|
||||||
|
|
@ -86,6 +87,8 @@ internal fun SwapScreenContent(
|
||||||
) {
|
) {
|
||||||
MainInfo(state)
|
MainInfo(state)
|
||||||
|
|
||||||
|
marketingBanner?.invoke(Modifier.fillMaxWidth())
|
||||||
|
|
||||||
if (state.swapUIMode == SwapUIMode.Simple) {
|
if (state.swapUIMode == SwapUIMode.Simple) {
|
||||||
ProviderItemBlockSimple(state = state.providerState)
|
ProviderItemBlockSimple(state = state.providerState)
|
||||||
} else {
|
} 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.UserWallet
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
|
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
|
||||||
|
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
|
||||||
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
||||||
import com.tangem.domain.settings.usercountry.models.UserCountry
|
import com.tangem.domain.settings.usercountry.models.UserCountry
|
||||||
import com.tangem.domain.stories.ShouldShowStoriesUseCase
|
import com.tangem.domain.stories.ShouldShowStoriesUseCase
|
||||||
|
|
@ -103,6 +104,7 @@ internal abstract class SwapModelTestBase {
|
||||||
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
|
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
|
||||||
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
|
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
|
||||||
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
|
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
|
||||||
|
protected val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase = mockk(relaxed = true)
|
||||||
protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true)
|
protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true)
|
||||||
protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
|
protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
|
||||||
|
|
||||||
|
|
@ -175,6 +177,7 @@ internal abstract class SwapModelTestBase {
|
||||||
getSwapUiModeUseCase = getSwapUiModeUseCase,
|
getSwapUiModeUseCase = getSwapUiModeUseCase,
|
||||||
setSwapUiModeUseCase = setSwapUiModeUseCase,
|
setSwapUiModeUseCase = setSwapUiModeUseCase,
|
||||||
calculateAmountUseCase = calculateAmountUseCase,
|
calculateAmountUseCase = calculateAmountUseCase,
|
||||||
|
getCurrencyUSDQuoteUseCase = getCurrencyUSDQuoteUseCase,
|
||||||
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
|
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
|
||||||
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
|
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:manage-tokens:api\$")) || // provides Composable function
|
||||||
contains(Regex(pattern = ":features:txhistory: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:promo-banners:api\$")) || // provides Composable function
|
||||||
|
contains(Regex(pattern = ":features:marketing:api\$")) || // provides Composable function
|
||||||
contains(Regex(pattern = ":impl\$"))
|
contains(Regex(pattern = ":impl\$"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue