Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-12 19:12:25 +03:00
parent d5554a7f7d
commit e5475e26e1
13 changed files with 1297 additions and 12 deletions

View file

@ -41,6 +41,7 @@ fun Modifier.hazeEffectTangem(
val rootBackground by LocalRootBackgroundColor.current
return hazeEffect(state, style) {
blurEnabled = isGlobalBlurEnabled
fallbackTint = HazeTint(rootBackground.copy(alpha = 0.5f))
configure()
blurEnabled = blurEnabled && isGlobalBlurEnabled

View file

@ -147,7 +147,7 @@ private fun StartIcon(
is TangemIconUM.Icon -> if (shouldRespectIconTint) {
wrappedIconRes
} else {
wrappedIconRes.copy(tintReference = ColorReference2 { iconColor })
wrappedIconRes.copy(tint = ColorReference2 { iconColor })
}
},
)
@ -180,7 +180,7 @@ private fun EndIcon(
is TangemIconUM.Icon -> if (shouldRespectIconTint) {
wrappedIconRes
} else {
wrappedIconRes.copy(tintReference = ColorReference2 { iconColor })
wrappedIconRes.copy(tint = ColorReference2 { iconColor })
}
},
)

View file

@ -6,13 +6,16 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import arrow.core.Either
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.CircleShimmer
@ -37,14 +40,36 @@ sealed interface TangemIconUM {
) : TangemIconUM
/** Icon represented by a drawable resource. */
@Immutable
data class Icon(
@DrawableRes val iconRes: Int,
val tintReference: ColorReference2 = ColorReference2 { TangemTheme.colors2.graphic.neutral.primary },
) : TangemIconUM
internal val icon: Either<Int, ImageVector>,
val tint: ColorReference2?,
) : TangemIconUM {
constructor(
@DrawableRes iconRes: Int,
tintReference: ColorReference2 = ColorReference2 { TangemTheme.colors2.graphic.neutral.primary },
) : this(Either.Left(iconRes), tintReference)
constructor(
imageVector: ImageVector,
tintReference: ColorReference2? = null,
) : this(Either.Right(imageVector), tintReference)
@Composable
fun imageVector(): ImageVector = icon.fold(
ifLeft = { resId -> ImageVector.vectorResource(resId) },
ifRight = { it },
)
@Composable
@ReadOnlyComposable
fun tintReference() = tint?.invoke() ?: LocalContentColor.current
}
/** Image represented by a drawable resource. */
data class Image(
@DrawableRes val imageRes: Int,
@param:DrawableRes val imageRes: Int,
) : TangemIconUM
/** Identicon represented by a text string (e.g., an address). */
@ -75,7 +100,10 @@ fun TangemIcon(tangemIconUM: TangemIconUM, modifier: Modifier = Modifier) {
)
}
is TangemIconUM.Icon -> Icon(
imageVector = ImageVector.vectorResource(tangemIconUM.iconRes),
imageVector = tangemIconUM.icon.fold(
ifLeft = { resId -> ImageVector.vectorResource(resId) },
ifRight = { it },
),
contentDescription = null,
modifier = modifier,
tint = tangemIconUM.tintReference(),

View file

@ -9,9 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -83,7 +80,7 @@ private fun Content(
endContentUM.startIcons.fastForEach { icon ->
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x3),
painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)),
imageVector = icon.imageVector(),
tint = icon.tintReference(),
contentDescription = null,
)
@ -117,7 +114,7 @@ private fun Content(
endContentUM.endIcons.fastForEach { icon ->
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x3),
painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)),
imageVector = icon.imageVector(),
tint = icon.tintReference(),
contentDescription = null,
)

View file

@ -0,0 +1,251 @@
package com.tangem.core.ui.ds2.button
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
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.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/**
* Design-system v2 button supporting an optional leading icon, label, and trailing icon.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=0-1)
*
* Behavior notes:
* - When [isLoading] is `true` or when no icon and no [text] are supplied the content fades out
* and a centered [com.tangem.core.ui.ds2.loader.TangemLoader] is shown in the variant's icon
* color. Clicks are still routed to [onClick] unless [isEnabled] is `false`.
* - When [text] is `null`, the button renders in icon-only mode (square footprint driven by
* [size]); otherwise its width grows from [TangemButton.Size]'s `minWidth` and the label
* truncates with an ellipsis when it can't fit. Pass `Modifier.fillMaxWidth()` (or any width
* modifier) on [modifier] to switch to a fixed-width layout.
* - [iconStart], [iconEnd], and [text] may be toggled at runtime each slot fades and expands /
* shrinks horizontally so the layout animates smoothly.
* - Icon tints are always driven by [variant] (and swapped for the disabled tint when [isEnabled]
* is `false`); any tint set on the supplied [TangemIconUM.Icon] is ignored.
* - The focus ring is drawn whenever the button is focused, including when [isEnabled] is `false`,
* so disabled buttons remain reachable via keyboard / accessibility focus.
*
* @param variant Visual style. See [TangemButton.Variant].
* @param size Token-driven size preset controlling height, padding, and icon size.
* See [TangemButton.Size].
* @param isLoading When `true`, hides the content and shows a centered loader.
* @param isEnabled When `false`, the button is dimmed by the variant's disabled alpha and clicks
* are ignored.
* @param iconStart Optional leading icon.
* @param iconEnd Optional trailing icon.
* @param text Optional label. `null` switches the button to icon-only mode.
* @param contentDescription Accessibility label announced by TalkBack. Should be supplied for
* icon-only buttons (e.g. `"Transfers"`), for the loading state to describe the action in
* progress (e.g. `"Processing payment"`), and for disabled buttons to explain why they can't be
* activated (e.g. `"Pay is disabled, amount is not filled"`). When non-null it overrides the
* label text for screen readers.
* @param interactionSource Interaction source for press/focus state. A focused state draws the
* variant's focus ring around the button.
* @param onClick Invoked when the button is clicked.
*/
@Composable
fun TangemButton(
modifier: Modifier = Modifier,
variant: TangemButton.Variant = TangemButton.Variant.Primary,
size: TangemButton.Size = TangemButton.Size.X10,
isLoading: Boolean = false,
isEnabled: Boolean = true,
iconStart: TangemIconUM? = null,
iconEnd: TangemIconUM? = null,
text: TextReference? = null,
contentDescription: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onClick: () -> Unit,
) {
val isIconOnly = text == null
val shouldShowLoader = isLoading || iconStart == null && iconEnd == null && text == null
val colorTokens = variant.tokens()
val sizeTokens = size.tokens()
val isFocused by interactionSource.collectIsFocusedAsState()
// Disabled state fades the content + background + default border by `disabledAlpha`, but the
// focus ring stays at full opacity so disabled-but-focused buttons remain clearly highlighted.
val contentAlpha = if (isEnabled) 1f else colorTokens.disabledAlpha
val backgroundColor = (if (isEnabled) colorTokens.backgroundColor else colorTokens.disabledBackgroundColor)
.scaleAlpha(contentAlpha)
TangemSurface(
modifier = modifier
.semantics(mergeDescendants = true) {
role = Role.Button
if (!isEnabled) disabled()
contentDescription?.let { this.contentDescription = it }
},
onClick = onClick,
enabled = isEnabled,
color = backgroundColor,
border = resolveBorder(isFocused = isFocused, colorTokens = colorTokens, contentAlpha = contentAlpha),
shape = RoundedCornerShape(TangemTheme.dimens3.borderRadius.full),
interactionSource = interactionSource,
isMaterial = variant == TangemButton.Variant.Material,
) {
TangemButtonInternal(
modifier = Modifier.alpha(contentAlpha),
isIconOnly = isIconOnly,
isEnabled = isEnabled,
isLoading = shouldShowLoader,
iconStart = iconStart,
iconEnd = iconEnd,
text = text,
colorTokens = colorTokens,
sizeTokens = sizeTokens,
)
}
}
@Composable
private fun resolveBorder(isFocused: Boolean, colorTokens: ColorTokens, contentAlpha: Float): BorderStroke? = when {
isFocused -> BorderStroke(
width = TangemTheme.dimens3.borderWidth.md,
// Focus ring is intentionally NOT scaled by contentAlpha — see TangemButton above.
color = colorTokens.focusRingColor,
)
colorTokens.defaultBorderColor != null -> BorderStroke(
width = TangemTheme.dimens3.borderWidth.sm,
color = colorTokens.defaultBorderColor.scaleAlpha(contentAlpha),
)
else -> null
}
/** Multiplies the existing alpha channel by [factor]. */
private fun Color.scaleAlpha(factor: Float): Color = if (factor == 1f) this else copy(alpha = alpha * factor)
object TangemButton {
/**
* Visual style of the button.
*
* - [Brand] brand-colored background, static-dark content.
* - [Primary] inverse-surface background, used for the dominant call to action.
* - [Secondary] opaque-surface background, used as a secondary action alongside [Primary].
* - [Material] translucent haze fill (rendered by [TangemSurface] when `isMaterial = true`),
* used over content backgrounds.
* - [Success] success-colored background for positive confirmations.
* - [Outline] transparent background with a secondary border.
* - [Ghost] transparent background, no border. Lowest visual weight.
*/
enum class Variant {
Brand,
Primary,
Secondary,
Material,
Success,
Outline,
Ghost,
}
/**
* Size preset. Names follow the design-system size scale (X7 = smallest, X14 = largest) and
* map to height / padding / icon-size tokens via the internal `tokens()` extension.
*/
enum class Size {
X14,
X12,
X11,
X10,
X9,
X8,
X7,
}
}
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemButtonPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
PreviewSection(label = "Variants (size X10)") {
TangemButton.Variant.entries.forEach { variant ->
PreviewVariantRow(variant = variant)
}
}
PreviewSection(label = "Sizes (Primary)") {
PreviewSizeRow()
}
}
}
}
@Composable
private fun PreviewSection(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = label,
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
)
content()
}
}
@Composable
private fun PreviewVariantRow(variant: TangemButton.Variant) {
val info = remember { TangemIconUM.Icon(iconRes = R.drawable.ic_information_24) }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.widthIn(min = 72.dp),
text = variant.name,
color = TangemTheme.colors3.text.tertiary,
style = TangemTheme.typography3.body.medium,
)
TangemButton(variant = variant, text = stringReference("Label"), onClick = {})
TangemButton(variant = variant, text = stringReference("Icons"), iconStart = info, iconEnd = info, onClick = {})
TangemButton(variant = variant, text = stringReference("Loading"), isLoading = true, onClick = {})
TangemButton(variant = variant, text = stringReference("Disabled"), isEnabled = false, onClick = {})
TangemButton(variant = variant, iconStart = info, onClick = {})
}
}
@Composable
private fun PreviewSizeRow() {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TangemButton.Size.entries.forEach { size ->
TangemButton(size = size, text = stringReference(size.name), onClick = {})
}
}
}

