diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt index 3753756a61..105ff25790 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/haze/HazeExt.kt @@ -24,6 +24,20 @@ internal fun ProvideHaze(content: @Composable () -> Unit) { } } +/** + * Returns whether the haze blur effect would actually render for the given [state], taking both + * the global [HazeState.blurEnabled] flag and the device's power-saving mode into account. + * + * Callers that pass a fully-transparent fallback to [hazeEffectTangem] should use this to decide + * whether they need to render an opaque fallback layer themselves — otherwise the surface can + * become invisible whenever blur is disabled (e.g. while power-saving mode is on). + */ +@Composable +fun isHazeBlurEffectivelyEnabled(state: HazeState = LocalHazeState.current): Boolean { + val isPowerSavingEnabled by LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState() + return state.blurEnabled && !isPowerSavingEnabled +} + /** * Applies a haze effect to the [Modifier] with consideration of global haze settings and power saving mode. * @@ -36,8 +50,7 @@ fun Modifier.hazeEffectTangem( style: HazeStyle = HazeStyle.Unspecified, configure: HazeEffectScope.() -> Unit = {}, ): Modifier { - val powerSavingEnabled = LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState() - val isGlobalBlurEnabled = state.blurEnabled && !powerSavingEnabled.value + val isGlobalBlurEnabled = isHazeBlurEffectivelyEnabled(state) val rootBackground by LocalRootBackgroundColor.current return hazeEffect(state, style) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/badge/TangemBadge.kt new file mode 100644 index 0000000000..9d7cbf9a37 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/badge/TangemBadge.kt @@ -0,0 +1,405 @@ +package com.tangem.core.ui.ds2.badge + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.surface.TangemSurface +import com.tangem.core.ui.extensions.ColorReference2 +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** + * Design-system v2 badge: a compact pill displaying a short label with optional leading / trailing + * icons. + * + * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=2002-213) + * + * Behavior notes: + * - Shape is always a fully-rounded pill (`borderRadius.full`). + * - Icon tints are driven by [variant] + [status]; any tint set on a supplied [TangemIconUM.Icon] + * is overridden. Other [TangemIconUM] subtypes (e.g. currency / image / url) pass through with + * their own colors intact. + * - The badge is non-interactive by default. Pass [onClick] to make it clickable. + * + * @param text Badge label. + * @param modifier Modifier applied to the badge container. + * @param variant Visual style. See [TangemBadge.Variant]. + * @param status Status color scheme (Neutral / Info / Error / Success / Warning). + * @param size Token-driven size preset controlling height, padding, icon size and text style. + * See [TangemBadge.Size]. + * @param iconStart Optional leading icon. + * @param iconEnd Optional trailing icon. + * @param contentDescription Accessibility label announced by TalkBack. When non-null it overrides + * the label text for screen readers. + * @param onClick Optional click handler. `null` makes the badge non-interactive. + */ +@Suppress("LongParameterList") +@Composable +fun TangemBadge( + text: TextReference, + modifier: Modifier = Modifier, + variant: TangemBadge.Variant = TangemBadge.Variant.Tinted, + status: TangemBadge.Status = TangemBadge.Status.Neutral, + size: TangemBadge.Size = TangemBadge.Size.X9, + iconStart: TangemIconUM? = null, + iconEnd: TangemIconUM? = null, + contentDescription: String? = null, + onClick: (() -> Unit)? = null, +) { + val colorTokens = resolveColorTokens(variant = variant, status = status) + val sizeTokens = size.tokens() + + TangemSurface( + modifier = modifier + .semantics(mergeDescendants = true) { + if (onClick != null) role = Role.Button + contentDescription?.let { this.contentDescription = it } + } + .heightIn(min = sizeTokens.minHeight), + onClick = onClick, + color = colorTokens.backgroundColor, + border = colorTokens.borderColor?.let { BorderStroke(TangemTheme.dimens3.borderWidth.sm, it) }, + shape = RoundedCornerShape(TangemTheme.dimens3.borderRadius.full), + ) { + BadgeContent( + iconStart = iconStart, + iconEnd = iconEnd, + text = text, + colorTokens = colorTokens, + sizeTokens = sizeTokens, + ) + } +} + +@Composable +private fun BadgeContent( + iconStart: TangemIconUM?, + iconEnd: TangemIconUM?, + text: TextReference, + colorTokens: BadgeColorTokens, + sizeTokens: BadgeSizeTokens, +) { + Row( + modifier = Modifier + .heightIn(min = sizeTokens.minHeight) + .padding( + horizontal = sizeTokens.containerHorizontalPadding, + vertical = sizeTokens.containerVerticalPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + iconStart?.let { icon -> + TangemIcon( + modifier = Modifier.size(sizeTokens.iconSize), + tangemIconUM = icon.applyTint(colorTokens.iconTint), + ) + } + Text( + modifier = Modifier.padding(horizontal = sizeTokens.labelPadding), + text = text.resolveReference(), + color = colorTokens.textColor, + style = sizeTokens.textStyle, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + iconEnd?.let { icon -> + TangemIcon( + modifier = Modifier.size(sizeTokens.iconSize), + tangemIconUM = icon.applyTint(colorTokens.iconTint), + ) + } + } +} + +/** + * Forces [tint] onto [TangemIconUM.Icon] so the badge's variant always drives icon color. Other + * icon types (currency, image, url) pass through unchanged so they keep their own visuals. + */ +private fun TangemIconUM.applyTint(tint: Color): TangemIconUM = when (this) { + is TangemIconUM.Icon -> copy(tint = ColorReference2 { tint }) + else -> this +} + +object TangemBadge { + + /** + * Visual style of the badge. + * + * - [Tinted] — soft tinted fill (subtle status color or opaque neutral), no border. + * - [Outline] — tinted fill with a matching border. + * - [Solid] — saturated status color fill with static-dark content. + */ + enum class Variant { + Tinted, + Outline, + Solid, + } + + /** Status color scheme of the badge. */ + enum class Status { + Neutral, + Info, + Error, + Success, + Warning, + } + + /** + * Size preset. Names follow the design-system size scale: X9 is the largest (min height 36dp), + * X4 is the smallest (min height 16dp). + */ + enum class Size { + X9, + X6, + X4, + } +} + +/** Resolved colors for a (variant, status) pair. */ +private data class BadgeColorTokens( + val backgroundColor: Color, + val textColor: Color, + val iconTint: Color, + val borderColor: Color? = null, +) + +/** Resolved per-size dimensions used by [TangemBadge]. */ +private data class BadgeSizeTokens( + val minHeight: Dp, + val containerHorizontalPadding: Dp, + val containerVerticalPadding: Dp, + val labelPadding: Dp, + val iconSize: Dp, + val textStyle: TextStyle, +) + +@Composable +@ReadOnlyComposable +private fun TangemBadge.Size.tokens(): BadgeSizeTokens { + val dimens = TangemTheme.dimens3 + val typography = TangemTheme.typography3 + return when (this) { + TangemBadge.Size.X9 -> BadgeSizeTokens( + minHeight = dimens.size.s450, + containerHorizontalPadding = dimens.spacing.s100, + containerVerticalPadding = dimens.spacing.s100, + labelPadding = dimens.spacing.s050, + iconSize = 20.dp, + textStyle = typography.subheading.medium, + ) + TangemBadge.Size.X6 -> BadgeSizeTokens( + minHeight = dimens.size.s300, + containerHorizontalPadding = dimens.spacing.s050, + containerVerticalPadding = dimens.spacing.s050, + labelPadding = dimens.spacing.s050, + iconSize = 16.dp, + textStyle = typography.caption.medium, + ) + TangemBadge.Size.X4 -> BadgeSizeTokens( + minHeight = dimens.size.s200, + containerHorizontalPadding = dimens.spacing.s025, + containerVerticalPadding = dimens.spacing.none, + labelPadding = dimens.spacing.s025, + iconSize = 12.dp, + textStyle = typography.caption.medium, + ) + } +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +@ReadOnlyComposable +private fun resolveColorTokens(variant: TangemBadge.Variant, status: TangemBadge.Status): BadgeColorTokens { + val colors = TangemTheme.colors3 + return when (variant) { + TangemBadge.Variant.Tinted -> when (status) { + TangemBadge.Status.Neutral -> BadgeColorTokens( + backgroundColor = colors.bg.opaque.primary, + textColor = colors.text.secondary, + iconTint = colors.icon.secondary, + ) + TangemBadge.Status.Info -> BadgeColorTokens( + backgroundColor = colors.bg.status.infoSubtle, + textColor = colors.text.status.info, + iconTint = colors.icon.status.info, + ) + TangemBadge.Status.Error -> BadgeColorTokens( + backgroundColor = colors.bg.status.errorSubtle, + textColor = colors.text.status.error, + iconTint = colors.icon.status.error, + ) + TangemBadge.Status.Success -> BadgeColorTokens( + backgroundColor = colors.bg.status.successSubtle, + textColor = colors.text.status.success, + iconTint = colors.icon.status.success, + ) + TangemBadge.Status.Warning -> BadgeColorTokens( + backgroundColor = colors.bg.status.warningSubtle, + textColor = colors.text.status.warning, + iconTint = colors.icon.status.warning, + ) + } + TangemBadge.Variant.Outline -> when (status) { + TangemBadge.Status.Neutral -> BadgeColorTokens( + backgroundColor = colors.bg.opaque.primary, + textColor = colors.text.secondary, + iconTint = colors.icon.secondary, + borderColor = colors.border.primary, + ) + TangemBadge.Status.Info -> BadgeColorTokens( + backgroundColor = colors.bg.status.infoSubtle, + textColor = colors.text.status.info, + iconTint = colors.icon.status.info, + borderColor = colors.border.status.infoSubtle, + ) + TangemBadge.Status.Error -> BadgeColorTokens( + backgroundColor = colors.bg.status.errorSubtle, + textColor = colors.text.status.error, + iconTint = colors.icon.status.error, + borderColor = colors.border.status.errorSubtle, + ) + TangemBadge.Status.Success -> BadgeColorTokens( + backgroundColor = colors.bg.status.successSubtle, + textColor = colors.text.status.success, + iconTint = colors.icon.status.success, + borderColor = colors.border.status.successSubtle, + ) + TangemBadge.Status.Warning -> BadgeColorTokens( + backgroundColor = colors.bg.status.warningSubtle, + textColor = colors.text.status.warning, + iconTint = colors.icon.status.warning, + borderColor = colors.border.status.warningSubtle, + ) + } + TangemBadge.Variant.Solid -> when (status) { + TangemBadge.Status.Neutral -> BadgeColorTokens( + backgroundColor = colors.bg.opaque.primary, + textColor = colors.text.primary, + iconTint = colors.icon.primary, + ) + TangemBadge.Status.Info -> BadgeColorTokens( + backgroundColor = colors.bg.status.info, + textColor = colors.text.staticDark.primary, + iconTint = colors.icon.staticDark, + ) + TangemBadge.Status.Error -> BadgeColorTokens( + backgroundColor = colors.bg.status.error, + textColor = colors.text.staticDark.primary, + iconTint = colors.icon.staticDark, + ) + TangemBadge.Status.Success -> BadgeColorTokens( + backgroundColor = colors.bg.status.success, + textColor = colors.text.staticDark.primary, + iconTint = colors.icon.staticDark, + ) + TangemBadge.Status.Warning -> BadgeColorTokens( + backgroundColor = colors.bg.status.warning, + textColor = colors.text.staticDark.primary, + iconTint = colors.icon.staticDark, + ) + } + } +} + +// region Previews + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemBadgePreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TangemBadge.Variant.entries.forEach { variant -> + PreviewVariantBlock(variant = variant) + } + PreviewSizesBlock() + } + } +} + +@Composable +private fun PreviewVariantBlock(variant: TangemBadge.Variant) { + val icon = remember { TangemIconUM.Icon(iconRes = R.drawable.ic_information_24) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = variant.name, + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.body.medium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemBadge.Status.entries.forEach { status -> + TangemBadge( + text = stringReference(status.name), + variant = variant, + status = status, + iconStart = icon, + ) + } + } + } +} + +@Composable +private fun PreviewSizesBlock() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.widthIn(min = 72.dp), + text = "Sizes (Tinted / Info)", + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.body.medium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemBadge.Size.entries.forEach { size -> + TangemBadge( + text = stringReference(size.name), + variant = TangemBadge.Variant.Tinted, + status = TangemBadge.Status.Info, + size = size, + ) + } + } + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt index 0bf4cac670..f93baaa8d3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt @@ -25,9 +25,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.isHazeBlurEffectivelyEnabled import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.extensions.softLayerShadow -import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.TangemTheme import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeTint @@ -133,9 +133,15 @@ private fun Modifier.materialBorder(shape: Shape): Modifier = border( * * When the haze state is enabled, paints a haze-blurred backdrop. When disabled, layers two * solid colors so the result still reads as "tinted fill" instead of going transparent. + * + * Uses [isHazeBlurEffectivelyEnabled] (rather than [LocalHazeState]'s `blurEnabled` directly) so + * the solid fallback is also applied when blur is suppressed for reasons other than the haze flag + * — most notably when the device is in power-saving mode. Otherwise the haze modifier's + * `fallbackTint = HazeTint(Color.Transparent)` would leave the surface fully transparent. */ @Composable private fun Modifier.materialFill(): Modifier { + val isBlurEnabled = isHazeBlurEffectivelyEnabled() val hazed = hazeEffectTangem( style = HazeStyle( backgroundColor = TangemTheme.colors3.material.fill.blur, @@ -145,7 +151,7 @@ private fun Modifier.materialFill(): Modifier { ) { fallbackTint = HazeTint(Color.Transparent) } - return hazed.conditionalCompose(!LocalHazeState.current.blurEnabled) { + return hazed.conditionalCompose(!isBlurEnabled) { // Paint the opaque fill first, then layer the translucent tint on top so both are visible. background(TangemTheme.colors3.material.fill.solid) .background(TangemTheme.colors3.material.tint.solid) 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 279dcd95a1..9d3176ee99 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 @@ -2,6 +2,7 @@ package com.tangem.feature.tester.presentation.storybook.entity import com.tangem.core.ui.ds.badge.TangemBadgeColor import com.tangem.core.ui.ds.field.search.TangemFieldShape +import com.tangem.core.ui.ds2.badge.TangemBadge import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds.message.TangemMessageEffect @@ -106,6 +107,7 @@ internal data class TangemLoaderStory( internal data class TangemButtonStory( val variant: TangemButton.Variant, val size: TangemButton.Size, + val background: Background, val isLoading: Boolean, val isEnabled: Boolean, val hasIconStart: Boolean, @@ -115,6 +117,7 @@ internal data class TangemButtonStory( val textScale: Float, val onVariantChange: (TangemButton.Variant) -> Unit, val onSizeChange: (TangemButton.Size) -> Unit, + val onBackgroundChange: (Background) -> Unit, val onLoadingToggle: () -> Unit, val onEnabledToggle: () -> Unit, val onIconStartToggle: () -> Unit, @@ -122,4 +125,41 @@ internal data class TangemButtonStory( val onTextToggle: () -> Unit, val onBlurToggle: () -> Unit, val onTextScaleChange: (Float) -> Unit, -) : DsStoryBookPage \ No newline at end of file +) : DsStoryBookPage { + + /** Backdrop the button preview is rendered on top of. */ + enum class Background(val label: String) { + Rainbow("rainbow"), + BgPrimary("bg.primary"), + BgSecondary("bg.secondary"), + BgBrand("bg.brand"), + BgInverse("bg.inverse"), + } +} + +internal data class TangemBadgeV2Story( + val variant: TangemBadge.Variant, + val status: TangemBadge.Status, + val size: TangemBadge.Size, + val background: Background, + val hasIconStart: Boolean, + val hasIconEnd: Boolean, + val textScale: Float, + val onVariantChange: (TangemBadge.Variant) -> Unit, + val onStatusChange: (TangemBadge.Status) -> Unit, + val onSizeChange: (TangemBadge.Size) -> Unit, + val onBackgroundChange: (Background) -> Unit, + val onIconStartToggle: () -> Unit, + val onIconEndToggle: () -> Unit, + val onTextScaleChange: (Float) -> Unit, +) : DsStoryBookPage { + + /** Backdrop the badge preview is rendered on top of. */ + enum class Background(val label: String) { + Rainbow("rainbow"), + BgPrimary("bg.primary"), + BgSecondary("bg.secondary"), + BgBrand("bg.brand"), + BgInverse("bg.inverse"), + } +} \ No newline at end of file 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 ae9037594f..05627e984a 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 @@ -15,6 +15,7 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListStory import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.badge.tangemBadgeV2StoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemButtonStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory @@ -23,6 +24,7 @@ private data class DsStoryItem(val title: String, val factory: StoryPageFactory) private fun buildDsStories() = listOf( DsStoryItem(title = "⏳ TangemLoader", factory = tangemLoaderStoryFactory), DsStoryItem(title = "🔘 TangemButton", factory = tangemButtonStoryFactory), + DsStoryItem(title = "🏷️ TangemBadge", factory = tangemBadgeV2StoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/Build.kt new file mode 100644 index 0000000000..bbd8963686 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/Build.kt @@ -0,0 +1,42 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.badge + +import com.tangem.core.ui.ds2.badge.TangemBadge +import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeV2Story +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemBadgeV2Story { + return TangemBadgeV2Story( + variant = TangemBadge.Variant.Tinted, + status = TangemBadge.Status.Info, + size = TangemBadge.Size.X9, + background = TangemBadgeV2Story.Background.BgPrimary, + hasIconStart = false, + hasIconEnd = false, + textScale = 1f, + onVariantChange = { variant -> + updateStory { it.copy(variant = variant) } + }, + onStatusChange = { status -> + updateStory { it.copy(status = status) } + }, + onSizeChange = { size -> + updateStory { it.copy(size = size) } + }, + onBackgroundChange = { background -> + updateStory { it.copy(background = background) } + }, + onIconStartToggle = { + updateStory { it.copy(hasIconStart = !it.hasIconStart) } + }, + onIconEndToggle = { + updateStory { it.copy(hasIconEnd = !it.hasIconEnd) } + }, + onTextScaleChange = { scale -> + updateStory { it.copy(textScale = scale) } + }, + ) +} + +internal val tangemBadgeV2StoryFactory + 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/badge/TangemBadgeV2Story.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt new file mode 100644 index 0000000000..78539107a0 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt @@ -0,0 +1,341 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.badge + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.shape.RoundedCornerShape +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.badge.TangemBadge +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeV2Story +import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeV2Story.Background + +@Composable +internal fun TangemBadgeV2Story(state: TangemBadgeV2Story, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ComponentPreview(state = state) + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + StatusSelector(selected = state.status, onSelect = state.onStatusChange) + SizeSelector(selected = state.size, onSelect = state.onSizeChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange) + Toggles(state = state) + } +} + +@Composable +private fun BlurTestBackground(modifier: Modifier = Modifier) { + val bands = remember { + listOf( + Color(0xFFFF1744), // red + Color(0xFFFF9100), // orange + Color(0xFFFFEA00), // yellow + Color(0xFF00E676), // green + Color(0xFF00B8D4), // cyan + Color(0xFF2962FF), // blue + Color(0xFFD500F9), // magenta + ) + } + val stops = remember(bands) { + buildList { + bands.forEachIndexed { index, color -> + val start = index.toFloat() / bands.size + val end = (index + 1).toFloat() / bands.size + add(start to color) + add(end to color) + } + }.toTypedArray() + } + val tilePx = with(LocalDensity.current) { 320.dp.toPx() } + val transition = rememberInfiniteTransition(label = "badge-blur-bg") + val offset by transition.animateFloat( + initialValue = 0f, + targetValue = tilePx, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 4_000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "badge-blur-bg-offset", + ) + Box( + modifier = modifier.background( + brush = Brush.linearGradient( + colorStops = stops, + start = Offset(offset, 0f), + end = Offset(offset + tilePx, 0f), + tileMode = TileMode.Repeated, + ), + ), + ) +} + +@Composable +private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) { + when (background) { + Background.Rainbow -> BlurTestBackground(modifier = modifier) + Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary)) + Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary)) + Background.BgBrand -> Box(modifier.background(TangemTheme.colors3.bg.brand)) + Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse)) + } +} + +@Composable +private fun ComponentPreview(state: TangemBadgeV2Story) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)), + ) { + PreviewBackground( + background = state.background, + modifier = Modifier + .matchParentSize() + .hazeSourceTangem(zIndex = 0f), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + ) { + val baseDensity = LocalDensity.current + val scaledDensity = remember(baseDensity, state.textScale) { + Density(density = baseDensity.density, fontScale = state.textScale) + } + CompositionLocalProvider(LocalDensity provides scaledDensity) { + TangemBadge( + text = stringReference("Label"), + variant = state.variant, + status = state.status, + size = state.size, + iconStart = if (state.hasIconStart) { + TangemIconUM.Icon(iconRes = R.drawable.ic_information_24) + } else { + null + }, + iconEnd = if (state.hasIconEnd) { + TangemIconUM.Icon(iconRes = R.drawable.ic_information_24) + } else { + null + }, + ) + } + } + } +} + +@Composable +private fun VariantSelector(selected: TangemBadge.Variant, onSelect: (TangemBadge.Variant) -> Unit) { + Section(label = "Variant") { + ChipGrid( + items = TangemBadge.Variant.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun StatusSelector(selected: TangemBadge.Status, onSelect: (TangemBadge.Status) -> Unit) { + Section(label = "Status") { + ChipGrid( + items = TangemBadge.Status.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun SizeSelector(selected: TangemBadge.Size, onSelect: (TangemBadge.Size) -> Unit) { + Section(label = "Size") { + ChipGrid( + items = TangemBadge.Size.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@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 TextScaleSlider(value: Float, onChange: (Float) -> Unit) { + Section(label = "Text scale: ${"%.2f".format(value)}x") { + Slider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = value, + onValueChange = onChange, + valueRange = 0.5f..2f, + steps = 14, + colors = SliderDefaults.colors( + thumbColor = TangemTheme.colors.text.accent, + activeTrackColor = TangemTheme.colors.text.accent, + activeTickColor = TangemTheme.colors2.surface.level3, + inactiveTrackColor = TangemTheme.colors2.surface.level3, + inactiveTickColor = TangemTheme.colors.text.accent, + ), + ) + } +} + +@Composable +private fun Toggles(state: TangemBadgeV2Story) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "iconStart", checked = state.hasIconStart, onToggle = state.onIconStartToggle) + ToggleRow(label = "iconEnd", checked = state.hasIconEnd, onToggle = state.onIconEndToggle) + } + } +} + +@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 TangemTheme.colors2.surface.level2, + ) + .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/page/ds/button/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/Build.kt index 1ea2837bcb..f349a83671 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/Build.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/Build.kt @@ -9,6 +9,7 @@ internal fun StateUpdater.build(): TangemButtonStory { return TangemButtonStory( variant = TangemButton.Variant.Primary, size = TangemButton.Size.X10, + background = TangemButtonStory.Background.Rainbow, isLoading = false, isEnabled = true, hasIconStart = false, @@ -22,6 +23,9 @@ internal fun StateUpdater.build(): TangemButtonStory { onSizeChange = { size -> updateStory { it.copy(size = size) } }, + onBackgroundChange = { background -> + updateStory { it.copy(background = background) } + }, onLoadingToggle = { updateStory { it.copy(isLoading = !it.isLoading) } }, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt index 1fd6bd5534..a465ac1166 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt @@ -37,6 +37,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.tester.presentation.storybook.entity.TangemButtonStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemButtonStory.Background @Composable internal fun TangemButtonStory(state: TangemButtonStory, modifier: Modifier = Modifier) { @@ -52,6 +53,7 @@ internal fun TangemButtonStory(state: TangemButtonStory, modifier: Modifier = Mo ComponentPreview(state = state) VariantSelector(selected = state.variant, onSelect = state.onVariantChange) SizeSelector(selected = state.size, onSelect = state.onSizeChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange) Toggles(state = state) } @@ -104,6 +106,17 @@ private fun BlurTestBackground(modifier: Modifier = Modifier) { ) } +@Composable +private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) { + when (background) { + Background.Rainbow -> BlurTestBackground(modifier = modifier) + Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary)) + Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary)) + Background.BgBrand -> Box(modifier.background(TangemTheme.colors3.bg.brand)) + Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse)) + } +} + @Composable private fun ComponentPreview(state: TangemButtonStory) { Box( @@ -113,7 +126,8 @@ private fun ComponentPreview(state: TangemButtonStory) { .padding(horizontal = 16.dp) .clip(RoundedCornerShape(16.dp)), ) { - BlurTestBackground( + PreviewBackground( + background = state.background, modifier = Modifier .matchParentSize() .hazeSourceTangem(zIndex = 0f), @@ -176,6 +190,18 @@ private fun SizeSelector(selected: TangemButton.Size, onSelect: (TangemButton.Si } } +@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 TextScaleSlider(value: Float, onChange: (Float) -> Unit) { Section(label = "Text scale: ${"%.2f".format(value)}x") { 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 98fd3302a5..24adf95385 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 @@ -12,6 +12,7 @@ import com.tangem.feature.tester.presentation.storybook.entity.OpportunitiesBGSt import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeV2Story import com.tangem.feature.tester.presentation.storybook.entity.TangemButtonStory import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM import com.tangem.feature.tester.presentation.storybook.entity.StoryList @@ -32,6 +33,7 @@ import com.tangem.feature.tester.presentation.storybook.page.badge.TangemBadgeSt import com.tangem.feature.tester.presentation.storybook.page.buttons.ButtonsStory import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIconStory import com.tangem.feature.tester.presentation.storybook.page.ds.DsComponentsListStory +import com.tangem.feature.tester.presentation.storybook.page.ds.badge.TangemBadgeV2Story import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemButtonStory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory import com.tangem.feature.tester.presentation.storybook.page.opportunities.OpportunitiesBGStory @@ -82,6 +84,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is DsComponentsListStory -> DsComponentsListStory(state = storyState) is TangemLoaderStory -> TangemLoaderStory(state = storyState) is TangemButtonStory -> TangemButtonStory(state = storyState) + is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState) } } } \ No newline at end of file