Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 17:18:33 +03:00
parent af84319092
commit b39699ad04
7 changed files with 933 additions and 0 deletions

View file

@ -0,0 +1,96 @@
package com.tangem.core.ui.components.notifications
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.messagebanner.CloseButton
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.ColorReference2
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
/**
* Driver that renders a DS3 [TangemMessageBanner] from a legacy [NotificationConfig], so existing
* notification call sites can adopt the new banner without rebuilding their models.
*
* @param config Legacy notification model.
* @param variant Banner appearance. See [TangemMessageBanner.Variant].
* @param contentAlign Text alignment. See [TangemMessageBanner.ContentAlign].
*/
@Deprecated("Use as migration solution, not production one")
@Composable
fun TangemMessageBanner(
config: NotificationConfig,
modifier: Modifier = Modifier,
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start,
) {
val onClick = config.onClick
val (secondaryButton, primaryButton) = config.buttonsState.toBannerButtons()
val icon = config.toBannerIcon()
TangemMessageBanner(
modifier = if (onClick != null) modifier.clickableSingle(onClick = onClick) else modifier,
variant = variant,
contentAlign = contentAlign,
title = config.title ?: config.subtitle,
description = config.title?.let { config.subtitle },
secondaryButton = secondaryButton,
primaryButton = primaryButton,
slotStart = icon?.let { iconUM ->
{ TangemIcon(tangemIconUM = iconUM, modifier = Modifier.size(config.iconSize)) }
},
slotEnd = config.onCloseClick?.let { onClose ->
{ TangemMessageBanner.CloseButton(onClick = onClose) }
},
)
}
/**
* Builds the leading icon, honouring the remote-url, untinted, and tinted cases of [NotificationConfig].
* Returns `null` when the config carries no icon (no url and an unset [NotificationConfig.iconResId]),
* so the banner hides the leading slot instead of rendering a broken one.
*/
private fun NotificationConfig.toBannerIcon(): TangemIconUM? = when {
iconUrl != null -> TangemIconUM.Url(url = iconUrl, fallbackRes = iconResId)
iconResId == 0 -> null
iconTint == NotificationConfig.IconTint.Unspecified -> TangemIconUM.Image(imageRes = iconResId)
else -> TangemIconUM.Icon(iconRes = iconResId, tintReference = ColorReference2 { iconTint.toColor() })
}
@Composable
private fun NotificationConfig.IconTint.toColor() = when (this) {
NotificationConfig.IconTint.Unspecified -> TangemTheme.colors3.icon.primary
NotificationConfig.IconTint.Accent -> TangemTheme.colors3.icon.status.info
NotificationConfig.IconTint.Attention -> TangemTheme.colors3.icon.status.warning
NotificationConfig.IconTint.Warning -> TangemTheme.colors3.icon.status.warning
}
/** Maps the legacy button configuration onto the banner's (secondary = start, primary = end) pair. */
private fun ButtonsState?.toBannerButtons(): Pair<TangemMessageBanner.Button?, TangemMessageBanner.Button?> =
when (this) {
is ButtonsState.PrimaryButtonConfig -> null to TangemMessageBanner.Button(
text = text,
onClick = onClick,
iconEnd = iconResId?.let { TangemIconUM.Icon(iconRes = it) },
isLoading = shouldShowProgress,
)
is ButtonsState.SecondaryButtonConfig -> TangemMessageBanner.Button(
text = text,
onClick = onClick,
iconEnd = iconResId?.let { TangemIconUM.Icon(iconRes = it) },
isLoading = shouldShowProgress,
) to null
is ButtonsState.PairButtonsConfig -> TangemMessageBanner.Button(
text = secondaryText,
onClick = onSecondaryClick,
) to TangemMessageBanner.Button(text = primaryText, onClick = onPrimaryClick)
is ButtonsState.SecondaryPairButtonsConfig -> TangemMessageBanner.Button(
text = leftText,
onClick = onLeftClick,
) to TangemMessageBanner.Button(text = rightText, onClick = onRightClick)
null -> null to null
}

View file