View file

@ -0,0 +1,412 @@
package com.tangem.core.ui.ds2.button
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
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.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.extensions.ColorReference2
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.rememberLastNonNull
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
/**
* Inner content of [TangemButton]: an icon-text-icon row with a cross-fading loader overlay.
*
* Designed to be hosted inside a [com.tangem.core.ui.ds2.surface.TangemSurface] which owns sizing,
* shape, color, border and click handling. This composable only renders the content.
*/
@Suppress("LongParameterList")
@Composable
internal fun TangemButtonInternal(
isIconOnly: Boolean,
isEnabled: Boolean,
isLoading: Boolean,
iconStart: TangemIconUM?,
iconEnd: TangemIconUM?,
text: TextReference?,
colorTokens: ColorTokens,
sizeTokens: SizeTokens,
modifier: Modifier = Modifier,
) {
val resolvedIconStart = iconStart?.resolveTint(colorTokens, isEnabled)
val resolvedIconEnd = iconEnd?.resolveTint(colorTokens, isEnabled)
val contentAlpha by animateFloatAsState(if (isLoading) 0f else 1f, label = "contentAlpha")
val loaderAlpha by animateFloatAsState(if (isLoading) 1f else 0f, label = "loaderAlpha")
Box(
modifier = modifier
.conditionalCompose(
condition = isIconOnly,
otherModifier = {
height(sizeTokens.minHeight)
.widthIn(min = sizeTokens.minWidth)
},
modifier = { size(sizeTokens.minSizeIconOnly) },
),
contentAlignment = Alignment.Center,
) {
ContentRow(
modifier = Modifier
.alpha(contentAlpha)
.padding(
horizontal = sizeTokens.containerHorizontalPadding,
vertical = sizeTokens.containerVerticalPadding,
),
isEnabled = isEnabled,
iconStart = resolvedIconStart,
iconEnd = resolvedIconEnd,
text = text,
colorTokens = colorTokens,
sizeTokens = sizeTokens,
)
if (loaderAlpha > 0f) {
TangemLoader(
modifier = Modifier
.align(Alignment.Center)
.alpha(loaderAlpha),
color = if (isEnabled) colorTokens.iconTint else colorTokens.disabledIconTint,
)
}
}
}
/**
* Row of `[iconStart] [text] [iconEnd]` where each slot animates in/out independently.
*
* Last non-null values are cached so that `AnimatedVisibility` exit transitions still have content
* to render once the caller flips a slot back to `null`.
*/
@Suppress("LongParameterList")
@Composable
private fun ContentRow(
isEnabled: Boolean,
iconStart: TangemIconUM?,
iconEnd: TangemIconUM?,
text: TextReference?,
colorTokens: ColorTokens,
sizeTokens: SizeTokens,
modifier: Modifier = Modifier,
) {
val displayedIconStart = rememberLastNonNull(iconStart)
val displayedIconEnd = rememberLastNonNull(iconEnd)
val displayedText = rememberLastNonNull(text)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedVisibility(
visible = iconStart != null,
enter = SlotEnterTransition,
exit = SlotExitTransition,
) {
displayedIconStart?.let { icon ->
TangemIcon(
modifier = Modifier.size(sizeTokens.iconSize),
tangemIconUM = icon,
)
}
}
AnimatedVisibility(
visible = text != null,
enter = SlotEnterTransition,
exit = SlotExitTransition,
) {
displayedText?.let { textRef ->
CompositionLocalProvider(LocalDensity provides cappedFontScaleDensity()) {
Text(
modifier = Modifier.padding(horizontal = sizeTokens.textPadding),
text = textRef.resolveReference(),
textAlign = TextAlign.Center,
color = if (isEnabled) colorTokens.textColor else colorTokens.disabledTextColor,
style = TangemTheme.typography3.body.medium.copy(
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
),
),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
}
}
}
AnimatedVisibility(
visible = iconEnd != null,
enter = SlotEnterTransition,
exit = SlotExitTransition,
) {
displayedIconEnd?.let { icon ->
TangemIcon(
modifier = Modifier.size(sizeTokens.iconSize),
tangemIconUM = icon,
)
}
}
}
}
/**
* Returns a [Density] derived from [LocalDensity] with [Density.fontScale] capped at
* [MAX_BUTTON_FONT_SCALE]. The button's height is fixed by design tokens, so unbounded user font
* scales would clip the label vertically; capping the scale keeps the text visible while still
* honoring user preferences up to a point. When the user's scale is already within the cap, the
* current [LocalDensity] is returned unchanged.
*/
@Composable
private fun cappedFontScaleDensity(): Density {
val baseDensity = LocalDensity.current
return remember(baseDensity.density, baseDensity.fontScale) {
if (baseDensity.fontScale <= MAX_BUTTON_FONT_SCALE) {
baseDensity
} else {
Density(density = baseDensity.density, fontScale = MAX_BUTTON_FONT_SCALE)
}
}
}
private const val MAX_BUTTON_FONT_SCALE = 1.3f
// Shared, snappy specs so size and alpha animations stay in sync across the three slots.
private val SlotSizeSpec = spring<IntSize>(stiffness = Spring.StiffnessMediumLow)
private val SlotAlphaSpec = spring<Float>(stiffness = Spring.StiffnessMediumLow)
private val SlotEnterTransition: EnterTransition =
fadeIn(animationSpec = SlotAlphaSpec) + expandHorizontally(animationSpec = SlotSizeSpec)
private val SlotExitTransition: ExitTransition =
fadeOut(animationSpec = SlotAlphaSpec) + shrinkHorizontally(animationSpec = SlotSizeSpec)
/**
* Forces the variant's icon color (or its disabled variant when [isEnabled] is `false`) onto
* [TangemIconUM.Icon] the button's variant always drives icon color, so any caller-supplied
* tint is overridden. Other icon types pass through unchanged.
*
* Note: we cannot honor a caller-supplied tint conditionally, because [TangemIconUM.Icon]'s
* convenience constructor defaults `tint` to a non-null `ColorReference2`, making "no tint
* supplied" indistinguishable from "tint explicitly set" at the call site.
*/
@Composable
private fun TangemIconUM.resolveTint(colorTokens: ColorTokens, isEnabled: Boolean): TangemIconUM {
return when (this) {
is TangemIconUM.Icon -> copy(
tint = ColorReference2 {
if (isEnabled) colorTokens.iconTint else colorTokens.disabledIconTint
},
)
else -> this
}
}
/** Resolved per-variant colors used by [TangemButton]. */
internal data class ColorTokens(
val backgroundColor: Color,
val textColor: Color,
val iconTint: Color,
val disabledBackgroundColor: Color,
val disabledTextColor: Color,
val disabledIconTint: Color,
val focusRingColor: Color,
val disabledAlpha: Float = 1f,
val defaultBorderColor: Color? = null,
)
@Suppress("LongMethod")
@Composable
@ReadOnlyComposable
internal fun TangemButton.Variant.tokens(): ColorTokens {
return when (this) {
TangemButton.Variant.Brand -> ColorTokens(
backgroundColor = TangemTheme.colors3.bg.brand,
textColor = TangemTheme.colors3.text.staticDark.primary,
iconTint = TangemTheme.colors3.icon.staticDark,
disabledBackgroundColor = TangemTheme.colors3.bg.disabled,
disabledTextColor = TangemTheme.colors3.text.tertiary,
disabledIconTint = TangemTheme.colors3.icon.tertiary,
focusRingColor = TangemTheme.colors3.interaction.focusRing.default,
)
TangemButton.Variant.Primary -> ColorTokens(
backgroundColor = TangemTheme.colors3.bg.inverse,
textColor = TangemTheme.colors3.text.inverse.primary,
iconTint = TangemTheme.colors3.icon.inverse,
disabledBackgroundColor = TangemTheme.colors3.bg.disabled,
disabledTextColor = TangemTheme.colors3.text.tertiary,
disabledIconTint = TangemTheme.colors3.icon.tertiary,
focusRingColor = TangemTheme.colors3.interaction.focusRing.brand,
)
TangemButton.Variant.Secondary -> ColorTokens(
backgroundColor = TangemTheme.colors3.bg.opaque.primary,
textColor = TangemTheme.colors3.text.primary,
iconTint = TangemTheme.colors3.icon.primary,
disabledBackgroundColor = TangemTheme.colors3.bg.opaque.primary,
disabledTextColor = TangemTheme.colors3.text.primary,
disabledIconTint = TangemTheme.colors3.icon.primary,
focusRingColor = TangemTheme.colors3.interaction.focusRing.brand,
disabledAlpha = TangemTheme.dimens3.opacity.disabled,
)
TangemButton.Variant.Material -> ColorTokens(
// Background is the haze fill (FILL/MATERIAL) rendered by TangemSurface when isMaterial = true;
// this slot is unused in that path.
backgroundColor = Color.Transparent,
textColor = TangemTheme.colors3.text.primary,
iconTint = TangemTheme.colors3.icon.primary,
disabledBackgroundColor = Color.Transparent,
disabledTextColor = TangemTheme.colors3.text.tertiary,
disabledIconTint = TangemTheme.colors3.icon.tertiary,
focusRingColor = TangemTheme.colors3.interaction.focusRing.brand,
)
TangemButton.Variant.Success -> ColorTokens(
backgroundColor = TangemTheme.colors3.bg.status.success,
textColor = TangemTheme.colors3.text.staticDark.primary,
iconTint = TangemTheme.colors3.icon.staticDark,
disabledBackgroundColor = TangemTheme.colors3.bg.status.success,
disabledTextColor = TangemTheme.colors3.text.staticDark.primary,
disabledIconTint = TangemTheme.colors3.icon.staticDark,
focusRingColor = TangemTheme.colors3.interaction.focusRing.default,
disabledAlpha = TangemTheme.dimens3.opacity.disabled,
)
TangemButton.Variant.Outline -> ColorTokens(
backgroundColor = Color.Transparent,
textColor = TangemTheme.colors3.text.primary,
iconTint = TangemTheme.colors3.icon.primary,
disabledBackgroundColor = Color.Transparent,
disabledTextColor = TangemTheme.colors3.text.primary,
disabledIconTint = TangemTheme.colors3.icon.primary,
focusRingColor = TangemTheme.colors3.interaction.focusRing.brand,
disabledAlpha = TangemTheme.dimens3.opacity.disabled,
defaultBorderColor = TangemTheme.colors3.border.secondary,
)
TangemButton.Variant.Ghost -> ColorTokens(
backgroundColor = Color.Transparent,
textColor = TangemTheme.colors3.text.primary,
iconTint = TangemTheme.colors3.icon.primary,
disabledBackgroundColor = Color.Transparent,
disabledTextColor = TangemTheme.colors3.text.primary,
disabledIconTint = TangemTheme.colors3.icon.primary,
focusRingColor = TangemTheme.colors3.interaction.focusRing.brand,
disabledAlpha = TangemTheme.dimens3.opacity.disabled,
)
}
}
/** Resolved per-size dimensions used by [TangemButton]. */
internal data class SizeTokens(
val minHeight: Dp,
val minWidth: Dp,
val minSizeIconOnly: Dp,
val textPadding: Dp,
val containerHorizontalPadding: Dp,
val containerVerticalPadding: Dp,
val iconSize: Dp,
)
@Composable
@ReadOnlyComposable
internal fun TangemButton.Size.tokens(): SizeTokens {
val dimens = TangemTheme.dimens3
return when (this) {
TangemButton.Size.X14 -> SizeTokens(
minHeight = dimens.size.s700,
minWidth = dimens.size.s1100,
minSizeIconOnly = dimens.size.s700,
textPadding = dimens.spacing.s100,
containerHorizontalPadding = dimens.spacing.s200,
containerVerticalPadding = dimens.spacing.s200,
iconSize = 24.dp,
)
TangemButton.Size.X12 -> SizeTokens(
minHeight = dimens.size.s600,
minWidth = dimens.size.s1000,
minSizeIconOnly = dimens.size.s600,
textPadding = dimens.spacing.s100,
containerHorizontalPadding = dimens.spacing.s150,
containerVerticalPadding = dimens.spacing.s150,
iconSize = 24.dp,
)
TangemButton.Size.X11 -> SizeTokens(
minHeight = dimens.size.s550,
minWidth = dimens.size.s900,
minSizeIconOnly = dimens.size.s550,
textPadding = dimens.spacing.s075,
containerHorizontalPadding = dimens.spacing.s150,
containerVerticalPadding = dimens.spacing.s150,
iconSize = 20.dp,
)
TangemButton.Size.X10 -> SizeTokens(
minHeight = dimens.size.s500,
minWidth = dimens.size.s800,
minSizeIconOnly = dimens.size.s500,
textPadding = dimens.spacing.s075,
containerHorizontalPadding = dimens.spacing.s125,
containerVerticalPadding = dimens.spacing.s125,
iconSize = 20.dp,
)
TangemButton.Size.X9 -> SizeTokens(
minHeight = dimens.size.s450,
minWidth = dimens.size.s700,
minSizeIconOnly = dimens.size.s450,
textPadding = dimens.spacing.s075,
containerHorizontalPadding = dimens.spacing.s100,
containerVerticalPadding = dimens.spacing.s100,
iconSize = 20.dp,
)
TangemButton.Size.X8 -> SizeTokens(
minHeight = dimens.size.s400,
minWidth = dimens.size.s600,
minSizeIconOnly = dimens.size.s400,
textPadding = dimens.spacing.s075,
containerHorizontalPadding = dimens.spacing.s075,
containerVerticalPadding = dimens.spacing.s075,
iconSize = 20.dp,
)
TangemButton.Size.X7 -> SizeTokens(
minHeight = dimens.size.s350,
minWidth = dimens.size.s500,
minSizeIconOnly = dimens.size.s350,
textPadding = dimens.spacing.s075,
containerHorizontalPadding = dimens.spacing.s075,
containerVerticalPadding = dimens.spacing.s050,
iconSize = 16.dp,
)
}
}

