Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-22 19:35:21 +02:00
parent ddfe8e2789
commit 3773f5f8c9
9 changed files with 150 additions and 37 deletions

View file

@ -125,9 +125,12 @@ internal class MarketingBannerModel @Inject constructor(
campaignId = id, campaignId = id,
text = banner.text, text = banner.text,
iconUrl = banner.iconUrl, iconUrl = banner.iconUrl,
// When the backend omits iconAlign, follow the design default: a dismissible banner keeps the icon
// on the left (the close button occupies the right slot), a non-dismissible one moves it to the right.
iconAlign = when (banner.iconAlign) { iconAlign = when (banner.iconAlign) {
MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT
MarketingBanner.IconAlign.LEFT, null -> MarketingBannerUM.IconAlign.LEFT MarketingBanner.IconAlign.LEFT -> MarketingBannerUM.IconAlign.LEFT
null -> if (banner.isDismissible) MarketingBannerUM.IconAlign.LEFT else MarketingBannerUM.IconAlign.RIGHT
}, },
isDismissible = banner.isDismissible, isDismissible = banner.isDismissible,
deeplink = banner.deeplink, deeplink = banner.deeplink,

View file

@ -21,7 +21,6 @@ import coil.request.ImageRequest
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.messagebanner.CloseButton import com.tangem.core.ui.ds2.messagebanner.CloseButton
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -51,11 +50,10 @@ internal fun MarketingBanner(
TangemMessageBanner( TangemMessageBanner(
title = stringReference(banner.text.orEmpty()), title = stringReference(banner.text.orEmpty()),
modifier = modifier.then( modifier = modifier,
if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier,
),
variant = TangemMessageBanner.Variant.Default, variant = TangemMessageBanner.Variant.Default,
showGlowRing = false, showGlowRing = false,
onClick = if (hasDeeplink) onClick else null,
slotStart = if (isIconAtStart) { slotStart = if (isIconAtStart) {
{ BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) } { BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) }
} else { } else {

View file

@ -16,6 +16,8 @@ import com.tangem.features.marketing.api.LinkedBannerRequest
import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.Runs import io.mockk.Runs
import io.mockk.clearMocks import io.mockk.clearMocks
@ -33,6 +35,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketingBannerModelTest { internal class MarketingBannerModelTest {
@ -280,4 +283,53 @@ internal class MarketingBannerModelTest {
// Assert // Assert
verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") } verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") }
} }
@ParameterizedTest
@ProvideTestModels
fun `GIVEN iconAlign and dismissible WHEN mapped THEN align follows design default`(
model: IconAlignModel,
) = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
standaloneCampaign(id = 1, iconAlign = model.iconAlign, isDismissible = model.isDismissible),
).right()
val bannerModel = createModel(
MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))),
)
// Act
advanceUntilIdle()
// Assert
val content = bannerModel.uiState.value as MarketingBannerListUM.Content
assertThat(content.banners.single().iconAlign).isEqualTo(model.expected)
}
private fun standaloneCampaign(id: Int, iconAlign: MarketingBanner.IconAlign?, isDismissible: Boolean) =
campaign(id, MarketingBanner.UiType.STANDALONE).let { base ->
base.copy(banner = base.banner.copy(iconAlign = iconAlign, isDismissible = isDismissible))
}
internal data class IconAlignModel(
val iconAlign: MarketingBanner.IconAlign?,
val isDismissible: Boolean,
val expected: MarketingBannerUM.IconAlign,
)
private fun provideTestModels() = listOf(
// Backend omits iconAlign -> derived from dismissible (design default)
IconAlignModel(iconAlign = null, isDismissible = false, expected = MarketingBannerUM.IconAlign.RIGHT),
IconAlignModel(iconAlign = null, isDismissible = true, expected = MarketingBannerUM.IconAlign.LEFT),
// Explicit backend value is always honored regardless of dismissible
IconAlignModel(
iconAlign = MarketingBanner.IconAlign.LEFT,
isDismissible = false,
expected = MarketingBannerUM.IconAlign.LEFT,
),
IconAlignModel(
iconAlign = MarketingBanner.IconAlign.RIGHT,
isDismissible = true,
expected = MarketingBannerUM.IconAlign.RIGHT,
),
)
} }

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.marketing.api.MarketingBannerComponent
internal interface AllOffersComponent : ComposableBottomSheetComponent { internal interface AllOffersComponent : ComposableBottomSheetComponent {
@ -14,6 +15,12 @@ internal interface AllOffersComponent : ComposableBottomSheetComponent {
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val amountCurrencyCode: String, val amountCurrencyCode: String,
// Marketing banner components are created and owned by the parent onramp-main component and passed
// down so this sheet reuses their models (and their amount-gated request flows) instead of building
// its own: [marketingBannerComponent] renders the standalone banner, [linkedMarketingBannerComponent]
// renders the per-provider LINKED_TO_PROVIDER banner next to each offer.
val marketingBannerComponent: MarketingBannerComponent,
val linkedMarketingBannerComponent: MarketingBannerComponent,
) )
interface Factory : ComponentFactory<Params, AllOffersComponent> interface Factory : ComponentFactory<Params, AllOffersComponent>

View file

@ -13,7 +13,7 @@ import dagger.assisted.AssistedInject
internal class DefaultAllOffersComponent @AssistedInject constructor( internal class DefaultAllOffersComponent @AssistedInject constructor(
@Assisted context: AppComponentContext, @Assisted context: AppComponentContext,
@Assisted params: AllOffersComponent.Params, @Assisted private val params: AllOffersComponent.Params,
) : AllOffersComponent, AppComponentContext by context { ) : AllOffersComponent, AppComponentContext by context {
private val model: AllOffersModel = getOrCreateModel(params) private val model: AllOffersModel = getOrCreateModel(params)
@ -27,6 +27,8 @@ internal class DefaultAllOffersComponent @AssistedInject constructor(
val state by model.state.collectAsState() val state by model.state.collectAsState()
AllOffersContentSheet( AllOffersContentSheet(
state = state, state = state,
marketingBannerComponent = params.marketingBannerComponent,
linkedMarketingBannerComponent = params.linkedMarketingBannerComponent,
onCloseClick = { dismiss() }, onCloseClick = { dismiss() },
) )
} }

View file

@ -25,9 +25,11 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
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.domain.onramp.model.OnrampPaymentMethod import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus import com.tangem.domain.onramp.model.PaymentMethodStatus
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.alloffers.entity.AllOffersPaymentMethodUM import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
@ -35,13 +37,18 @@ import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.Offer import com.tangem.features.onramp.main.ui.OfferWithLinkedBanner
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@Composable @Composable
internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () -> Unit) { internal fun AllOffersContentSheet(
state: AllOffersStateUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
onCloseClick: () -> Unit,
) {
val onBack = remember(state) { val onBack = remember(state) {
{ {
if (state is AllOffersStateUM.Content && state.currentMethod != null) { if (state is AllOffersStateUM.Content && state.currentMethod != null) {
@ -71,37 +78,64 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
} }
}, },
content = { content = {
Box( AllOffersSheetContent(
modifier = Modifier state = state,
.fillMaxSize() marketingBannerComponent = marketingBannerComponent,
.padding(vertical = 8.dp) linkedMarketingBannerComponent = linkedMarketingBannerComponent,
.animateContentSize(), )
) { },
AnimatedContent( )
targetState = state is AllOffersStateUM.Content && state.currentMethod != null, }
transitionSpec = {
fadeIn(tween(durationMillis = 220)) togetherWith @Composable
fadeOut(tween(durationMillis = 220)) private fun AllOffersSheetContent(
}, state: AllOffersStateUM,
label = "Change offers and payment method state", marketingBannerComponent: MarketingBannerComponent,
) { shouldShowOffersScreen -> linkedMarketingBannerComponent: MarketingBannerComponent,
when (state) { ) {
AllOffersStateUM.Loading -> AllOffersContentLoading() Column(modifier = Modifier.fillMaxSize()) {
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification) // Standalone marketing banner at the top of the sheet (DS3 -> wrap in the redesign theme).
is AllOffersStateUM.Content -> { // Renders nothing when no matching campaign, so it adds no space in the common case.
if (shouldShowOffersScreen) { TangemThemeRedesign {
state.currentMethod?.let { marketingBannerComponent.Content(
OffersBasedOnPaymentMethodContent(offers = it.offers) Modifier
} .fillMaxWidth()
} else { .padding(horizontal = 16.dp),
PaymentMethodsContent(methods = state.methods) )
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
.animateContentSize(),
) {
AnimatedContent(
targetState = state is AllOffersStateUM.Content && state.currentMethod != null,
transitionSpec = {
fadeIn(tween(durationMillis = 220)) togetherWith
fadeOut(tween(durationMillis = 220))
},
label = "Change offers and payment method state",
) { shouldShowOffersScreen ->
when (state) {
AllOffersStateUM.Loading -> AllOffersContentLoading()
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
is AllOffersStateUM.Content -> {
if (shouldShowOffersScreen) {
state.currentMethod?.let { method ->
OffersBasedOnPaymentMethodContent(
offers = method.offers,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
} }
} else {
PaymentMethodsContent(methods = state.methods)
} }
} }
} }
} }
}, }
) }
} }
@Composable @Composable
@ -127,7 +161,10 @@ private fun PaymentMethodTitle(onCloseClick: () -> Unit) {
} }
@Composable @Composable
private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferUM>) { private fun OffersBasedOnPaymentMethodContent(
offers: ImmutableList<OnrampOfferUM>,
linkedMarketingBannerComponent: MarketingBannerComponent,
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -136,7 +173,7 @@ private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferU
) { ) {
offers.fastForEach { offer -> offers.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)
} }
} }
@ -250,11 +287,18 @@ private fun AllOffersContentSheetPaymentPreview() {
currentMethod = method, currentMethod = method,
onBackClicked = {}, onBackClicked = {},
), ),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {}, onCloseClick = {},
) )
} }
} }
private val PreviewMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
}
@Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
@ -319,6 +363,8 @@ private fun AllOffersContentSheetOffersPreview() {
currentMethod = null, currentMethod = null,
onBackClicked = {}, onBackClicked = {},
), ),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {}, onCloseClick = {},
) )
} }

