Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 13:13:09 +03:00
parent 7db3810064
commit b208c0dd7c
7 changed files with 258 additions and 55 deletions

View file

@ -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<TangemMess
isCentered = true,
onCloseClick = {},
),
TangemMessageUM(
id = "5",
title = stringReference("Title text"),
subtitle = stringReference("Subtext"),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
onCloseClick = {},
),
TangemMessageUM(
id = "5-leading",
title = stringReference("Title text"),
subtitle = stringReference("Subtext"),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
iconPosition = TangemMessageIconPosition.Leading,
),
TangemMessageUM(
id = "6",
title = stringReference("Invite friends. Earn 10 USDT."),
subtitle = stringReference("Share Tangem, give 10% OFF, and earn 10 USDT - until Jun 5"),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_tangem_24),
onCloseClick = {},
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = stringReference("Invite friends"),
type = TangemButtonType.Secondary,
onClick = {},
),
),
),
)
}
// endregion
@ -561,6 +640,17 @@ private class TangemMessageLegacyPreviewProvider : PreviewParameterProvider<Noti
subtitle = resourceReference(id = R.string.information_generated_with_ai),
iconResId = R.drawable.ic_magic_28,
),
NotificationConfig(
title = TextReference.Str(value = "Invite friends. Earn 10 USDT."),
subtitle = TextReference.Str(value = "Share Tangem, give 10% OFF, and earn 10 USDT - until Jun 5"),
iconResId = R.drawable.img_attention_20,
iconSize = 36.dp,
buttonsState = ButtonsState.SecondaryButtonConfig(
text = TextReference.Str(value = "Invite friends"),
onClick = {},
),
onCloseClick = {},
),
)
}
// endregion

View file

@ -17,6 +17,8 @@ import kotlinx.collections.immutable.persistentListOf
* @param messageEffect TangemMessageEffect defining the visual effect of the message.
* @param iconUM Optional TangemIconUM representing the icon to be displayed in the message.
* @param iconSize Dp value defining the size of the icon (default is 28.dp).
* @param iconPosition Position of the icon relative to the texts. When [onCloseClick] is set, the icon is
* always placed at the leading position so it never overlaps the close button.
* @param isCentered Boolean indicating whether the icon is centered.
* @param buttonsUM ImmutableList of TangemMessageButtonUM representing the buttons in the message.
* @param onClick Lambda to be invoked when the message is clicked (optional).
@ -29,12 +31,24 @@ data class TangemMessageUM(
val messageEffect: TangemMessageEffect = TangemMessageEffect.None,
val iconUM: TangemIconUM? = null,
val iconSize: Dp = 28.dp,
val iconPosition: TangemMessageIconPosition = TangemMessageIconPosition.Trailing,
val isCentered: Boolean = false,
val buttonsUM: ImmutableList<TangemMessageButtonUM> = 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.
*

View file

@ -30,6 +30,7 @@ dependencies {
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.lifecycle.compose)
/** Other */

View file

@ -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)
}
}
}
}
// 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

View file

@ -289,6 +289,7 @@ internal class WalletComponent @AssistedInject constructor(
if (designFeatureToggles.isRedesignEnabled) {
WalletScreen2(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponent,
tangemPayComponent = tangemPayMainBlockComponent,
virtualAccountComponent = virtualAccountMainBlockComponent,
bottomSheetContent = { onExpandSheet ->

View file

@ -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<Int, LazyListState>,
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()

View file

@ -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,