@ -0,0 +1,436 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.messagebanner
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.glowring.TangemGlowRing
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
/**
* Design-system v2 (DS3) **Message Banner** low-level slot API: a [content] block above an
* optional action-button row. For the common title/description layout, prefer the `title` overload.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev)
*
* @param variant Visual appearance background color + glow ring.
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
* background.
* @param secondaryButton Start action. `null` hides it.
* @param primaryButton End action. `null` hides it.
* @param content The banner body above the buttons.
*/
@Composable
fun TangemMessageBanner(
modifier: Modifier = Modifier,
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
showGlowRing: Boolean = true,
secondaryButton: TangemMessageBanner.Button? = null,
primaryButton: TangemMessageBanner.Button? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val tokens = variant.tokens()
Box(modifier = modifier) {
TangemSurface(
modifier = Modifier.fillMaxWidth(),
color = tokens.background,
shape = RoundedCornerShape(28.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
content()
MessageBannerButtons(secondaryButton = secondaryButton, primaryButton = primaryButton)
}
}
if (showGlowRing) {
TangemGlowRing(
modifier = Modifier.matchParentSize(),
variant = tokens.glowRing,
cornerRadius = 28.dp,
)
}
}
}
/**
* Design-system v2 (DS3) **Message Banner** title/description header with optional slots and an
* action-button row.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev)
*
* @param title Banner headline.
* @param variant Visual appearance background color + glow ring.
* @param contentAlign Horizontal alignment of the text block.
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
* background.
* @param description Secondary line under the [title]. `null` hides it.
* @param secondaryButton Start action. `null` hides it.
* @param primaryButton End action. `null` hides it.
* @param slotStart Leading slot before the title. `null` hides it.
* @param slotEnd Trailing slot after the title (e.g. the [CloseButton] preset). `null` hides it.
* @param extraBottomSlot Slot under the description, inside the text column.
*/
@Suppress("LongParameterList")
@Composable
fun TangemMessageBanner(
title: TextReference,
modifier: Modifier = Modifier,
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start,
showGlowRing: Boolean = true,
description: TextReference? = null,
secondaryButton: TangemMessageBanner.Button? = null,
primaryButton: TangemMessageBanner.Button? = null,
slotStart: (@Composable () -> Unit)? = null,
slotEnd: (@Composable () -> Unit)? = null,
extraBottomSlot: (@Composable ColumnScope.() -> Unit)? = null,
) {
TangemMessageBanner(
modifier = modifier,
variant = variant,
showGlowRing = showGlowRing,
secondaryButton = secondaryButton,
primaryButton = primaryButton,
) {
MessageBannerContentRow(
title = title,
description = description,
contentAlign = contentAlign,
slotStart = slotStart,
slotEnd = slotEnd,
extraBottomSlot = extraBottomSlot,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun MessageBannerContentRow(
title: TextReference,
description: TextReference?,
contentAlign: TangemMessageBanner.ContentAlign,
slotStart: (@Composable () -> Unit)?,
slotEnd: (@Composable () -> Unit)?,
extraBottomSlot: (@Composable ColumnScope.() -> Unit)?,
) {
val textWrapper: @Composable (Modifier) -> Unit = { textModifier ->
MessageBannerTextWrapper(
modifier = textModifier,
title = title,
description = description,
contentAlign = contentAlign,
extraBottomSlot = extraBottomSlot,
)
}
when (contentAlign) {
TangemMessageBanner.ContentAlign.Start -> Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
slotStart?.let { slot -> Box(modifier = Modifier.align(Alignment.Top)) { slot() } }
textWrapper(Modifier.weight(1f).align(Alignment.Top))
slotEnd?.let { slot -> Box(modifier = Modifier.align(Alignment.Top)) { slot() } }
}
TangemMessageBanner.ContentAlign.Center -> Box(modifier = Modifier.fillMaxWidth()) {
textWrapper(Modifier.fillMaxWidth())
slotStart?.let { slot -> Box(modifier = Modifier.align(Alignment.TopStart)) { slot() } }
slotEnd?.let { slot -> Box(modifier = Modifier.align(Alignment.TopEnd)) { slot() } }
}
}
}
@Composable
private fun MessageBannerTextWrapper(
title: TextReference,
description: TextReference?,
contentAlign: TangemMessageBanner.ContentAlign,
extraBottomSlot: (@Composable ColumnScope.() -> Unit)?,
modifier: Modifier = Modifier,
) {
val isCenter = contentAlign == TangemMessageBanner.ContentAlign.Center
val textAlign = if (isCenter) TextAlign.Center else TextAlign.Start
Column(
modifier = if (isCenter) modifier.padding(horizontal = 32.dp) else modifier,
horizontalAlignment = if (isCenter) Alignment.CenterHorizontally else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title.resolveAnnotatedReference(),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
textAlign = textAlign,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (description != null) {
Text(
text = description.resolveAnnotatedReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = textAlign,
)
}
extraBottomSlot?.let { slot ->
Column(modifier = Modifier.padding(top = 12.dp)) { slot() }
}
}
}
@Composable
private fun MessageBannerButtons(
secondaryButton: TangemMessageBanner.Button?,
primaryButton: TangemMessageBanner.Button?,
) {
if (secondaryButton == null && primaryButton == null) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
secondaryButton?.let { button ->
TangemButton(
modifier = Modifier.weight(1f),
variant = TangemButton.Variant.Secondary,
text = button.text,
iconStart = button.iconStart,
iconEnd = button.iconEnd,
isEnabled = button.isEnabled,
isLoading = button.isLoading,
onClick = button.onClick,
)
}
primaryButton?.let { button ->
TangemButton(
modifier = Modifier.weight(1f),
variant = TangemButton.Variant.Primary,
text = button.text,
iconStart = button.iconStart,
iconEnd = button.iconEnd,
isEnabled = button.isEnabled,
isLoading = button.isLoading,
onClick = button.onClick,
)
}
}
}
/** Public API surface of [TangemMessageBanner]. */
object TangemMessageBanner {
/** Visual appearance — background color + glow ring color. */
enum class Variant {
/** Neutral opaque background with a multi-color "magic" glow ring. */
Default,
/** Neutral tertiary (filled) background with a multi-color "magic" glow ring. */
Solid,
/** Subtle success-green background and matching glow ring. */
Success,
/** Subtle error-red background and matching glow ring. */
Error,
/** Subtle warning-yellow background and matching glow ring. */
Warning,
/** Subtle info-blue background and matching glow ring. */
Info,
}
/** Horizontal alignment of the text block. */
enum class ContentAlign {
Start,
Center,
}
/** An action button shown in the banner's button row. */
@Immutable
data class Button(
val text: TextReference,
val onClick: () -> Unit,
val iconStart: TangemIconUM? = null,
val iconEnd: TangemIconUM? = null,
val isEnabled: Boolean = true,
val isLoading: Boolean = false,
)
}
/**
* Dismiss-button preset for [TangemMessageBanner] a filled cross-circle to pass as `slotEnd`.
*
* @param contentDescription Accessibility label announced by TalkBack (e.g. `"Dismiss"`).
*/
@Composable
fun TangemMessageBanner.CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
Icon(
imageVector = Icons.ic_cross_circle_20_filled,
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = modifier
.size(20.dp)
.clip(RoundedCornerShape(percent = 50))
.clickableSingle(onClick = onClick)
.semantics {
role = Role.Button
contentDescription?.let { this.contentDescription = it }
},
)
}
/** Resolved appearance tokens for a [TangemMessageBanner.Variant]. */
private data class MessageBannerTokens(val background: Color, val glowRing: TangemGlowRing.Variant)
@Composable
@ReadOnlyComposable
private fun TangemMessageBanner.Variant.tokens(): MessageBannerTokens {
val colors = TangemTheme.colors3
return when (this) {
TangemMessageBanner.Variant.Default -> MessageBannerTokens(
background = colors.bg.opaque.primary,
glowRing = TangemGlowRing.Variant.Magic,
)
TangemMessageBanner.Variant.Solid -> MessageBannerTokens(
background = colors.bg.tertiary,
glowRing = TangemGlowRing.Variant.Magic,
)
TangemMessageBanner.Variant.Success -> MessageBannerTokens(
background = colors.bg.status.successSubtle,
glowRing = TangemGlowRing.Variant.Success,
)
TangemMessageBanner.Variant.Error -> MessageBannerTokens(
background = colors.bg.status.errorSubtle,
glowRing = TangemGlowRing.Variant.Error,
)
TangemMessageBanner.Variant.Warning -> MessageBannerTokens(
background = colors.bg.status.warningSubtle,
glowRing = TangemGlowRing.Variant.Warning,
)
TangemMessageBanner.Variant.Info -> MessageBannerTokens(
background = colors.bg.status.infoSubtle,
glowRing = TangemGlowRing.Variant.Info,
)
}
}
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemMessageBannerPreview() {
PreviewContainer {
TangemMessageBanner(
modifier = Modifier.fillMaxWidth(),
title = stringReference("Title"),
description = stringReference("Description"),
slotEnd = { TangemMessageBanner.CloseButton(onClick = {}, contentDescription = "Dismiss") },
secondaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}),
primaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}),
)
TangemMessageBanner(
modifier = Modifier.fillMaxWidth(),
title = stringReference("Invite friends. Earn 10 USDT."),
description = stringReference("Share Tangem, give 10% OFF, and earn 10 USDT."),
slotStart = {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(percent = 50))
.background(TangemTheme.colors3.bg.tertiary),
)
},
primaryButton = TangemMessageBanner.Button(text = stringReference("Invite friends"), onClick = {}),
)
}
}
@Preview(name = "Variants Light", showBackground = true)
@Preview(name = "Variants Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemMessageBannerVariantsPreview() {
PreviewContainer {
TangemMessageBanner.Variant.entries.forEach { variant ->
TangemMessageBanner(
modifier = Modifier.fillMaxWidth(),
variant = variant,
title = stringReference(variant.name),
description = stringReference("Description"),
secondaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}),
primaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}),
)
}
}
}
@Preview(name = "Align Light", showBackground = true)
@Preview(name = "Align Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemMessageBannerContentAlignPreview() {
PreviewContainer {
TangemMessageBanner.ContentAlign.entries.forEach { align ->
TangemMessageBanner(
modifier = Modifier.fillMaxWidth(),
contentAlign = align,
title = stringReference("Content align ${align.name}"),
description = stringReference("Share Tangem, give 10% OFF, and earn 10 USDT."),
secondaryButton = TangemMessageBanner.Button(text = stringReference("Later"), onClick = {}),
primaryButton = TangemMessageBanner.Button(text = stringReference("Invite"), onClick = {}),
)
}
}
}
@Composable
private fun PreviewContainer(content: @Composable ColumnScope.() -> Unit) {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.core.ui.ds2.glowring.TangemGlowRing
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
@ -371,6 +372,40 @@ internal data class TangemGlowRingStory(
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemMessageBannerStory(
val variant: TangemMessageBanner.Variant,
val contentAlign: TangemMessageBanner.ContentAlign,
val hasGlowRing: Boolean,
val hasDescription: Boolean,
val hasSecondaryButton: Boolean,
val hasPrimaryButton: Boolean,
val hasCloseButton: Boolean,
val hasSlotStart: Boolean,
val hasSlotEnd: Boolean,
val hasExtraContent: Boolean,
val background: Background,
val onVariantChange: (TangemMessageBanner.Variant) -> Unit,
val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit,
val onGlowRingToggle: () -> Unit,
val onDescriptionToggle: () -> Unit,
val onSecondaryButtonToggle: () -> Unit,
val onPrimaryButtonToggle: () -> Unit,
val onCloseButtonToggle: () -> Unit,
val onSlotStartToggle: () -> Unit,
val onSlotEndToggle: () -> Unit,
val onExtraContentToggle: () -> Unit,
val onBackgroundChange: (Background) -> Unit,
) : DsStoryBookPage {
/** Backdrop the banner preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -22,6 +22,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.tangem
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.tangemGlowRingStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.tangemMessageBannerStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
@ -41,6 +42,7 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory),
DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory),
DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory),
DsStoryItem(title = "📢 TangemMessageBanner", factory = tangemMessageBannerStoryFactory),
)
@Composable

View file

@ -0,0 +1,37 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory.Background
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemMessageBannerStory>.build(): TangemMessageBannerStory {
return TangemMessageBannerStory(
variant = TangemMessageBanner.Variant.Default,
contentAlign = TangemMessageBanner.ContentAlign.Start,
hasGlowRing = true,
hasDescription = true,
hasSecondaryButton = true,
hasPrimaryButton = true,
hasCloseButton = true,
hasSlotStart = true,
hasSlotEnd = true,
hasExtraContent = true,
background = Background.BgSecondary,
onVariantChange = { variant -> updateStory { it.copy(variant = variant) } },
onContentAlignChange = { align -> updateStory { it.copy(contentAlign = align) } },
onGlowRingToggle = { updateStory { it.copy(hasGlowRing = !it.hasGlowRing) } },
onDescriptionToggle = { updateStory { it.copy(hasDescription = !it.hasDescription) } },
onSecondaryButtonToggle = { updateStory { it.copy(hasSecondaryButton = !it.hasSecondaryButton) } },
onPrimaryButtonToggle = { updateStory { it.copy(hasPrimaryButton = !it.hasPrimaryButton) } },
onCloseButtonToggle = { updateStory { it.copy(hasCloseButton = !it.hasCloseButton) } },
onSlotStartToggle = { updateStory { it.copy(hasSlotStart = !it.hasSlotStart) } },
onSlotEndToggle = { updateStory { it.copy(hasSlotEnd = !it.hasSlotEnd) } },
onExtraContentToggle = { updateStory { it.copy(hasExtraContent = !it.hasExtraContent) } },
onBackgroundChange = { background -> updateStory { it.copy(background = background) } },
)
}
internal val tangemMessageBannerStoryFactory
get() = storyPageFactory(StateUpdater<TangemMessageBannerStory>::build)

View file

@ -0,0 +1,325 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory.Background
@Composable
internal fun TangemMessageBannerStory(state: TangemMessageBannerStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
ContentAlignSelector(selected = state.contentAlign, onSelect = state.onContentAlignChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
Toggles(state = state)
}
}
}
@Composable
private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) {
when (background) {
Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary))
Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary))
Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse))
}
}
@Composable
private fun ComponentPreview(state: TangemMessageBannerStory) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp)),
) {
PreviewBackground(background = state.background, modifier = Modifier.matchParentSize())
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
PreviewBanner(state = state)
}
}
}
@Composable
private fun PreviewBanner(state: TangemMessageBannerStory) {
TangemMessageBanner(
modifier = Modifier.fillMaxWidth(),
variant = state.variant,
contentAlign = state.contentAlign,
showGlowRing = state.hasGlowRing,
title = stringReference("Would you predict?"),
description = if (state.hasDescription) {
stringReference("France will win FIFA 2026")
} else {
null
},
secondaryButton = if (state.hasSecondaryButton) {
TangemMessageBanner.Button(text = stringReference("Yes"), onClick = {})
} else {
null
},
primaryButton = if (state.hasPrimaryButton) {
TangemMessageBanner.Button(text = stringReference("Oh, yes"), onClick = {})
} else {
null
},
slotStart = if (state.hasSlotStart) {
{ BannerLeadingIcon() }
} else {
null
},
slotEnd = when {
state.hasCloseButton -> {
{ TangemMessageBanner.CloseButton(onClick = {}, contentDescription = "Dismiss") }
}
state.hasSlotEnd -> {
{ CirclePlaceholder(size = 24.dp) }
}
else -> null
},
extraBottomSlot = if (state.hasExtraContent) {
{ ProtectedByRow() }
} else {
null
},
)
}
@Composable
private fun BannerLeadingIcon() {
Image(
painter = painterResource(R.drawable.img_solana_22),
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(percent = 50)),
)
}
@Composable
private fun ProtectedByRow() {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Protected by Tangem Security",
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
Icon(
painter = painterResource(R.drawable.ic_shield_check_16),
contentDescription = null,
tint = TangemTheme.colors3.icon.status.info,
)
}
}
@Composable
private fun CirclePlaceholder(size: Dp) {
Box(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(percent = 50))
.background(TangemTheme.colors3.bg.tertiary),
)
}
@Composable
private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) {
Section(label = "Background") {
ChipGrid(
items = Background.entries,
label = { it.label },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun VariantSelector(selected: TangemMessageBanner.Variant, onSelect: (TangemMessageBanner.Variant) -> Unit) {
Section(label = "Variant") {
ChipGrid(
items = TangemMessageBanner.Variant.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun ContentAlignSelector(
selected: TangemMessageBanner.ContentAlign,
onSelect: (TangemMessageBanner.ContentAlign) -> Unit,
) {
Section(label = "Content align") {
ChipGrid(
items = TangemMessageBanner.ContentAlign.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun Toggles(state: TangemMessageBannerStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "glowRing", checked = state.hasGlowRing, onToggle = state.onGlowRingToggle)
ToggleRow(label = "description", checked = state.hasDescription, onToggle = state.onDescriptionToggle)
ToggleRow(
label = "secondaryButton",
checked = state.hasSecondaryButton,
onToggle = state.onSecondaryButtonToggle,
)
ToggleRow(label = "primaryButton", checked = state.hasPrimaryButton, onToggle = state.onPrimaryButtonToggle)
ToggleRow(label = "closeButton", checked = state.hasCloseButton, onToggle = state.onCloseButtonToggle)
ToggleRow(label = "slotStart", checked = state.hasSlotStart, onToggle = state.onSlotStartToggle)
ToggleRow(label = "slotEnd", checked = state.hasSlotEnd, onToggle = state.onSlotEndToggle)
ToggleRow(label = "extraContent", checked = state.hasExtraContent, onToggle = state.onExtraContentToggle)
}
}
}
@Composable
private fun Section(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = label,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
content()
}
}
@Composable
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEach { item ->
Chip(
label = label(item),
selected = isSelected(item),
onClick = { onSelect(item) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else Color.Transparent,
)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
Text(
text = label,
style = TangemTheme.typography.caption2,
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(TangemTheme.colors2.surface.level2)
.clickable(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = if (checked) "ON" else "OFF",
style = TangemTheme.typography.caption2,
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
)
}
}

View file

@ -44,6 +44,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.Tangem
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.TangemGlowRingStory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.TangemMessageBannerStory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
@ -103,6 +104,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemShimmerStory -> TangemShimmerStory(state = storyState)
is TangemFadeStory -> TangemFadeStory(state = storyState)
is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState)
is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState)
}
}
}