Updated on 2026-08-14
This commit is contained in:
parent
a9b5490cc4
commit
f58a422765
10 changed files with 888 additions and 6 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue