Updated on 2026-08-14
This commit is contained in:
parent
ddfe8e2789
commit
3773f5f8c9
9 changed files with 150 additions and 37 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,37 +78,64 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
|
|||
}
|
||||
},
|
||||
content = {
|
||||
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 {
|
||||
OffersBasedOnPaymentMethodContent(offers = it.offers)
|
||||
}
|
||||
} else {
|
||||
PaymentMethodsContent(methods = state.methods)
|
||||
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()
|
||||
.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
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
|
|||
onDismiss = model.bottomSheetNavigation::dismiss,
|
||||
openRedirectPage = params.openRedirectPage,
|
||||
amountCurrencyCode = config.amountCurrencyCode,
|
||||
marketingBannerComponent = marketingBannerComponent,
|
||||
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue