From b39699ad041f1938d3f0afcf17c4b38aebd665af Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 17:18:33 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../TangemMessageBannerNotification.kt | 96 ++++ .../ds2/messagebanner/TangemMessageBanner.kt | 436 ++++++++++++++++++ .../storybook/entity/StoryBookPage.kt | 35 ++ .../page/ds/DsComponentsListScreen.kt | 2 + .../storybook/page/ds/messagebanner/Build.kt | 37 ++ .../messagebanner/TangemMessageBannerStory.kt | 325 +++++++++++++ .../storybook/ui/StoryBookScreen.kt | 2 + 7 files changed, 933 insertions(+) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/TangemMessageBannerStory.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt new file mode 100644 index 0000000000..f24a7802c3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt @@ -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 = + 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 + } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt new file mode 100644 index 0000000000..b186ea78cd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index b380d317b6..c2cb7afe7d 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -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, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index fc0a092152..8fb85b511b 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -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 diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/Build.kt new file mode 100644 index 0000000000..172cac1142 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/Build.kt @@ -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.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::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/TangemMessageBannerStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/TangemMessageBannerStory.kt new file mode 100644 index 0000000000..67990c2159 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/TangemMessageBannerStory.kt @@ -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 ChipGrid(items: List, 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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index be9720bd99..79dd299759 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -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) } } } \ No newline at end of file