From b208c0dd7c1edf12b735992d0cf532c4c532f9d5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 13:13:09 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../core/ui/ds/message/TangemMessage.kt | 176 +++++++++++++----- .../core/ui/ds/message/TangemMessageUM.kt | 14 ++ features/promo-banners/impl/build.gradle.kts | 1 + .../promobanners/impl/ui/PromoBannersBlock.kt | 108 +++++++++-- .../wallet/child/wallet/WalletComponent.kt | 1 + .../presentation/wallet/ui/WalletScreen2.kt | 5 + .../ui/components/common/WalletContent.kt | 8 + 7 files changed, 258 insertions(+), 55 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt index ecda9ea392..93489e6a6c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -22,7 +23,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import coil.compose.SubcomposeAsyncImage import com.tangem.core.ui.R +import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState @@ -47,6 +50,25 @@ fun TangemMessage( modifier: Modifier = Modifier, contentColor: Color = TangemTheme.colors2.surface.level3, ) { + val isIconLeading = messageUM.onCloseClick != null || + messageUM.iconPosition == TangemMessageIconPosition.Leading + val icon: (@Composable RowScope.() -> Unit)? = messageUM.iconUM?.let { iconUM -> + { + TangemIcon( + tangemIconUM = iconUM, + modifier = Modifier + .align( + if (messageUM.buttonsUM.isEmpty() && !isIconLeading) { + Alignment.CenterVertically + } else { + Alignment.Top + }, + ) + .size(messageUM.iconSize) + .testTag(NotificationTestTags.ICON), + ) + } + } TangemMessage( modifier = modifier .conditional(messageUM.onClick != null) { @@ -56,23 +78,8 @@ fun TangemMessage( subtitle = messageUM.subtitle, messageEffect = messageUM.messageEffect, isCentered = messageUM.isCentered, - trailingContent = { - if (messageUM.iconUM != null) { - TangemIcon( - tangemIconUM = messageUM.iconUM, - modifier = Modifier - .align( - if (messageUM.buttonsUM.isEmpty()) { - Alignment.CenterVertically - } else { - Alignment.Top - }, - ) - .size(messageUM.iconSize) - .testTag(NotificationTestTags.ICON), - ) - } - }, + leadingContent = if (isIconLeading) icon else null, + trailingContent = if (isIconLeading) null else icon, contentColor = contentColor, onCloseClick = messageUM.onCloseClick, buttons = { @@ -90,8 +97,10 @@ fun TangemMessage( * Tangem message component that displays a notification based on the provided [NotificationConfig]. * [Message](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8455-81318&m=dev) * - * @param config Configuration for the notification message. - * @param modifier Modifier to be applied to the message component. + * @param config Configuration for the notification message. + * @param modifier Modifier to be applied to the message component. + * @param iconPosition Position of the icon relative to the texts. When [NotificationConfig.onCloseClick] is set, + * the icon is always placed at the leading position so it never overlaps the close button. * * @see NotificationConfig for more details. * @see com.tangem.core.ui.components.notifications.Notification for legacy component. @@ -101,34 +110,41 @@ fun TangemMessage( config: NotificationConfig, modifier: Modifier = Modifier, contentColor: Color = TangemTheme.colors2.surface.level3, + iconPosition: TangemMessageIconPosition = TangemMessageIconPosition.Trailing, ) { val buttonState = config.buttonsState + val isIconLeading = config.onCloseClick != null || iconPosition == TangemMessageIconPosition.Leading + val icon: @Composable RowScope.() -> Unit = { + val iconTint = when (config.iconTint) { + NotificationConfig.IconTint.Unspecified -> null + NotificationConfig.IconTint.Accent -> TangemTheme.colors2.graphic.status.accent + NotificationConfig.IconTint.Attention -> TangemTheme.colors2.graphic.status.attention + NotificationConfig.IconTint.Warning -> TangemTheme.colors2.graphic.status.warning + } + val iconModifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON) + if (config.iconUrl != null) { + SubcomposeAsyncImage( + model = config.iconUrl, + contentDescription = null, + modifier = iconModifier.clip(CircleShape), + loading = { + CircleShimmer(modifier = Modifier.matchParentSize()) + }, + error = { + ResIcon(config = config, iconTint = iconTint, modifier = Modifier.matchParentSize()) + }, + ) + } else { + ResIcon(config = config, iconTint = iconTint, modifier = iconModifier) + } + } TangemMessage( title = config.title, subtitle = config.subtitle, modifier = modifier, - trailingContent = { - val iconTint = when (config.iconTint) { - NotificationConfig.IconTint.Unspecified -> null - NotificationConfig.IconTint.Accent -> TangemTheme.colors2.graphic.status.accent - NotificationConfig.IconTint.Attention -> TangemTheme.colors2.graphic.status.attention - NotificationConfig.IconTint.Warning -> TangemTheme.colors2.graphic.status.warning - } - if (iconTint == null) { - Image( - painter = painterResource(config.iconResId), - contentDescription = null, - modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON), - ) - } else { - Icon( - imageVector = ImageVector.vectorResource(config.iconResId), - contentDescription = null, - tint = iconTint, - modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON), - ) - } - }, + onCloseClick = config.onCloseClick, + leadingContent = if (isIconLeading) icon else null, + trailingContent = if (isIconLeading) null else icon, contentColor = contentColor, buttons = if (buttonState != null) { { @@ -148,7 +164,8 @@ fun TangemMessage( * @param title Optional title of the message. * @param subtitle Optional subtitle of the message. * @param messageEffect Effect to be applied to the message background. - * @param leadingContent Optional composable content displayed before the title and subtitle. + * @param leadingContent Optional composable content displayed before the title and subtitle. When [onCloseClick] + * is provided alongside it, the texts reserve trailing space so they never run under the close button. * @param trailingContent Optional composable content displayed after the title and subtitle. * @param buttons Optional composable buttons to be displayed below the message. * @param isCentered Flag indicating whether the content should be centered horizontally. @@ -195,6 +212,7 @@ fun TangemMessage( leadingContent = leadingContent, content = trailingContent, isCentered = isCentered, + hasCloseButton = onCloseClick != null, ) if (buttons != null) { Row( @@ -227,6 +245,7 @@ private fun TangemMessageContent( subtitle: TextReference? = null, alignment: Alignment.Horizontal = Alignment.Start, isCentered: Boolean = false, + hasCloseButton: Boolean = false, leadingContent: (@Composable RowScope.() -> Unit)? = null, content: (@Composable RowScope.() -> Unit)? = null, ) { @@ -235,13 +254,24 @@ private fun TangemMessageContent( } else { TextAlign.Start } + // The close button is drawn over the top-end corner, so in the leading-content layout the texts + // reserve trailing space to never run under it; trailing-content layouts are kept untouched + val isCloseSpaceReserved = hasCloseButton && leadingContent != null && content == null Row( horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), modifier = Modifier.padding(TangemTheme.dimens2.x1), ) { leadingContent?.invoke(this) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .then( + if (isCloseSpaceReserved) { + Modifier.padding(end = TangemTheme.dimens2.x5) + } else { + Modifier + }, + ), horizontalAlignment = alignment, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), ) { @@ -269,6 +299,24 @@ private fun TangemMessageContent( } } +@Composable +private fun ResIcon(config: NotificationConfig, iconTint: Color?, modifier: Modifier = Modifier) { + if (iconTint == null) { + Image( + painter = painterResource(config.iconResId), + contentDescription = null, + modifier = modifier, + ) + } else { + Icon( + imageVector = ImageVector.vectorResource(config.iconResId), + contentDescription = null, + tint = iconTint, + modifier = modifier, + ) + } +} + @Suppress("LongMethod") @Composable private fun RowScope.TangemMessageLegacyButtons(buttonState: ButtonsState) { @@ -413,6 +461,37 @@ private class TangemMessagePreviewProvider : PreviewParameterProvider = persistentListOf(), val onClick: (() -> Unit)? = null, val onCloseClick: (() -> Unit)? = null, ) +/** + * Position of the message icon (or custom content) relative to the texts. + * + * [Trailing] — icon at the end of the message, after the texts (default). + * [Leading] — icon at the start of the message, before the texts. Forced when a close button is shown. + */ +enum class TangemMessageIconPosition { + Trailing, + Leading, +} + /** * Data model representing a button within a Tangem message. * diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index 5570dc9c69..a510cfa91b 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { /** Compose */ implementation(deps.compose.foundation) implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) implementation(deps.lifecycle.compose) /** Other */ diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt index 5cebe96145..c6a4666d66 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt @@ -1,7 +1,9 @@ package com.tangem.features.promobanners.impl.ui +import android.content.res.Configuration import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -13,14 +15,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.pager.PagerIndicator +import com.tangem.core.ui.ds.message.TangemMessage +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.promobanners.api.PromoBannersBlockComponent.Placeholder import com.tangem.features.promobanners.impl.model.PromoBannerNotificationUM import com.tangem.features.promobanners.impl.model.PromoBannersBlockUM +import kotlinx.collections.immutable.persistentListOf import kotlin.math.ceil import kotlin.math.floor @@ -54,17 +65,46 @@ internal fun PromoBannersBlock(state: PromoBannersBlockUM, modifier: Modifier = } @Composable -private fun bannerContainerColor(placeholder: Placeholder): Color = when (placeholder) { - Placeholder.MAIN -> TangemTheme.colors.background.primary - Placeholder.FEED -> TangemTheme.colors.background.action +private fun bannerContainerColor(placeholder: Placeholder): Color = if (LocalRedesignEnabled.current) { + when (placeholder) { + Placeholder.MAIN -> TangemTheme.colors2.surface.level1 + Placeholder.FEED -> TangemTheme.colors2.surface.level3 + } +} else { + when (placeholder) { + Placeholder.MAIN -> TangemTheme.colors.background.primary + Placeholder.FEED -> TangemTheme.colors.background.action + } +} + +/** + * Renders a banner either with the redesigned [TangemMessage] component or the legacy [Notification], + * depending on [LocalRedesignEnabled]. Both accept the same [NotificationConfig], so the banner model + * and converters stay untouched. + */ +@Composable +private fun BannerNotification(config: NotificationConfig, containerColor: Color, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + TangemMessage( + config = config, + modifier = modifier.fillMaxWidth(), + contentColor = containerColor, + ) + } else { + Notification( + config = config, + modifier = modifier.fillMaxWidth(), + containerColor = containerColor, + ) + } } @Composable private fun SingleBanner(banner: PromoBannerNotificationUM, containerColor: Color, modifier: Modifier = Modifier) { - Notification( + BannerNotification( config = banner.config, - modifier = modifier.fillMaxWidth(), containerColor = containerColor, + modifier = modifier, ) } @@ -139,18 +179,16 @@ private fun SmoothHeightPager( val fraction = scrollPosition - floor(scrollPosition) val lowerHeight = subcompose(slotId = "measure_lower") { - Notification( + BannerNotification( config = banners[lowerPage].config, - modifier = Modifier.fillMaxWidth(), containerColor = containerColor, ) }.first().measure(pageConstraints).height val upperHeight = if (upperPage != lowerPage) { subcompose(slotId = "measure_upper") { - Notification( + BannerNotification( config = banners[upperPage].config, - modifier = Modifier.fillMaxWidth(), containerColor = containerColor, ) }.first().measure(pageConstraints).height @@ -167,9 +205,8 @@ private fun SmoothHeightPager( pageSpacing = TangemTheme.dimens.spacing16, ) { page -> val banner = banners.getOrNull(page) ?: return@HorizontalPager - Notification( + BannerNotification( config = banner.config, - modifier = Modifier.fillMaxWidth(), containerColor = containerColor, ) } @@ -181,4 +218,51 @@ private fun SmoothHeightPager( pagerPlaceable.place(0, 0) } } -} \ No newline at end of file +} + +// region Preview +private fun previewState(bannerCount: Int) = PromoBannersBlockUM( + userWalletId = "preview", + initialPage = 0, + banners = persistentListOf( + *Array(bannerCount) { index -> + PromoBannerNotificationUM( + displayId = index, + config = NotificationConfig( + title = stringReference("Earn up to 14% APY"), + subtitle = stringReference("Staking is the easiest way to earn rewards on your crypto."), + iconResId = com.tangem.core.ui.R.drawable.ic_alert_circle_24, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = stringReference("Start earning"), + onClick = {}, + ), + onCloseClick = {}, + ), + ) + }, + ), + isVisibleOnScreen = false, + placeholder = Placeholder.MAIN, + onBannerShown = {}, + onCarouselScrolled = {}, + onPageChanged = {}, +) + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_PromoBannersBlock_Legacy() { + TangemThemePreview { + PromoBannersBlock(state = previewState(bannerCount = 2), modifier = Modifier.padding(16.dp)) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_PromoBannersBlock_Redesign() { + TangemThemePreviewRedesign { + PromoBannersBlock(state = previewState(bannerCount = 2), modifier = Modifier.padding(16.dp)) + } +} +// endregion \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index 724052b15f..b2c1be6913 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -289,6 +289,7 @@ internal class WalletComponent @AssistedInject constructor( if (designFeatureToggles.isRedesignEnabled) { WalletScreen2( state = uiState, + promoBannersBlockComponent = promoBannersBlockComponent, tangemPayComponent = tangemPayMainBlockComponent, virtualAccountComponent = virtualAccountMainBlockComponent, bottomSheetContent = { onExpandSheet -> diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 9d5c452aa7..2329a20839 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -55,6 +55,7 @@ import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.components.rememberIsKeyboardVisible import com.tangem.core.ui.components.sheetscaffold.* +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior @@ -92,6 +93,7 @@ internal fun WalletScreen2( tangemPayComponent: TangemPayMainBlockComponent, virtualAccountComponent: VirtualAccountMainBlockComponent, modifier: Modifier = Modifier, + promoBannersBlockComponent: ComposableContentComponent? = null, bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, bottomSheetHeaderHeightProvider: () -> Dp, onBottomSheetStateChange: (BottomSheetState) -> Unit, @@ -136,6 +138,7 @@ internal fun WalletScreen2( state = state, walletsPagerState = walletsPagerState, tangemPayComponent = tangemPayComponent, + promoBannersBlockComponent = promoBannersBlockComponent, virtualAccountComponent = virtualAccountComponent, behavior = behavior, bottomSheetContent = bottomSheetContent, @@ -168,6 +171,7 @@ private fun WalletContent2( behavior: TangemCollapsingAppBarBehavior, listStates: Map, modifier: Modifier = Modifier, + promoBannersBlockComponent: ComposableContentComponent? = null, bottomSheetHeaderHeightProvider: () -> Dp, onBottomSheetStateChange: (BottomSheetState) -> Unit, bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, @@ -336,6 +340,7 @@ private fun WalletContent2( isBalanceHidden = state.isHidingMode, contentPadding = contentPadding, tangemPayComponent = tangemPayComponent, + promoBannersBlockComponent = promoBannersBlockComponent, virtualAccountComponent = virtualAccountComponent, modifier = Modifier .fillMaxSize() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt index e3bcda5ec0..f59bc324b5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt @@ -15,6 +15,7 @@ import com.tangem.common.ui.notifications.notifications import com.tangem.common.ui.notifications.notificationsCarousel import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.components.transactions.txHistoryItems +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState @@ -39,6 +40,7 @@ internal fun WalletListContent( virtualAccountComponent: VirtualAccountMainBlockComponent, contentPadding: PaddingValues, modifier: Modifier = Modifier, + promoBannersBlockComponent: ComposableContentComponent? = null, ) { val containerColor = TangemTheme.colors2.surface.level1 @@ -63,6 +65,12 @@ internal fun WalletListContent( notifications = currentWallet.notificationsCarousel.map { it.messageUM }.toPersistentList(), ) + promoBannersBlockComponent?.let { component -> + item(key = "PromoBannersBlock") { + component.Content(modifier = itemModifier) + } + } + tangemPay( tangemPayComponent = tangemPayComponent, tangemPayUM = currentWallet.tangemPayMainUM,