View file

@ -106,6 +106,8 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
onDismiss = model.bottomSheetNavigation::dismiss, onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage, openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode, amountCurrencyCode = config.amountCurrencyCode,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
), ),
) )
} }

View file

@ -104,7 +104,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBann
} }
@Composable @Composable
private fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) { internal fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
val hasBanner = linkedMarketingBannerComponent.hasLinkedBanner(offer.providerId) val hasBanner = linkedMarketingBannerComponent.hasLinkedBanner(offer.providerId)
// Square the offer's bottom corners so the bottom-rounded banner glues to it as one card. // Square the offer's bottom corners so the bottom-rounded banner glues to it as one card.
Offer(offer, roundBottom = !hasBanner) Offer(offer, roundBottom = !hasBanner)

View file

@ -170,6 +170,9 @@ private fun StakingScreenContent(
amountState = uiState.amountState, amountState = uiState.amountState,
clickIntents = uiState.clickIntents, clickIntents = uiState.clickIntents,
modifier = Modifier.background(TangemTheme.colors.background.secondary), modifier = Modifier.background(TangemTheme.colors.background.secondary),
extraContent = {
marketingBannerComponent.Content(Modifier.fillMaxWidth())
},
) )
StakingStep.Confirmation -> StakingConfirmationContent( StakingStep.Confirmation -> StakingConfirmationContent(
amountState = uiState.amountState, amountState = uiState.amountState,