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,
text = banner.text,
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) {
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,
deeplink = banner.deeplink,

View file

@ -21,7 +21,6 @@ import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.messagebanner.CloseButton
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -51,11 +50,10 @@ internal fun MarketingBanner(
TangemMessageBanner(
title = stringReference(banner.text.orEmpty()),
modifier = modifier.then(
if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier,
),
modifier = modifier,
variant = TangemMessageBanner.Variant.Default,
showGlowRing = false,
onClick = if (hasDeeplink) onClick else null,
slotStart = if (isIconAtStart) {
{ BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) }
} 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.MarketingBannerRequest
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 io.mockk.Runs
import io.mockk.clearMocks
@ -33,6 +35,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketingBannerModelTest {
@ -280,4 +283,53 @@ internal class MarketingBannerModelTest {
// Assert
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.wallet.UserWallet
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.marketing.api.MarketingBannerComponent
internal interface AllOffersComponent : ComposableBottomSheetComponent {
@ -14,6 +15,12 @@ internal interface AllOffersComponent : ComposableBottomSheetComponent {
val onDismiss: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
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>

View file

@ -13,7 +13,7 @@ import dagger.assisted.AssistedInject
internal class DefaultAllOffersComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AllOffersComponent.Params,
@Assisted private val params: AllOffersComponent.Params,
) : AllOffersComponent, AppComponentContext by context {
private val model: AllOffersModel = getOrCreateModel(params)
@ -27,6 +27,8 @@ internal class DefaultAllOffersComponent @AssistedInject constructor(
val state by model.state.collectAsState()
AllOffersContentSheet(
state = state,
marketingBannerComponent = params.marketingBannerComponent,
linkedMarketingBannerComponent = params.linkedMarketingBannerComponent,
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.res.TangemTheme
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.PaymentMethodStatus
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.AllOffersStateUM
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.OnrampOfferCategoryUM
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.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () -> Unit) {
internal fun AllOffersContentSheet(
state: AllOffersStateUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
onCloseClick: () -> Unit,
) {
val onBack = remember(state) {
{
if (state is AllOffersStateUM.Content && state.currentMethod != null) {
@ -71,6 +78,31 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
}
},
content = {
AllOffersSheetContent(
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
},
)
}
@Composable
private fun AllOffersSheetContent(
state: AllOffersStateUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Standalone marketing banner at the top of the sheet (DS3 -> wrap in the redesign theme).
// Renders nothing when no matching campaign, so it adds no space in the common case.
TangemThemeRedesign {
marketingBannerComponent.Content(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
Box(
modifier = Modifier
.fillMaxSize()
@ -90,8 +122,11 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
is AllOffersStateUM.Content -> {
if (shouldShowOffersScreen) {
state.currentMethod?.let {
OffersBasedOnPaymentMethodContent(offers = it.offers)
state.currentMethod?.let { method ->
OffersBasedOnPaymentMethodContent(
offers = method.offers,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
}
} else {
PaymentMethodsContent(methods = state.methods)
@ -100,8 +135,7 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
}
}
}
},
)
}
}
@Composable
@ -127,7 +161,10 @@ private fun PaymentMethodTitle(onCloseClick: () -> Unit) {
}
@Composable
private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferUM>) {
private fun OffersBasedOnPaymentMethodContent(
offers: ImmutableList<OnrampOfferUM>,
linkedMarketingBannerComponent: MarketingBannerComponent,
) {
Column(
modifier = Modifier
.fillMaxWidth()
@ -136,7 +173,7 @@ private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferU
) {
offers.fastForEach { offer ->
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
Offer(offer)
OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
SpacerH(8.dp)
}
}
@ -250,11 +287,18 @@ private fun AllOffersContentSheetPaymentPreview() {
currentMethod = method,
onBackClicked = {},
),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {},
)
}
}
private val PreviewMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -319,6 +363,8 @@ private fun AllOffersContentSheetOffersPreview() {
currentMethod = null,
onBackClicked = {},
),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {},
)
}

View file

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

View file

@ -104,7 +104,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBann
}
@Composable
private fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
internal 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)

View file

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