diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt index 6c03a6d43a..8808bb4097 100644 --- a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt @@ -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, diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt index c24afa21d0..719a63a24f 100644 --- a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt @@ -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 { diff --git a/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt index 6e1643f546..51967a0363 100644 --- a/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt +++ b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt @@ -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, + ), + ) } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt index d2d2615c0e..da3f45b7d9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/AllOffersComponent.kt @@ -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 diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt index 12c4ec31f9..1ece8b5a5c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/DefaultAllOffersComponent.kt @@ -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() }, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt index 8f4e481d37..5fa50c230c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/AllOffersContentSheet.kt @@ -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) { +private fun OffersBasedOnPaymentMethodContent( + offers: ImmutableList, + linkedMarketingBannerComponent: MarketingBannerComponent, +) { Column( modifier = Modifier .fillMaxWidth() @@ -136,7 +173,7 @@ private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList 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 = {}, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt index 268e40bb54..2103967c01 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/DefaultOnrampMainComponent.kt @@ -106,6 +106,8 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor( onDismiss = model.bottomSheetNavigation::dismiss, openRedirectPage = params.openRedirectPage, amountCurrencyCode = config.amountCurrencyCode, + marketingBannerComponent = marketingBannerComponent, + linkedMarketingBannerComponent = linkedMarketingBannerComponent, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt index a8ac8de316..4f33a3ce4b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampOffersContent.kt @@ -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) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt index 7e78a43cd4..434172dd0d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -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,