View file

@ -0,0 +1,181 @@
package com.tangem.core.ui.ds2.surface
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.RippleConfiguration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
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.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.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
/**
* Design-system v2 surface: a clipped, optionally-bordered, optionally-clickable container that
* renders either as a flat colored surface or as a translucent "material" surface backed by a
* haze blur effect.
*
* Rendering modes:
* - **Flat** (`isMaterial = false`, default): a solid [color] background clipped to [shape].
* - **Material** (`isMaterial = true`): the [color] parameter is ignored. The surface adds a soft
* drop shadow and a gradient stroke (`material.border`), then renders a haze-blurred backdrop
* tinted with `material.fill.blur`. When `LocalHazeState.blurEnabled` is `false` (e.g.
* previews, low-end devices), the surface falls back to opaque `material.fill.solid` overlaid
* with translucent `material.tint.solid` so both layers remain visible.
*
* Interaction:
* - When [onClick] is non-null the surface is clickable. The v2 ripple configuration is provided
* via [LocalRippleConfiguration] for the [content] subtree as well.
* - [enabled] only gates the click handler disabled surfaces don't change appearance here;
* callers are expected to handle visual disabled state themselves (e.g. via alpha).
*
* @param color Background color used in flat mode. Ignored when [isMaterial] is `true`.
* @param isMaterial Switches to the haze-based translucent rendering.
* @param border Optional outer stroke. Drawn underneath the material gradient stroke when both
* are present.
* @param shape Shape used for clipping, background, and borders.
* @param onClick Click handler. `null` makes the surface non-interactive.
* @param enabled Forwarded to the click handler.
* @param content Content rendered inside the clipped surface.
*/
@Suppress("UnsafeCallOnNullableType", "")
@Composable
@NonRestartableComposable
fun TangemSurface(
modifier: Modifier = Modifier,
color: Color = TangemTheme.colors3.bg.primary,
isMaterial: Boolean = false,
border: BorderStroke? = null,
shape: Shape = RoundedCornerShape(TangemTheme.dimens3.borderRadius.b200),
onClick: (() -> Unit)? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit,
) {
val resolvedInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
val surface: @Composable () -> Unit = {
Box(
modifier = modifier
.conditionalCompose(isMaterial) { materialShadow(shape) }
.conditionalCompose(border != null) { border(border!!, shape) }
.conditionalCompose(isMaterial) { materialBorder(shape) }
.clip(shape)
.background(if (isMaterial) Color.Transparent else color, shape)
.conditionalCompose(isMaterial) { materialFill() }
.conditionalCompose(onClick != null) {
clickable(
interactionSource = resolvedInteractionSource,
indication = LocalIndication.current,
enabled = enabled,
onClick = onClick!!,
)
},
) {
content()
}
}
if (onClick != null) {
CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple()) {
surface()
}
} else {
surface()
}
}
// region material rendering
/** Drop shadow for the material variant. */
@Composable
private fun Modifier.materialShadow(shape: Shape): Modifier = softLayerShadow(
radius = 40.dp,
color = Color.Black.copy(alpha = 0.10f),
shape = shape,
spread = 0.dp,
offset = DpOffset(x = 0.dp, y = 8.dp),
)
/** Diagonal gradient stroke that wraps the material variant. */
@Composable
private fun Modifier.materialBorder(shape: Shape): Modifier = border(
width = TangemTheme.dimens3.borderWidth.sm,
brush = materialBorderBrush(),
shape = shape,
)
/**
* Translucent fill for the material variant.
*
* 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.
*/
@Composable
private fun Modifier.materialFill(): Modifier {
val hazed = hazeEffectTangem(
style = HazeStyle(
backgroundColor = TangemTheme.colors3.material.fill.blur,
blurRadius = TangemTheme.dimens3.blur.Button,
tints = emptyList(),
),
) {
fallbackTint = HazeTint(Color.Transparent)
}
return hazed.conditionalCompose(!LocalHazeState.current.blurEnabled) {
// 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)
}
}
@Suppress("MagicNumber")
@Composable
@ReadOnlyComposable
private fun materialBorderBrush(): Brush {
val border = TangemTheme.colors3.material.border
return Brush.linearGradient(
0f to border.start,
0.5f to border.mid,
1f to border.end,
start = Offset.Zero,
end = Offset.Infinite,
)
}
// endregion
@Composable
@ReadOnlyComposable
private fun tangemSurfaceRipple(): RippleConfiguration = RippleConfiguration(
color = TangemTheme.colors3.interaction.press.default,
rippleAlpha = RippleAlpha(
draggedAlpha = 0f,
focusedAlpha = 0f,
hoveredAlpha = 0.05f,
pressedAlpha = 0.1f,
),
)

