Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-27 13:23:24 +03:00
parent e018eee87b
commit f0f5fe96fc
9 changed files with 1019 additions and 43 deletions

View file

@ -0,0 +1,468 @@
package com.tangem.core.ui.ds2.row
import android.content.res.Configuration
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.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.RippleConfiguration
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
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.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/** Controls how the title and value sides share horizontal space in a [TangemRow]. */
@Immutable
enum class TangemRowContentLead { Equal, Start, End }
/** Vertical alignment between the start slot, content labels, and end slot in a [TangemRow]. */
@Immutable
enum class TangemRowVerticalAlignment { Top, Center }
/**
* Design-system v2 row: a list-item container with start / end slots, title+subtitle on the
* leading side, value+subvalue on the trailing side, and an optional slot below the row.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=2344-1406)
*
* Usage:
* ```
* TangemRow(
* titleSlot = { TangemRowText("Network fee", TangemRowTextRole.Title) },
* valueSlot = { TangemRowText("0.0001 ETH", TangemRowTextRole.Value) },
* contentLead = TangemRowContentLead.Start,
* onClick = { /* ... */ },
* )
* ```
*
* @param modifier Modifier applied to the row container.
* @param divider Draws an inset bottom divider.
* @param includeInnerPaddings Applies the default row padding. Disable when wrapped in a container
* that already handles padding.
* @param contentLead Strategy for sharing space between the title and value sides.
* See [TangemRowContentLead].
* @param verticalAlignment Vertical alignment of the slots. See [TangemRowVerticalAlignment].
* @param titleSlot Leading primary label.
* @param subtitleSlot Leading secondary label rendered under [titleSlot].
* @param valueSlot Trailing primary label.
* @param subvalueSlot Trailing secondary label rendered under [valueSlot].
* @param startSlot Leading icon / control slot.
* @param endSlot Trailing icon / control slot.
* @param extraBottomSlot Full-width content rendered below the row.
* @param interactionSource Interaction source used when [onClick] is non-null.
* @param onClick Click handler. `null` makes the row non-interactive.
*/
@Suppress("LongParameterList")
@Composable
fun TangemRow(
modifier: Modifier = Modifier,
divider: Boolean = false,
includeInnerPaddings: Boolean = true,
contentLead: TangemRowContentLead = TangemRowContentLead.Equal,
verticalAlignment: TangemRowVerticalAlignment = TangemRowVerticalAlignment.Top,
titleSlot: (@Composable RowScope.() -> Unit)? = null,
subtitleSlot: (@Composable RowScope.() -> Unit)? = null,
valueSlot: (@Composable RowScope.() -> Unit)? = null,
subvalueSlot: (@Composable RowScope.() -> Unit)? = null,
startSlot: (@Composable BoxScope.() -> Unit)? = null,
endSlot: (@Composable BoxScope.() -> Unit)? = null,
extraBottomSlot: (@Composable () -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
onClick: (() -> Unit)? = null,
) {
val resolvedInteractionSource: MutableInteractionSource? = if (onClick != null) {
interactionSource ?: remember { MutableInteractionSource() }
} else {
null
}
val isFocused = if (resolvedInteractionSource != null) {
val isFocusedByState by resolvedInteractionSource.collectIsFocusedAsState()
isFocusedByState
} else {
false
}
WithRowRipple(enabled = onClick != null) {
Column(
modifier = modifier
.conditionalCompose(isFocused) { focusBorder() }
.then(
if (onClick != null) {
Modifier.clickable(
interactionSource = resolvedInteractionSource,
indication = LocalIndication.current,
onClick = onClick,
)
} else {
Modifier
},
)
.conditionalCompose(divider) {
bottomDivider(
color = TangemTheme.colors3.border.secondary,
width = TangemTheme.dimens3.borderWidth.sm,
horizontalInset = TangemTheme.dimens3.spacing.s200,
)
}
.conditionalCompose(includeInnerPaddings) {
padding(TangemTheme.dimens3.spacing.s200)
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = verticalAlignment.toCompose(),
) {
SideSlot(slot = startSlot, position = SideSlotPosition.Start)
ContentLabels(
modifier = Modifier.weight(1f),
contentLead = contentLead,
verticalAlignment = verticalAlignment,
titleSlot = titleSlot,
subtitleSlot = subtitleSlot,
valueSlot = valueSlot,
subvalueSlot = subvalueSlot,
)
SideSlot(slot = endSlot, position = SideSlotPosition.End)
}
if (extraBottomSlot != null) {
SpacerH(TangemTheme.dimens3.spacing.s100)
extraBottomSlot()
}
}
}
}
@Composable
private fun WithRowRipple(enabled: Boolean, content: @Composable () -> Unit) {
if (enabled) {
CompositionLocalProvider(LocalRippleConfiguration provides tangemRowRipple(), content = content)
} else {
content()
}
}
@Composable
@ReadOnlyComposable
private fun tangemRowRipple(): RippleConfiguration = RippleConfiguration(
color = TangemTheme.colors3.interaction.press.default,
rippleAlpha = RippleAlpha(
draggedAlpha = 0f,
focusedAlpha = 0f,
hoveredAlpha = 0.05f,
pressedAlpha = 0.1f,
),
)
private fun TangemRowVerticalAlignment.toCompose(): Alignment.Vertical = when (this) {
TangemRowVerticalAlignment.Top -> Alignment.Top
TangemRowVerticalAlignment.Center -> Alignment.CenterVertically
}
private enum class SideSlotPosition { Start, End }
@Composable
private fun SideSlot(slot: (@Composable BoxScope.() -> Unit)?, position: SideSlotPosition) {
if (slot == null) return
val spacing = TangemTheme.dimens3.spacing.s150
val padding = when (position) {
SideSlotPosition.Start -> Modifier.padding(end = spacing)
SideSlotPosition.End -> Modifier.padding(start = spacing)
}
Box(modifier = padding, content = slot)
}
/**
* Lays out the title/value columns side-by-side with [contentLead]-aware width sharing.
*/
@Suppress("UnnecessaryParentheses", "LongParameterList")
@Composable
private fun ContentLabels(
contentLead: TangemRowContentLead,
verticalAlignment: TangemRowVerticalAlignment,
titleSlot: (@Composable RowScope.() -> Unit)?,
subtitleSlot: (@Composable RowScope.() -> Unit)?,
valueSlot: (@Composable RowScope.() -> Unit)?,
subvalueSlot: (@Composable RowScope.() -> Unit)?,
modifier: Modifier = Modifier,
) {
val hasLeft = titleSlot != null || subtitleSlot != null
val hasRight = valueSlot != null || subvalueSlot != null
if (!hasLeft && !hasRight) return
Layout(
modifier = modifier,
content = {
// Always emit two roots so `measurables` indices are stable in the measure block.
LabelColumnContent(primary = titleSlot, secondary = subtitleSlot, alignment = Alignment.Start)
LabelColumnContent(primary = valueSlot, secondary = subvalueSlot, alignment = Alignment.End)
},
) { measurables, constraints ->
val titleMeasurable = measurables[0]
val valueMeasurable = measurables[1]
// Fall back to summed intrinsics when parent provides unbounded width — `layout()` cannot
// report an infinite size.
val rowWidth = if (constraints.hasBoundedWidth) {
constraints.maxWidth
} else {
titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE) + valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE)
}
val (titleSlotWidth, valueSlotWidth) = when {
!hasLeft -> 0 to rowWidth
!hasRight -> rowWidth to 0
else -> when (contentLead) {
TangemRowContentLead.Equal -> (rowWidth / 2) to (rowWidth - rowWidth / 2)
TangemRowContentLead.Start -> {
// Value hugs (intrinsic), title fills the rest. Cap the hugging side at half
// the row so the filling label never collapses to zero on overflow — degrades
// to an equal split when the hugging side wants more than half.
val v = valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2)
(rowWidth - v) to v
}
TangemRowContentLead.End -> {
// Title hugs (intrinsic), value fills the rest. Same half-row cap as above.
val t = titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2)
t to (rowWidth - t)
}
}
}
val titlePlaceable = titleMeasurable.measure(
constraints.copy(minWidth = 0, maxWidth = titleSlotWidth),
)
val valuePlaceable = valueMeasurable.measure(
constraints.copy(minWidth = 0, maxWidth = valueSlotWidth),
)
val rowHeight = maxOf(titlePlaceable.height, valuePlaceable.height)
val yFor: (Int) -> Int = when (verticalAlignment) {
TangemRowVerticalAlignment.Top -> { _ -> 0 }
TangemRowVerticalAlignment.Center -> { h -> (rowHeight - h) / 2 }
}
layout(rowWidth, rowHeight) {
titlePlaceable.placeRelative(x = 0, y = yFor(titlePlaceable.height))
// Filling side is pinned to the row's trailing edge.
valuePlaceable.placeRelative(
x = rowWidth - valuePlaceable.width,
y = yFor(valuePlaceable.height),
)
}
}
}
@Composable
private fun LabelColumnContent(
primary: (@Composable RowScope.() -> Unit)?,
secondary: (@Composable RowScope.() -> Unit)?,
alignment: Alignment.Horizontal,
) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens3.spacing.s025),
horizontalAlignment = alignment,
) {
if (primary != null) LabelRow(alignment = alignment, content = primary)
if (secondary != null) LabelRow(alignment = alignment, content = secondary)
}
}
@Composable
private fun LabelRow(alignment: Alignment.Horizontal, content: @Composable RowScope.() -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(
space = TangemTheme.dimens3.spacing.s050,
alignment = alignment,
),
content = content,
)
}
/** Semantic role of a label inside a [TangemRow], driving its typography, color and alignment. */
@Immutable
enum class TangemRowTextRole { Title, Subtitle, Value, Subvalue }
/**
* Default text styling for [TangemRow] label slots.
*
* Usage:
* ```
* TangemRowText(text = "Network fee", role = TangemRowTextRole.Title)
* ```
*
* @param text Label text.
* @param role Semantic role. See [TangemRowTextRole].
* @param modifier Modifier applied to the underlying [Text].
* @param maxLines Maximum number of visible lines before truncation.
* @param overflow Overflow behavior. Defaults to ellipsis.
*/
@Composable
fun TangemRowText(
text: String,
role: TangemRowTextRole,
modifier: Modifier = Modifier,
maxLines: Int = 1,
overflow: TextOverflow = TextOverflow.Ellipsis,
) {
Text(
text = text,
modifier = modifier,
color = rowTextColor(role),
style = rowTextStyle(role),
textAlign = rowTextAlign(role),
maxLines = maxLines,
overflow = overflow,
)
}
@Composable
private fun rowTextStyle(role: TangemRowTextRole): TextStyle = when (role) {
TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.typography3.body.medium
TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.typography3.caption.medium
}
@Composable
private fun rowTextColor(role: TangemRowTextRole): Color = when (role) {
TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.colors3.text.primary
TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.colors3.text.secondary
}
private fun rowTextAlign(role: TangemRowTextRole): TextAlign = when (role) {
TangemRowTextRole.Title, TangemRowTextRole.Subtitle -> TextAlign.Start
TangemRowTextRole.Value, TangemRowTextRole.Subvalue -> TextAlign.End
}
@Composable
private fun Modifier.focusBorder(): Modifier {
val radius = TangemTheme.dimens3.borderRadius.b200
val shape = remember(radius) { RoundedCornerShape(radius) }
return border(
width = TangemTheme.dimens3.borderWidth.md,
color = TangemTheme.colors3.interaction.focusRing.default,
shape = shape,
)
}
private fun Modifier.bottomDivider(color: Color, width: Dp, horizontalInset: Dp): Modifier = drawWithContent {
drawContent()
val stroke = width.toPx()
val inset = horizontalInset.toPx()
val y = size.height - stroke / 2f
drawLine(
color = color,
start = Offset(x = inset, y = y),
end = Offset(x = size.width - inset, y = y),
strokeWidth = stroke,
)
}
// region Previews
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemRowPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(vertical = 16.dp),
) {
// Equal lead, text only, with divider
TangemRow(
divider = true,
contentLead = TangemRowContentLead.Equal,
titleSlot = { TangemRowText(text = "Title", role = TangemRowTextRole.Title) },
subtitleSlot = { TangemRowText(text = "Subtitle", role = TangemRowTextRole.Subtitle) },
valueSlot = { TangemRowText(text = "Value", role = TangemRowTextRole.Value) },
subvalueSlot = { TangemRowText(text = "Subvalue", role = TangemRowTextRole.Subvalue) },
)
// Start lead — title hugs, value side fills
TangemRow(
divider = true,
contentLead = TangemRowContentLead.Start,
titleSlot = { TangemRowText(text = "Network fee", role = TangemRowTextRole.Title) },
valueSlot = { TangemRowText(text = "0.0001 ETH", role = TangemRowTextRole.Value) },
subvalueSlot = { TangemRowText(text = "$0.32", role = TangemRowTextRole.Subvalue) },
)
// End lead — value hugs, title side fills
TangemRow(
divider = true,
contentLead = TangemRowContentLead.End,
titleSlot = {
TangemRowText(
text = "Recipient with a very long address that should ellipsize",
role = TangemRowTextRole.Title,
)
},
valueSlot = { TangemRowText(text = "0x1234…abcd", role = TangemRowTextRole.Value) },
)
// Side slots + clickable + centered alignment
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
startSlot = {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
},
titleSlot = { TangemRowText(text = "Settings", role = TangemRowTextRole.Title) },
subtitleSlot = {
TangemRowText(text = "Tap to configure", role = TangemRowTextRole.Subtitle)
},
endSlot = {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
)
},
onClick = {},
)
// Title only — exercises nullable slots / single LabelRow path
TangemRow(
titleSlot = { TangemRowText(text = "Single-line row", role = TangemRowTextRole.Title) },
)
}
}
}
// endregion

View file

@ -9,6 +9,8 @@ import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle
internal sealed interface StoryBookPage
@ -177,6 +179,46 @@ internal data class TangemButtonStory(
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemRowStory(
val contentLead: TangemRowContentLead,
val verticalAlignment: TangemRowVerticalAlignment,
val background: Background,
val divider: Boolean,
val includeInnerPaddings: Boolean,
val isClickable: Boolean,
val hasStartSlot: Boolean,
val hasEndSlot: Boolean,
val hasSubtitle: Boolean,
val hasValue: Boolean,
val hasSubvalue: Boolean,
val hasExtraBottom: Boolean,
val longTitle: Boolean,
val textScale: Float,
val onContentLeadChange: (TangemRowContentLead) -> Unit,
val onVerticalAlignmentChange: (TangemRowVerticalAlignment) -> Unit,
val onBackgroundChange: (Background) -> Unit,
val onDividerToggle: () -> Unit,
val onInnerPaddingsToggle: () -> Unit,
val onClickableToggle: () -> Unit,
val onStartSlotToggle: () -> Unit,
val onEndSlotToggle: () -> Unit,
val onSubtitleToggle: () -> Unit,
val onValueToggle: () -> Unit,
val onSubvalueToggle: () -> Unit,
val onExtraBottomToggle: () -> Unit,
val onLongTitleToggle: () -> Unit,
val onTextScaleChange: (Float) -> Unit,
) : DsStoryBookPage {
/** Backdrop the row preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgBrand("bg.brand"),
BgInverse("bg.inverse"),
}
}
internal data class TangemFadeStory(
val variant: TangemFade.Variant,
val position: TangemFade.Position,

View file

@ -19,6 +19,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.badge.tangemBadg
import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemButtonStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
private data class DsStoryItem(val title: String, val factory: StoryPageFactory)
@ -27,6 +28,7 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "⏳ TangemLoader", factory = tangemLoaderStoryFactory),
DsStoryItem(title = "🔘 TangemButton", factory = tangemButtonStoryFactory),
DsStoryItem(title = "🏷️ TangemBadge", factory = tangemBadgeV2StoryFactory),
DsStoryItem(title = "📋 TangemRow", factory = tangemRowStoryFactory),
DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory),
DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory),
)

View file

@ -18,7 +18,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
@ -54,13 +56,20 @@ internal fun TangemBadgeV2Story(state: TangemBadgeV2Story, modifier: Modifier =
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
StatusSelector(selected = state.status, onSelect = state.onStatusChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
StatusSelector(selected = state.status, onSelect = state.onStatusChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
}
}
}

View file

@ -50,12 +50,19 @@ internal fun TangemButtonStory(state: TangemButtonStory, modifier: Modifier = Mo
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
}
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.row
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemRowStory>.build(): TangemRowStory {
return TangemRowStory(
contentLead = TangemRowContentLead.Equal,
verticalAlignment = TangemRowVerticalAlignment.Top,
background = TangemRowStory.Background.BgPrimary,
divider = false,
includeInnerPaddings = true,
isClickable = false,
hasStartSlot = false,
hasEndSlot = false,
hasSubtitle = true,
hasValue = true,
hasSubvalue = true,
hasExtraBottom = false,
longTitle = false,
textScale = 1f,
onContentLeadChange = { contentLead ->
updateStory { it.copy(contentLead = contentLead) }
},
onVerticalAlignmentChange = { alignment ->
updateStory { it.copy(verticalAlignment = alignment) }
},
onBackgroundChange = { background ->
updateStory { it.copy(background = background) }
},
onDividerToggle = {
updateStory { it.copy(divider = !it.divider) }
},
onInnerPaddingsToggle = {
updateStory { it.copy(includeInnerPaddings = !it.includeInnerPaddings) }
},
onClickableToggle = {
updateStory { it.copy(isClickable = !it.isClickable) }
},
onStartSlotToggle = {
updateStory { it.copy(hasStartSlot = !it.hasStartSlot) }
},
onEndSlotToggle = {
updateStory { it.copy(hasEndSlot = !it.hasEndSlot) }
},
onSubtitleToggle = {
updateStory { it.copy(hasSubtitle = !it.hasSubtitle) }
},
onValueToggle = {
updateStory { it.copy(hasValue = !it.hasValue) }
},
onSubvalueToggle = {
updateStory { it.copy(hasSubvalue = !it.hasSubvalue) }
},
onExtraBottomToggle = {
updateStory { it.copy(hasExtraBottom = !it.hasExtraBottom) }
},
onLongTitleToggle = {
updateStory { it.copy(longTitle = !it.longTitle) }
},
onTextScaleChange = { scale ->
updateStory { it.copy(textScale = scale) }
},
)
}
internal val tangemRowStoryFactory
get() = storyPageFactory(StateUpdater<TangemRowStory>::build)

View file

@ -0,0 +1,369 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.row
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory.Background
private const val SHORT_TITLE = "Title"
private const val LONG_TITLE = "Recipient address that should ellipsize when constrained by width"
private const val SUBTITLE = "Subtitle"
private const val VALUE = "0.0421 ETH"
private const val SUBVALUE = "$124.80"
@Composable
internal fun TangemRowStory(state: TangemRowStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ContentLeadSelector(selected = state.contentLead, onSelect = state.onContentLeadChange)
VerticalAlignmentSelector(
selected = state.verticalAlignment,
onSelect = state.onVerticalAlignmentChange,
)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
}
}
}
@Composable
private fun ComponentPreview(state: TangemRowStory) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp)),
) {
PreviewBackground(background = state.background, modifier = Modifier.matchParentSize())
val baseDensity = LocalDensity.current
val scaledDensity = remember(baseDensity, state.textScale) {
Density(density = baseDensity.density, fontScale = state.textScale)
}
CompositionLocalProvider(LocalDensity provides scaledDensity) {
Box(modifier = Modifier.padding(vertical = 24.dp)) {
StorybookRow(state = state)
}
}
}
}
@Composable
private fun StorybookRow(state: TangemRowStory) {
val titleText = if (state.longTitle) LONG_TITLE else SHORT_TITLE
TangemRow(
contentLead = state.contentLead,
verticalAlignment = state.verticalAlignment,
divider = state.divider,
includeInnerPaddings = state.includeInnerPaddings,
titleSlot = { TangemRowText(text = titleText, role = TangemRowTextRole.Title) },
subtitleSlot = if (state.hasSubtitle) {
{ TangemRowText(text = SUBTITLE, role = TangemRowTextRole.Subtitle) }
} else {
null
},
valueSlot = if (state.hasValue) {
{ TangemRowText(text = VALUE, role = TangemRowTextRole.Value) }
} else {
null
},
subvalueSlot = if (state.hasSubvalue) {
{ TangemRowText(text = SUBVALUE, role = TangemRowTextRole.Subvalue) }
} else {
null
},
startSlot = if (state.hasStartSlot) {
{
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
}
} else {
null
},
endSlot = if (state.hasEndSlot) {
{
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
)
}
} else {
null
},
extraBottomSlot = if (state.hasExtraBottom) {
{
Text(
text = "Extra bottom slot — multi-line description content.",
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
}
} else {
null
},
onClick = if (state.isClickable) {
{ /* no-op — ripple + focus demo */ }
} else {
null
},
)
}
@Composable
private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) {
when (background) {
Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary))
Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary))
Background.BgBrand -> Box(modifier.background(TangemTheme.colors3.bg.brand))
Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse))
}
}
@Composable
private fun ContentLeadSelector(selected: TangemRowContentLead, onSelect: (TangemRowContentLead) -> Unit) {
Section(label = "Content lead") {
ChipGrid(
items = TangemRowContentLead.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun VerticalAlignmentSelector(
selected: TangemRowVerticalAlignment,
onSelect: (TangemRowVerticalAlignment) -> Unit,
) {
Section(label = "Vertical alignment") {
ChipGrid(
items = TangemRowVerticalAlignment.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) {
Section(label = "Background") {
ChipGrid(
items = Background.entries,
label = { it.label },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun TextScaleSlider(value: Float, onChange: (Float) -> Unit) {
Section(label = "Text scale: ${"%.2f".format(value)}x") {
Slider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
value = value,
onValueChange = onChange,
valueRange = 0.5f..2f,
steps = 14,
colors = SliderDefaults.colors(
thumbColor = TangemTheme.colors.text.accent,
activeTrackColor = TangemTheme.colors.text.accent,
activeTickColor = TangemTheme.colors2.surface.level3,
inactiveTrackColor = TangemTheme.colors2.surface.level3,
inactiveTickColor = TangemTheme.colors.text.accent,
),
)
}
}
@Composable
private fun Toggles(state: TangemRowStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "divider", checked = state.divider, onToggle = state.onDividerToggle)
ToggleRow(
label = "includeInnerPaddings",
checked = state.includeInnerPaddings,
onToggle = state.onInnerPaddingsToggle,
)
ToggleRow(
label = "clickable (ripple + focus)",
checked = state.isClickable,
onToggle = state.onClickableToggle,
)
ToggleRow(
label = "startSlot (leading icon)",
checked = state.hasStartSlot,
onToggle = state.onStartSlotToggle,
)
ToggleRow(
label = "endSlot (trailing icon)",
checked = state.hasEndSlot,
onToggle = state.onEndSlotToggle,
)
ToggleRow(label = "subtitle", checked = state.hasSubtitle, onToggle = state.onSubtitleToggle)
ToggleRow(label = "value", checked = state.hasValue, onToggle = state.onValueToggle)
ToggleRow(label = "subvalue", checked = state.hasSubvalue, onToggle = state.onSubvalueToggle)
ToggleRow(
label = "extraBottomSlot",
checked = state.hasExtraBottom,
onToggle = state.onExtraBottomToggle,
)
ToggleRow(
label = "long title (ellipsis test)",
checked = state.longTitle,
onToggle = state.onLongTitleToggle,
)
}
}
}
@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

@ -26,42 +26,48 @@ internal fun TangemShimmerStory(state: TangemShimmerStory, modifier: Modifier =
.statusBarsPadding()
.fillMaxSize()
.background(TangemTheme.colors3.bg.primary)
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
ChipSection(label = "Text style") {
ChipGrid(
items = TextShimmerStyle.entries,
label = { it.chipLabel() },
isSelected = { it == state.textStyle },
onSelect = state.onTextStyleChange,
)
}
ChipSection(label = "Radius") {
ChipGrid(
items = RadiusOption.entries,
label = { it.label },
isSelected = { it == state.radius },
onSelect = state.onRadiusChange,
)
}
ChipSection(label = "Rectangle width") {
ChipGrid(
items = RectangleWidthOption.entries,
label = { it.label },
isSelected = { it == state.rectangleWidth },
onSelect = state.onRectangleWidthChange,
)
}
ChipSection(label = "Rectangle height") {
ChipGrid(
items = RectangleHeightOption.entries,
label = { it.label },
isSelected = { it == state.rectangleHeight },
onSelect = state.onRectangleHeightChange,
)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ChipSection(label = "Text style") {
ChipGrid(
items = TextShimmerStyle.entries,
label = { it.chipLabel() },
isSelected = { it == state.textStyle },
onSelect = state.onTextStyleChange,
)
}
ChipSection(label = "Radius") {
ChipGrid(
items = RadiusOption.entries,
label = { it.label },
isSelected = { it == state.radius },
onSelect = state.onRadiusChange,
)
}
ChipSection(label = "Rectangle width") {
ChipGrid(
items = RectangleWidthOption.entries,
label = { it.label },
isSelected = { it == state.rectangleWidth },
onSelect = state.onRectangleWidthChange,
)
}
ChipSection(label = "Rectangle height") {
ChipGrid(
items = RectangleHeightOption.entries,
label = { it.label },
isSelected = { it == state.rectangleHeight },
onSelect = state.onRectangleHeightChange,
)
}
}
}
}

View file

@ -41,6 +41,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.badge.TangemBadg
import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory
@ -89,6 +90,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemLoaderStory -> TangemLoaderStory(state = storyState)
is TangemButtonStory -> TangemButtonStory(state = storyState)
is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState)
is TangemRowStory -> TangemRowStory(state = storyState)
is TangemShimmerStory -> TangemShimmerStory(state = storyState)
is TangemFadeStory -> TangemFadeStory(state = storyState)
}