From f0f5fe96fcf87eabde680452769a50e45e266cd3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 May 2026 13:23:24 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/core/ui/ds2/row/TangemRow.kt | 468 ++++++++++++++++++ .../storybook/entity/StoryBookPage.kt | 42 ++ .../page/ds/DsComponentsListScreen.kt | 2 + .../page/ds/badge/TangemBadgeV2Story.kt | 21 +- .../page/ds/button/TangemButtonStory.kt | 17 +- .../storybook/page/ds/row/Build.kt | 71 +++ .../storybook/page/ds/row/TangemRowStory.kt | 369 ++++++++++++++ .../page/ds/shimmer/TangemShimmerStory.kt | 70 +-- .../storybook/ui/StoryBookScreen.kt | 2 + 9 files changed, 1019 insertions(+), 43 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/TangemRowStory.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt new file mode 100644 index 0000000000..e5c6f71471 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt @@ -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 \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index d3149250d0..51e22e3f0a 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -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, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 57b7f91cc1..ba469619c7 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -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), ) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt index 78539107a0..6d0d5c0f01 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt @@ -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) + } } } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt index a465ac1166..7eccf7507b 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt @@ -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) + } } } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/Build.kt new file mode 100644 index 0000000000..c04c585037 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/Build.kt @@ -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.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::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/TangemRowStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/TangemRowStory.kt new file mode 100644 index 0000000000..998cf58cf5 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/TangemRowStory.kt @@ -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 ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TangemTheme.colors2.surface.level2) + .clickable(onClick = onToggle) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = if (checked) "ON" else "OFF", + style = TangemTheme.typography.caption2, + color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt index e808cb171b..dd6c6aba97 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt @@ -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, + ) + } } } } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index a261cb5e27..29ec749202 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -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) }