View file

@ -1,6 +1,8 @@
package com.tangem.core.ui.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@ -27,4 +29,35 @@ fun rememberHapticFeedback(
onAction.invoke()
}
}
}
/**
* Returns [value] when it is non-null, otherwise the most recent non-null value seen at this call
* site. The cached value is updated in a [SideEffect] so this function never writes to a snapshot
* state during composition.
*
* Typical use case: pairing transient nullable inputs with `AnimatedVisibility` (or any other
* exit-animating wrapper). When the caller flips the input back to `null` to trigger an exit
* transition, the last non-null value is still available for the wrapped content to render until
* the transition finishes without it, the content would disappear instantly and the exit
* animation would have nothing to animate.
*
* Example:
* ```
* val displayedIcon = rememberLastNonNull(iconStart)
* AnimatedVisibility(visible = iconStart != null) {
* displayedIcon?.let { TangemIcon(it) }
* }
* ```
*
* Note: the cache is per call site, so calling this multiple times in the same composable yields
* independent caches.
*/
@Composable
fun <T : Any> rememberLastNonNull(value: T?): T? {
val cache = remember { mutableStateOf(value) }
SideEffect {
if (value != null && cache.value !== value) cache.value = value
}
return value ?: cache.value
}

View file

@ -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.button.TangemButton
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.topbar.TangemTopBarType
@ -100,4 +101,25 @@ internal data class DsComponentsListStory(
internal data class TangemLoaderStory(
val selectedSize: TangemLoaderSize,
val onSizeChange: (TangemLoaderSize) -> Unit,
) : DsStoryBookPage
internal data class TangemButtonStory(
val variant: TangemButton.Variant,
val size: TangemButton.Size,
val isLoading: Boolean,
val isEnabled: Boolean,
val hasIconStart: Boolean,
val hasIconEnd: Boolean,
val hasText: Boolean,
val isBlurEnabled: Boolean,
val textScale: Float,
val onVariantChange: (TangemButton.Variant) -> Unit,
val onSizeChange: (TangemButton.Size) -> Unit,
val onLoadingToggle: () -> Unit,
val onEnabledToggle: () -> Unit,
val onIconStartToggle: () -> Unit,
val onIconEndToggle: () -> Unit,
val onTextToggle: () -> Unit,
val onBlurToggle: () -> Unit,
val onTextScaleChange: (Float) -> Unit,
) : DsStoryBookPage

View file

@ -15,12 +15,14 @@ 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.button.tangemButtonStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory
private data class DsStoryItem(val title: String, val factory: StoryPageFactory)
private fun buildDsStories() = listOf(
DsStoryItem(title = "⏳ TangemLoader", factory = tangemLoaderStoryFactory),
DsStoryItem(title = "🔘 TangemButton", factory = tangemButtonStoryFactory),
)
@Composable

View file

@ -0,0 +1,50 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.button
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.feature.tester.presentation.storybook.entity.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemButtonStory>.build(): TangemButtonStory {
return TangemButtonStory(
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X10,
isLoading = false,
isEnabled = true,
hasIconStart = false,
hasIconEnd = false,
hasText = true,
isBlurEnabled = true,
textScale = 1f,
onVariantChange = { variant ->
updateStory { it.copy(variant = variant) }
},
onSizeChange = { size ->
updateStory { it.copy(size = size) }
},
onLoadingToggle = {
updateStory { it.copy(isLoading = !it.isLoading) }
},
onEnabledToggle = {
updateStory { it.copy(isEnabled = !it.isEnabled) }
},
onIconStartToggle = {
updateStory { it.copy(hasIconStart = !it.hasIconStart) }
},
onIconEndToggle = {
updateStory { it.copy(hasIconEnd = !it.hasIconEnd) }
},
onTextToggle = {
updateStory { it.copy(hasText = !it.hasText) }
},
onBlurToggle = {
updateStory { it.copy(isBlurEnabled = !it.isBlurEnabled) }
},
onTextScaleChange = { scale ->
updateStory { it.copy(textScale = scale) }
},
)
}
internal val tangemButtonStoryFactory
get() = storyPageFactory(StateUpdater<TangemButtonStory>::build)

View file

@ -0,0 +1,305 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.button
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateFloat
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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.SideEffect
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.button.TangemButton
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
@Composable
internal fun TangemButtonStory(state: TangemButtonStory, modifier: Modifier = Modifier) {
val hazeState = LocalHazeState.current
SideEffect { hazeState.blurEnabled = state.isBlurEnabled }
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)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
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
)
}
// Hard-edged stripes — sharp seams make the blur visually obvious.
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 = "blur-bg")
val offset by transition.animateFloat(
initialValue = 0f,
targetValue = tilePx,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 4_000, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "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 ComponentPreview(state: TangemButtonStory) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp)),
) {
BlurTestBackground(
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) {
TangemButton(
variant = state.variant,
size = state.size,
isLoading = state.isLoading,
isEnabled = state.isEnabled,
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
},
text = if (state.hasText) stringReference("Button") else null,
onClick = {},
)
}
}
}
}
@Composable
private fun VariantSelector(selected: TangemButton.Variant, onSelect: (TangemButton.Variant) -> Unit) {
Section(label = "Variant") {
ChipGrid(
items = TangemButton.Variant.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun SizeSelector(selected: TangemButton.Size, onSelect: (TangemButton.Size) -> Unit) {
Section(label = "Size") {
ChipGrid(
items = TangemButton.Size.entries,
label = { it.name },
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: TangemButtonStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "isLoading", checked = state.isLoading, onToggle = state.onLoadingToggle)
ToggleRow(label = "isEnabled", checked = state.isEnabled, onToggle = state.onEnabledToggle)
ToggleRow(label = "iconStart", checked = state.hasIconStart, onToggle = state.onIconStartToggle)
ToggleRow(label = "iconEnd", checked = state.hasIconEnd, onToggle = state.onIconEndToggle)
ToggleRow(label = "text", checked = state.hasText, onToggle = state.onTextToggle)
ToggleRow(label = "blur", checked = state.isBlurEnabled, onToggle = state.onBlurToggle)
}
}
}
@Composable
private fun Section(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = label,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
content()
}
}
@Composable
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEach { item ->
Chip(
label = label(item),
selected = isSelected(item),
onClick = { onSelect(item) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else 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,
)
}
}

View file

@ -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.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM
import com.tangem.feature.tester.presentation.storybook.entity.StoryList
import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxStory
@ -31,6 +32,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.button.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.page.opportunities.OpportunitiesBGStory
import com.tangem.feature.tester.presentation.storybook.page.checkbox.TangemCheckboxStory
@ -79,6 +81,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
DeviceIconStory -> DeviceIconStory()
is DsComponentsListStory -> DsComponentsListStory(state = storyState)
is TangemLoaderStory -> TangemLoaderStory(state = storyState)
is TangemButtonStory -> TangemButtonStory(state = storyState)
}
}
}