Updated on 2026-08-14
This commit is contained in:
parent
d5554a7f7d
commit
e5475e26e1
13 changed files with 1297 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue