Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 13:34:06 +03:00
parent ddba5fe3cf
commit 5ce84d1500
14 changed files with 2832 additions and 0 deletions

View file

@ -0,0 +1,121 @@
@file:Suppress("MagicNumber")
package com.tangem.common.ui.markets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
import com.tangem.core.ui.ds2.tokenrow.TangemTokenRowMarket
import com.tangem.core.ui.ds2.util.TangemPriceChange
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.windowsize.WindowSizeType
/**
* DS3 markets-list item [TangemTokenRowMarket] bound to the legacy [MarketsListItemUM], for a
* drop-in migration from [MarketsListItemV2].
*
* Behavior notes (kept from [MarketsListItemV2]):
* - The chart is hidden on small window widths.
* - While [MarketsListItemUM.chartData] is `null`, the graph slot shows a shimmer bar.
* - On a live price update the price text flashes in the update-direction color
* ([MarketsListItemUM.Price.changeType]), like the legacy `TokenPriceText`.
*
* @param model Legacy markets list item model.
* @param modifier Modifier applied to the row container.
* @param onClick Row click handler. `null` makes the row non-interactive.
*/
@Composable
fun MarketsListItemV3(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) {
val isChartVisible = LocalWindowSize.current.widthAtLeast(WindowSizeType.Small)
TangemTokenRowMarket(
icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = model.iconUrl)),
title = stringReference(model.name),
modifier = modifier,
ticker = stringReference(model.currencySymbol),
position = model.ratingPosition?.let(::stringReference),
capitalization = model.marketCap?.let(::stringReference),
price = stringReference(model.price.text),
priceChange = TangemPriceChange.State(
value = stringReference(model.trendPercentText),
direction = model.trendType.toPriceChangeDirection(),
),
priceUpdateDirection = model.price.changeType?.toPriceChangeDirection(),
chart = if (isChartVisible) {
{ MarketRowChart(chartType = model.chartType, chartRawData = model.chartData) }
} else {
null
},
onClick = onClick,
)
}
private fun PriceChangeType.toPriceChangeDirection(): TangemPriceChange.Direction = when (this) {
PriceChangeType.UP -> TangemPriceChange.Direction.Up
PriceChangeType.DOWN -> TangemPriceChange.Direction.Down
PriceChangeType.NEUTRAL -> TangemPriceChange.Direction.Neutral
}
/** Mini-graph for the [TangemTokenRowMarket] chart slot — 24x32 collapsed, shimmer while loading. */
@Composable
private fun MarketRowChart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) {
Box(
modifier = Modifier.size(width = 24.dp, height = 32.dp),
contentAlignment = Alignment.Center,
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
neutralColor = TangemTheme.colors3.border.accent.neutral,
growingColor = TangemTheme.colors3.border.accent.blue,
fallingColor = TangemTheme.colors3.border.accent.red,
)
} else {
TangemShimmer(
radius = 4.dp,
modifier = Modifier.size(width = 24.dp, height = 16.dp),
)
}
}
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(
name = "Dark",
showBackground = true,
widthDp = 360,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
)
@Composable
private fun MarketsListItemV3Preview(
@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM,
) {
TangemThemePreviewRedesign {
Column(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
MarketsListItemV3(model = state, onClick = {})
MarketsListItemV3(model = state.copy(chartData = null))
}
}
}
// endregion

View file

@ -0,0 +1,280 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.messagebubble
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cross_circle_16_filled
import com.tangem.core.ui.res.generated.icons.ic_info_16
/**
* Design-system v2 (DS3) **Message Bubble** a compact caption pill with an optional leading icon,
* an optional close button and an optional "tip" tail on top pointing at the anchored element.
* DS3 replacement for the legacy `TokenRowPromoBanner`.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=6327-22326)
*
* @param text Bubble message, rendered in caption/medium typography.
* @param modifier Modifier applied to the bubble. The bubble hugs its content in both dimensions.
* @param variant Visual appearance background, text and tip colors.
* @param showTip Whether the tail on top of the bubble is drawn. `false` shows only the pill.
* @param icon Leading 16dp icon, tinted with the [variant] content color. `null` hides it.
* @param onClick Invoked when the bubble body is tapped. `null` makes the bubble non-interactive.
* @param onClose Invoked when the close button is tapped. `null` hides the button.
* @param closeContentDescription Accessibility label for the close button announced by TalkBack
* (e.g. `"Dismiss"`). Supply it whenever [onClose] is set.
*/
@Suppress("LongParameterList")
@Composable
fun TangemMessageBubble(
text: TextReference,
modifier: Modifier = Modifier,
variant: TangemMessageBubble.Variant = TangemMessageBubble.Variant.Neutral,
showTip: Boolean = true,
icon: ImageVector? = null,
onClick: (() -> Unit)? = null,
onClose: (() -> Unit)? = null,
closeContentDescription: String? = null,
) {
val tokens = variant.tokens()
val shape = if (showTip) MessageBubbleShape else RoundedCornerShape(12.dp)
Row(
modifier = modifier
.clip(shape)
.background(tokens.background)
.conditionalCompose(onClick != null) {
clickableSingle(role = Role.Button, onClick = requireNotNull(onClick))
}
.padding(
start = 8.dp,
top = if (showTip) 12.dp else 4.dp,
end = if (onClose != null) 4.dp else 8.dp,
bottom = 4.dp,
),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tokens.content,
modifier = Modifier.size(16.dp),
)
}
Text(
text = text.resolveAnnotatedReference(),
style = TangemTheme.typography3.caption.medium,
color = tokens.content,
)
if (onClose != null) {
CloseButton(
onClick = onClose,
tint = tokens.closeIcon,
contentDescription = closeContentDescription,
)
}
}
}
/** Public API surface of [TangemMessageBubble]. */
object TangemMessageBubble {
/** Visual appearance — background, text and tip colors. */
enum class Variant {
/** Neutral tertiary background with secondary text. */
Neutral,
/** Subtle success-green background with success text. */
Success,
/** Subtle info-blue background with info text. */
Info,
}
}
@Composable
private fun CloseButton(onClick: () -> Unit, tint: Color, contentDescription: String?, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.padding(start = 4.dp)
.size(16.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.requiredSize(28.dp)
.clip(RoundedCornerShape(percent = 50))
.clickableSingle(onClick = onClick)
.semantics {
role = Role.Button
contentDescription?.let { this.contentDescription = it }
},
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.ic_cross_circle_16_filled,
contentDescription = null,
tint = tint,
modifier = Modifier.size(16.dp),
)
}
}
}
/**
* Pill with the tip tail as a single-path [Shape]: an 8x8 swoosh (apex at the top-start corner,
* concave curve down to the bottom-end the geometry of the legacy `shape_triangular` drawable)
* sitting on a 12dp-rounded rect. One shape means no anti-aliasing seam between tail and pill, no
* double-painting of translucent background tokens, and a ripple clipped to the full silhouette.
*/
private object MessageBubbleShape : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val path = Path.combine(
operation = PathOperation.Union,
path1 = density.pillPath(size),
path2 = density.tipPath(size, layoutDirection),
)
return Outline.Generic(path)
}
private fun Density.pillPath(size: Size): Path = Path().apply {
addRoundRect(
RoundRect(
left = 0f,
top = 8.dp.toPx(),
right = size.width,
bottom = size.height,
cornerRadius = CornerRadius(12.dp.toPx()),
),
)
}
private fun Density.tipPath(size: Size, layoutDirection: LayoutDirection): Path {
val tipWidth = 8.dp.toPx()
val tipHeight = 8.dp.toPx()
// Extends below the tip band, into the pill, to guarantee the union shapes overlap.
val skirtHeight = 2.dp.toPx()
val startX = when (layoutDirection) {
LayoutDirection.Ltr -> 16.dp.toPx()
LayoutDirection.Rtl -> size.width - 16.dp.toPx() - tipWidth
}
// Maps a 0..1 fraction of the tip width to an x coordinate, mirrored in RTL.
fun x(fraction: Float): Float = when (layoutDirection) {
LayoutDirection.Ltr -> startX + fraction * tipWidth
LayoutDirection.Rtl -> startX + (1 - fraction) * tipWidth
}
// Vector drawable path "M8,8 L0,8 L0,0 C0,0 2,6 8,8 Z" in an 8x8 viewport.
return Path().apply {
moveTo(x(1f), tipHeight + skirtHeight)
lineTo(x(0f), tipHeight + skirtHeight)
lineTo(x(0f), 0f)
cubicTo(
x1 = x(0f),
y1 = 0f,
x2 = x(0.25f),
y2 = tipHeight * 0.75f,
x3 = x(1f),
y3 = tipHeight,
)
close()
}
}
}
/** Resolved appearance tokens for a [TangemMessageBubble.Variant]. */
private data class MessageBubbleTokens(val background: Color, val content: Color, val closeIcon: Color)
@Composable
@ReadOnlyComposable
private fun TangemMessageBubble.Variant.tokens(): MessageBubbleTokens {
val colors = TangemTheme.colors3
return when (this) {
TangemMessageBubble.Variant.Neutral -> MessageBubbleTokens(
background = colors.bg.tertiary,
content = colors.text.secondary,
closeIcon = colors.icon.secondary,
)
TangemMessageBubble.Variant.Success -> MessageBubbleTokens(
background = colors.bg.status.successSubtle,
content = colors.text.status.success,
closeIcon = colors.icon.status.success,
)
TangemMessageBubble.Variant.Info -> MessageBubbleTokens(
background = colors.bg.status.infoSubtle,
content = colors.text.status.info,
closeIcon = colors.icon.status.info,
)
}
}
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemMessageBubblePreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TangemMessageBubble.Variant.entries.forEach { variant ->
TangemMessageBubble(
text = stringReference("Description"),
variant = variant,
icon = Icons.ic_info_16,
onClick = {},
onClose = {},
closeContentDescription = "Dismiss",
)
}
TangemMessageBubble(
text = stringReference("Text only"),
showTip = false,
)
TangemMessageBubble(
text = stringReference("No icon"),
variant = TangemMessageBubble.Variant.Info,
onClose = {},
closeContentDescription = "Dismiss",
)
}
}
}

View file

@ -0,0 +1,719 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.tokenrow
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.messagebubble.TangemMessageBubble
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
import com.tangem.core.ui.ds2.util.TangemPriceChange
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chart_bar_vertical_16
import com.tangem.core.ui.res.generated.icons.ic_sign_equal_24
/**
* Design-system v2 (DS3) **Token Row** a portfolio list item: token icon, title with an optional
* badge, quote + price change, fiat/crypto balances and an optional message-bubble promo line
* underneath. DS3 replacement for the legacy `ds/row/token` `TangemTokenRow`.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5723-2473)
*
* @param icon Token icon state, rendered at 40dp. See [TangemTokenIcon.UiState].
* @param title Token name. Single line, ellipsized.
* @param modifier Modifier applied to the row container.
* @param badge Optional [TangemTokenRow.Badge] after the title (e.g. an `"APY 5.47%"` chip; use a
* [TangemBadge.Variant.Solid] badge for the filled look). `null` hides it.
* @param hasPending Shows a small spinner after the title while a transaction is pending.
* @param quote Fiat quote for one token (e.g. `"$1.00"`). `null` hides it.
* @param priceChange Price change indicator next to the quote. `null` hides it.
* @param fiatBalance Primary balance at the end (e.g. `"$583.00"`). `null` hides the line.
* @param cryptoBalance Secondary balance under [fiatBalance] (e.g. `"0,000015 BTC"`). `null` hides it.
* @param showContractWarning Shows the orange contract-error warning before [fiatBalance].
* @param showUpdateWarning Shows the cloud update-error icon before [fiatBalance].
* @param isBalanceHidden When `true`, [fiatBalance] and [cryptoBalance] are masked with stars.
* @param isQuoteFlickering Runs the blade animation over [quote] and [priceChange] while the price
* is being refreshed.
* @param isBalanceFlickering Runs the blade animation over the balances while they are being
* refreshed.
* @param messageBubble Optional slot below the row content pass a [TangemMessageBubble].
* @param onClick Row click handler. `null` with no [onLongClick] makes the row non-interactive.
* @param onLongClick Row long-press handler. `null` disables long-press.
*/
@Suppress("LongParameterList")
@Composable
fun TangemTokenRow(
icon: TangemTokenIcon.UiState,
title: TextReference,
modifier: Modifier = Modifier,
badge: TangemTokenRow.Badge? = null,
hasPending: Boolean = false,
quote: TextReference? = null,
priceChange: TangemPriceChange.State? = null,
fiatBalance: TextReference? = null,
cryptoBalance: TextReference? = null,
showContractWarning: Boolean = false,
showUpdateWarning: Boolean = false,
isBalanceHidden: Boolean = false,
isQuoteFlickering: Boolean = false,
isBalanceFlickering: Boolean = false,
messageBubble: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
TokenRowContainer(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
) {
TokenRowHeadIcon(icon = icon)
TokenRowTitleContent(
title = title,
badge = badge,
hasPending = hasPending,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_TOP)
.padding(end = 8.dp),
)
if (quote != null || priceChange != null) {
TokenRowSubtitleContent(
quote = quote,
priceChange = priceChange,
isFlickering = isQuoteFlickering,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_BOTTOM)
.padding(end = 8.dp),
)
}
if (fiatBalance != null) {
TokenRowBalanceContent(
fiatBalance = fiatBalance,
showContractWarning = showContractWarning,
showUpdateWarning = showUpdateWarning,
isFlickering = isBalanceFlickering,
isBalanceHidden = isBalanceHidden,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.END_TOP),
)
}
if (cryptoBalance != null) {
TokenRowCaptionText(
text = cryptoBalance,
isFlickering = isBalanceFlickering,
isBalanceHidden = isBalanceHidden,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.END_BOTTOM),
)
}
if (messageBubble != null) {
Box(
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.EXTRA_BOTTOM)
.padding(start = 40.dp, bottom = 8.dp)
.fillMaxWidth(),
) {
messageBubble()
}
}
}
}
/** Public API surface of [TangemTokenRow]. */
object TangemTokenRow {
/**
* Badge shown after the title (e.g. an `"APY 5.47%"` chip). Wraps [TangemBadge] so callers can
* pick a tinted or a filled ([TangemBadge.Variant.Solid]) look with a status color, as in
* production usage.
*
* @param text Badge label.
* @param variant Badge appearance [TangemBadge.Variant.Tinted] (default) or
* [TangemBadge.Variant.Solid] for the filled look. See [TangemBadge.Variant].
* @param status Status color scheme. See [TangemBadge.Status].
*/
@Immutable
data class Badge(
val text: TextReference,
val variant: TangemBadge.Variant = TangemBadge.Variant.Tinted,
val status: TangemBadge.Status = TangemBadge.Status.Neutral,
)
/**
* Model of the message bubble rendered under the row content by the [State.Content] overload.
* Mirrors the [TangemMessageBubble] parameters.
*
* @param text Bubble message.
* @param variant Bubble appearance. See [TangemMessageBubble.Variant].
* @param shouldShowTip Whether the tail on top of the bubble is drawn.
* @param icon Leading 16dp bubble icon. `null` hides it.
* @param onClick Invoked when the bubble body is tapped. `null` makes it non-interactive.
* @param onClose Invoked when the bubble close button is tapped. `null` hides the button.
* @param closeContentDescription Accessibility label for the bubble close button.
*/
@Immutable
data class MessageBubble(
val text: TextReference,
val variant: TangemMessageBubble.Variant = TangemMessageBubble.Variant.Neutral,
val shouldShowTip: Boolean = true,
val icon: ImageVector? = null,
val onClick: (() -> Unit)? = null,
val onClose: (() -> Unit)? = null,
val closeContentDescription: String? = null,
)
/**
* State model of [TangemTokenRow] one subtype per Figma variant. Render it with the
* `TangemTokenRow(state = )` overload.
*/
@Immutable
sealed class State {
/** Unique id, e.g. for `LazyColumn` item keys. */
abstract val id: String
/**
* Default variant balances, optional badge and an optional message bubble.
*
* @param id Unique id.
* @param icon Token icon state.
* @param title Token name.
* @param badge Badge after the title. `null` hides it. See [Badge].
* @param hasPending Shows a small spinner after the title while a transaction is pending.
* @param quote Fiat quote for one token. `null` hides it.
* @param priceChange Price change indicator next to the quote. `null` hides it.
* @param fiatBalance Primary balance at the end. `null` hides the line.
* @param cryptoBalance Secondary balance under [fiatBalance]. `null` hides it.
* @param shouldShowContractWarning Shows the contract-error warning before [fiatBalance].
* @param shouldShowUpdateWarning Shows the update-error icon before [fiatBalance].
* @param isQuoteFlickering Runs the blade animation over the quote and price change while
* the price is being refreshed.
* @param isBalanceFlickering Runs the blade animation over the balances while they are
* being refreshed.
* @param messageBubble Message bubble under the row content. `null` hides it.
* @param onClick Row click handler. `null` with no [onLongClick] makes the row
* non-interactive.
* @param onLongClick Row long-press handler. `null` disables long-press.
*/
data class Content(
override val id: String,
val icon: TangemTokenIcon.UiState,
val title: TextReference,
val badge: Badge? = null,
val hasPending: Boolean = false,
val quote: TextReference? = null,
val priceChange: TangemPriceChange.State? = null,
val fiatBalance: TextReference? = null,
val cryptoBalance: TextReference? = null,
val shouldShowContractWarning: Boolean = false,
val shouldShowUpdateWarning: Boolean = false,
val isQuoteFlickering: Boolean = false,
val isBalanceFlickering: Boolean = false,
val messageBubble: MessageBubble? = null,
val onClick: (() -> Unit)? = null,
val onLongClick: (() -> Unit)? = null,
) : State()
/**
* Organize (reorder) variant ticker after the title, fiat balance underneath, drag
* handle at the end. Pass the reorder modifier via `dragHandleModifier` of the overload.
*
* @param id Unique id.
* @param icon Token icon state.
* @param title Token name.
* @param ticker Currency ticker after the title. `null` hides it.
* @param fiatBalance Balance line under the title. `null` hides it.
*/
data class Organize(
override val id: String,
val icon: TangemTokenIcon.UiState,
val title: TextReference,
val ticker: TextReference? = null,
val fiatBalance: TextReference? = null,
) : State()
/**
* Unreachable variant dimmed leading texts and a warning badge instead of balances.
*
* @param id Unique id.
* @param icon Token icon state.
* @param title Token name, dimmed.
* @param badge Localized badge label (e.g. `"Unreachable"`).
* @param quote Fiat quote, dimmed. `null` hides it.
* @param priceChange Price change indicator, dimmed. `null` hides it.
* @param onClick Row click handler. `null` with no [onLongClick] makes the row
* non-interactive.
* @param onLongClick Row long-press handler. `null` disables long-press.
*/
data class Unreachable(
override val id: String,
val icon: TangemTokenIcon.UiState,
val title: TextReference,
val badge: TextReference,
val quote: TextReference? = null,
val priceChange: TangemPriceChange.State? = null,
val onClick: (() -> Unit)? = null,
val onLongClick: (() -> Unit)? = null,
) : State()
/**
* No-address variant a tertiary message instead of balances.
*
* @param id Unique id.
* @param icon Token icon state.
* @param title Token name.
* @param message Localized end message (e.g. `"No address"`).
* @param quote Fiat quote. `null` hides it.
* @param priceChange Price change indicator. `null` hides it.
* @param onClick Row click handler. `null` with no [onLongClick] makes the row
* non-interactive.
* @param onLongClick Row long-press handler. `null` disables long-press.
*/
data class NoAddress(
override val id: String,
val icon: TangemTokenIcon.UiState,
val title: TextReference,
val message: TextReference,
val quote: TextReference? = null,
val priceChange: TangemPriceChange.State? = null,
val onClick: (() -> Unit)? = null,
val onLongClick: (() -> Unit)? = null,
) : State()
/**
* Loading variant icon and text-line shimmers.
*
* @param id Unique id.
*/
data class Shimmer(
override val id: String,
) : State()
}
}
/**
* Design-system v2 (DS3) **Token Row** state-driven overload: renders the variant described by
* [TangemTokenRow.State].
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5723-2473)
*
* @param state Row state model. See [TangemTokenRow.State].
* @param modifier Modifier applied to the row container.
* @param isBalanceHidden When `true`, balances are masked with stars. Kept outside [state] because
* it is an app-wide setting, like in the legacy DS2 row.
* @param dragHandleModifier Modifier applied to the drag-handle icon of the
* [TangemTokenRow.State.Organize] variant (e.g. a reorderable drag-handle modifier); ignored by
* other variants.
*/
@Composable
fun TangemTokenRow(
state: TangemTokenRow.State,
modifier: Modifier = Modifier,
isBalanceHidden: Boolean = false,
dragHandleModifier: Modifier = Modifier,
) {
when (state) {
is TangemTokenRow.State.Content -> TangemTokenRow(
icon = state.icon,
title = state.title,
modifier = modifier,
badge = state.badge,
hasPending = state.hasPending,
quote = state.quote,
priceChange = state.priceChange,
fiatBalance = state.fiatBalance,
cryptoBalance = state.cryptoBalance,
showContractWarning = state.shouldShowContractWarning,
showUpdateWarning = state.shouldShowUpdateWarning,
isBalanceHidden = isBalanceHidden,
isQuoteFlickering = state.isQuoteFlickering,
isBalanceFlickering = state.isBalanceFlickering,
messageBubble = state.messageBubble?.let { bubble -> { TokenRowMessageBubble(bubble = bubble) } },
onClick = state.onClick,
onLongClick = state.onLongClick,
)
is TangemTokenRow.State.Organize -> TangemTokenRow.Organize(
icon = state.icon,
title = state.title,
modifier = modifier,
ticker = state.ticker,
fiatBalance = state.fiatBalance,
isBalanceHidden = isBalanceHidden,
dragHandleModifier = dragHandleModifier,
)
is TangemTokenRow.State.Unreachable -> TangemTokenRow.Unreachable(
icon = state.icon,
title = state.title,
badge = state.badge,
modifier = modifier,
quote = state.quote,
priceChange = state.priceChange,
onClick = state.onClick,
onLongClick = state.onLongClick,
)
is TangemTokenRow.State.NoAddress -> TangemTokenRow.NoAddress(
icon = state.icon,
title = state.title,
message = state.message,
modifier = modifier,
quote = state.quote,
priceChange = state.priceChange,
onClick = state.onClick,
onLongClick = state.onLongClick,
)
is TangemTokenRow.State.Shimmer -> TangemTokenRow.Shimmer(modifier = modifier)
}
}
/** Renders the [TangemTokenRow.MessageBubble] model as a [TangemMessageBubble]. */
@Composable
private fun TokenRowMessageBubble(bubble: TangemTokenRow.MessageBubble) {
TangemMessageBubble(
text = bubble.text,
variant = bubble.variant,
showTip = bubble.shouldShowTip,
icon = bubble.icon,
onClick = bubble.onClick,
onClose = bubble.onClose,
closeContentDescription = bubble.closeContentDescription,
)
}
/**
* Design-system v2 (DS3) **Token Row / Organize** reorder mode: title with a ticker, fiat balance
* underneath and a drag handle at the end.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5723-2473)
*
* @param icon Token icon state, rendered at 40dp.
* @param title Token name. Single line, ellipsized.
* @param modifier Modifier applied to the row container.
* @param ticker Currency ticker after the title (e.g. `"BTC"`), baseline-aligned. `null` hides it.
* @param fiatBalance Balance line under the title (e.g. `"$583.00"`). `null` hides it.
* @param isBalanceHidden When `true`, [fiatBalance] is masked with stars.
* @param dragHandleModifier Modifier applied to the drag-handle icon pass the reorderable
* drag-handle modifier here to make the row draggable.
*/
@Composable
fun TangemTokenRow.Organize(
icon: TangemTokenIcon.UiState,
title: TextReference,
modifier: Modifier = Modifier,
ticker: TextReference? = null,
fiatBalance: TextReference? = null,
isBalanceHidden: Boolean = false,
dragHandleModifier: Modifier = Modifier,
) {
TokenRowContainer(modifier = modifier) {
TokenRowHeadIcon(icon = icon)
TokenRowTitleContent(
title = title,
ticker = ticker,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_TOP)
.padding(end = 8.dp),
)
if (fiatBalance != null) {
TokenRowCaptionText(
text = fiatBalance,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_BOTTOM)
.padding(end = 8.dp),
)
}
Box(
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.TAIL)
.padding(start = 8.dp)
.size(24.dp)
.then(dragHandleModifier),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.ic_sign_equal_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.tertiary,
)
}
}
}
/**
* Design-system v2 (DS3) **Token Row / Unreachable** network-error state: leading texts are
* dimmed to the disabled opacity and a warning badge replaces the balances.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5723-2473)
*
* @param icon Token icon state, rendered at 40dp.
* @param title Token name, dimmed. Single line, ellipsized.
* @param badge Localized badge label (e.g. `"Unreachable"`), shown as a warning-tinted badge.
* @param modifier Modifier applied to the row container.
* @param quote Fiat quote, dimmed. `null` hides it.
* @param priceChange Price change indicator, dimmed. `null` hides it.
* @param onClick Row click handler. `null` with no [onLongClick] makes the row non-interactive.
* @param onLongClick Row long-press handler. `null` disables long-press.
*/
@Suppress("LongParameterList")
@Composable
fun TangemTokenRow.Unreachable(
icon: TangemTokenIcon.UiState,
title: TextReference,
badge: TextReference,
modifier: Modifier = Modifier,
quote: TextReference? = null,
priceChange: TangemPriceChange.State? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
TokenRowContainer(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
) {
TokenRowHeadIcon(icon = icon)
TokenRowTitleContent(
title = title,
isDimmed = true,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_TOP)
.padding(end = 8.dp),
)
if (quote != null || priceChange != null) {
TokenRowSubtitleContent(
quote = quote,
priceChange = priceChange,
isDimmed = true,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_BOTTOM)
.padding(end = 8.dp),
)
}
// A single end child has no bottom counterpart, so the container centers it vertically.
TangemBadge(
text = badge,
variant = TangemBadge.Variant.Tinted,
status = TangemBadge.Status.Warning,
size = TangemBadge.Size.X6,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.END_TOP),
)
}
}
/**
* Design-system v2 (DS3) **Token Row / No Address** missing-derivation state: a tertiary message
* replaces the balances.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5723-2473)
*
* @param icon Token icon state, rendered at 40dp.
* @param title Token name. Single line, ellipsized.
* @param message Localized end message (e.g. `"No address"`), body typography in tertiary color.
* @param modifier Modifier applied to the row container.
* @param quote Fiat quote. `null` hides it.
* @param priceChange Price change indicator. `null` hides it.
* @param onClick Row click handler. `null` with no [onLongClick] makes the row non-interactive.
* @param onLongClick Row long-press handler. `null` disables long-press.
*/
@Suppress("LongParameterList")
@Composable
fun TangemTokenRow.NoAddress(
icon: TangemTokenIcon.UiState,
title: TextReference,
message: TextReference,
modifier: Modifier = Modifier,
quote: TextReference? = null,
priceChange: TangemPriceChange.State? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
TokenRowContainer(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
) {
TokenRowHeadIcon(icon = icon)
TokenRowTitleContent(
title = title,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_TOP)
.padding(end = 8.dp),
)
if (quote != null || priceChange != null) {
TokenRowSubtitleContent(
quote = quote,
priceChange = priceChange,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_BOTTOM)
.padding(end = 8.dp),
)
}
// A single end child has no bottom counterpart, so the container centers it vertically.
Text(
text = message.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.tertiary,
maxLines = 1,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.END_TOP),
)
}
}
/**
* Design-system v2 (DS3) **Token Row / Shimmer** loading placeholder: circular icon shimmer plus
* text-line bars on both sides.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5723-2473)
*
* @param modifier Modifier applied to the row container.
*/
@Composable
fun TangemTokenRow.Shimmer(modifier: Modifier = Modifier) {
TokenRowContainer(modifier = modifier) {
TokenRowHeadIcon(icon = TangemTokenIcon.UiState.Shimmer)
TokenRowShimmerLine(
style = TangemTheme.typography3.body.medium,
width = 72.dp,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.START_TOP),
)
TokenRowShimmerLine(
style = TangemTheme.typography3.caption.medium,
width = 44.dp,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.START_BOTTOM),
)
TokenRowShimmerLine(
style = TangemTheme.typography3.body.medium,
width = 72.dp,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.END_TOP),
)
TokenRowShimmerLine(
style = TangemTheme.typography3.caption.medium,
width = 44.dp,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.END_BOTTOM),
)
}
}
// region Previews
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(
name = "Dark",
showBackground = true,
widthDp = 360,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
)
@Composable
private fun TangemTokenRowPreview() {
PreviewContainer { icon ->
TangemTokenRow(
icon = icon,
title = stringReference("Bitcoin"),
badge = TangemTokenRow.Badge(
text = stringReference("APY 5.47%"),
variant = TangemBadge.Variant.Solid,
status = TangemBadge.Status.Info,
),
quote = stringReference("$1.00"),
priceChange = TangemPriceChange.State(
value = stringReference("2.08%"),
direction = TangemPriceChange.Direction.Up,
),
fiatBalance = stringReference("$583.00"),
cryptoBalance = stringReference("0,000015 BTC"),
showContractWarning = true,
showUpdateWarning = true,
messageBubble = {
TangemMessageBubble(
text = stringReference("Enable 5.47% APY on your balance"),
variant = TangemMessageBubble.Variant.Success,
icon = Icons.ic_chart_bar_vertical_16,
onClick = {},
onClose = {},
closeContentDescription = "Dismiss",
)
},
onClick = {},
)
TangemTokenRow(
icon = icon,
title = stringReference("Bitcoin"),
quote = stringReference("$1.00"),
priceChange = TangemPriceChange.State(
value = stringReference("0.4%"),
direction = TangemPriceChange.Direction.Down,
),
fiatBalance = stringReference("$583.00"),
cryptoBalance = stringReference("0,000015 BTC"),
onClick = {},
)
TangemTokenRow.Organize(
icon = icon,
title = stringReference("Bitcoin"),
ticker = stringReference("BTC"),
fiatBalance = stringReference("$583.00"),
)
}
}
@Preview(name = "States Light", showBackground = true, widthDp = 360)
@Preview(
name = "States Dark",
showBackground = true,
widthDp = 360,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
)
@Composable
private fun TangemTokenRowStatesPreview() {
PreviewContainer { icon ->
TangemTokenRow.Unreachable(
icon = icon,
title = stringReference("Bitcoin"),
badge = stringReference("Unreachable"),
quote = stringReference("$1.00"),
priceChange = TangemPriceChange.State(
value = stringReference("2.08%"),
direction = TangemPriceChange.Direction.Up,
),
)
TangemTokenRow.NoAddress(
icon = icon,
title = stringReference("Bitcoin"),
message = stringReference("No address"),
quote = stringReference("$1.00"),
priceChange = TangemPriceChange.State(
value = stringReference("2.08%"),
direction = TangemPriceChange.Direction.Neutral,
),
)
TangemTokenRow.Shimmer()
}
}
@Composable
private fun PreviewContainer(content: @Composable (icon: TangemTokenIcon.UiState) -> Unit) {
val icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = null))
TangemThemePreviewRedesign {
Column(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
content(icon)
}
}
}
// endregion

View file

@ -0,0 +1,538 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.tokenrow
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
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.width
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.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
import com.tangem.core.ui.ds2.util.TangemPriceChange
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cloud_exclamation_20
import com.tangem.core.ui.res.generated.icons.ic_warning_20
import kotlin.math.max
/** Content dimming applied to the leading texts of unavailable rows (Figma `opacity/disabled`). */
internal const val TOKEN_ROW_DISABLED_ALPHA = 0.4f
// region Container layout
/** Slot ids of the [TokenRowContainer] custom layout. */
internal enum class TokenRowLayoutId {
HEAD, START_TOP, END_TOP, START_BOTTOM, END_BOTTOM, TAIL, EXTRA_BOTTOM
}
/**
* Token-row container
*
* Policy:
* - HEAD and TAIL are measured first and take their intrinsic width.
* - END slots take the free space left after guaranteeing the START side its minimum width
* (30% of the row for the top line, 32% for the bottom line).
* - START slots fill the remaining width, never shrinking below that minimum.
* - A line with no counterpart on the other axis is vertically centered in the main area.
* - EXTRA_BOTTOM is placed full-width below the main content with an 8dp gap.
*/
@Suppress("LongMethod")
@Composable
internal fun TokenRowContainer(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val isInteractive = onClick != null || onLongClick != null
val density = LocalDensity.current
val verticalPadding = with(density) { 4.dp.roundToPx() }
val extraContentPadding = with(density) { 8.dp.roundToPx() }
val contentPadding = with(density) { 12.dp.roundToPx() }
val rowModifier = modifier
.conditionalCompose(isFocused) {
border(
width = 2.dp,
color = TangemTheme.colors3.interaction.focusRing.brand,
shape = RoundedCornerShape(4.dp),
)
}
.conditionalCompose(isInteractive) {
combinedClickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
role = Role.Button,
onLongClick = onLongClick,
onClick = onClick ?: {},
)
}
WithTokenRowRipple(enabled = isInteractive) {
Layout(
content = content,
modifier = rowModifier,
) { measurables, constraints ->
val layoutWidth = max(0, constraints.maxWidth - contentPadding * 2)
val startTopMinWidth = (layoutWidth * TITLE_MIN_WIDTH_COEFFICIENT).toInt()
val startBottomMinWidth = (layoutWidth * PRICE_MIN_WIDTH_COEFFICIENT).toInt()
val headPlaceable = measurables.measure(
layoutId = TokenRowLayoutId.HEAD,
constraints = constraints.copy(minWidth = 0),
)
val tailPlaceable = measurables.measure(
layoutId = TokenRowLayoutId.TAIL,
constraints = constraints.copy(minWidth = 0),
)
val availableWidthForBody = layoutWidth - headPlaceable.widthOrZero() - tailPlaceable.widthOrZero()
// End slots take the whole free space but always leave the start side its minimum width.
val endTopPlaceable = measurables.measure(
layoutId = TokenRowLayoutId.END_TOP,
constraints = constraints.copy(
minWidth = 0,
maxWidth = max(0, availableWidthForBody - startTopMinWidth),
),
)
val endBottomPlaceable = measurables.measure(
layoutId = TokenRowLayoutId.END_BOTTOM,
constraints = constraints.copy(
minWidth = 0,
maxWidth = max(0, availableWidthForBody - startBottomMinWidth),
),
)
// Start slots fill the remaining width but never less than their minimum.
val startTopPlaceable = measurables.measure(
layoutId = TokenRowLayoutId.START_TOP,
constraints = constraints.copy(
minWidth = 0,
maxWidth = max(
a = startTopMinWidth,
b = availableWidthForBody - endTopPlaceable.widthOrZero(),
),
),
)
val startBottomPlaceable = measurables.measure(
layoutId = TokenRowLayoutId.START_BOTTOM,
constraints = constraints.copy(
minWidth = 0,
maxWidth = max(
a = startBottomMinWidth,
b = availableWidthForBody - endBottomPlaceable.widthOrZero(),
),
),
)
val extraBottomPlaceable = measurables.measure(
layoutId = TokenRowLayoutId.EXTRA_BOTTOM,
constraints = constraints,
)
val mainLayoutHeight = maxOf(
headPlaceable.heightOrZero(),
tailPlaceable.heightOrZero(),
startTopPlaceable.heightOrZero() + startBottomPlaceable.heightOrZero() + verticalPadding,
endTopPlaceable.heightOrZero() + endBottomPlaceable.heightOrZero() + verticalPadding,
)
val mainContentBottomPadding = if (extraBottomPlaceable != null) {
extraBottomPlaceable.heightOrZero() + contentPadding
} else {
contentPadding
}
val layoutHeight = mainLayoutHeight + contentPadding + mainContentBottomPadding
layout(width = constraints.maxWidth, height = layoutHeight) {
headPlaceable?.placeRelative(
x = contentPadding,
y = contentPadding + (mainLayoutHeight - headPlaceable.height).div(other = 2),
)
startTopPlaceable?.placeRelative(
x = contentPadding + headPlaceable.widthOrZero(),
y = contentPadding + if (startBottomPlaceable == null) {
(mainLayoutHeight - startTopPlaceable.height).div(2)
} else {
0
},
)
startBottomPlaceable?.placeRelative(
x = contentPadding + headPlaceable.widthOrZero(),
y = contentPadding + if (startTopPlaceable == null) {
(mainLayoutHeight - startBottomPlaceable.height).div(2)
} else {
startTopPlaceable.heightOrZero() + verticalPadding
},
)
endTopPlaceable?.placeRelative(
x = layoutWidth - endTopPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + contentPadding,
y = contentPadding + if (endBottomPlaceable == null) {
(mainLayoutHeight - endTopPlaceable.height).div(2)
} else {
0
},
)
endBottomPlaceable?.placeRelative(
x = layoutWidth - endBottomPlaceable.widthOrZero() - tailPlaceable.widthOrZero() + contentPadding,
y = contentPadding + if (endTopPlaceable == null) {
(mainLayoutHeight - endBottomPlaceable.height).div(2)
} else {
endTopPlaceable.heightOrZero() + verticalPadding
},
)
tailPlaceable?.placeRelative(
x = layoutWidth - tailPlaceable.width + contentPadding,
y = contentPadding + (mainLayoutHeight - tailPlaceable.height).div(other = 2),
)
extraBottomPlaceable?.placeRelative(
x = 0,
y = contentPadding + mainLayoutHeight + extraContentPadding,
)
}
}
}
}
private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.3
private const val PRICE_MIN_WIDTH_COEFFICIENT = 0.32
private fun List<Measurable>.measure(layoutId: TokenRowLayoutId, constraints: Constraints): Placeable? {
return firstOrNull { it.layoutId == layoutId }?.measure(constraints)
}
private fun Placeable?.widthOrZero(): Int = this?.width ?: 0
private fun Placeable?.heightOrZero(): Int = this?.height ?: 0
@Composable
private fun WithTokenRowRipple(enabled: Boolean, content: @Composable () -> Unit) {
if (enabled) {
CompositionLocalProvider(LocalRippleConfiguration provides tokenRowRipple(), content = content)
} else {
content()
}
}
@Composable
@ReadOnlyComposable
private fun tokenRowRipple(): RippleConfiguration = RippleConfiguration(
color = TangemTheme.colors3.interaction.press.default,
rippleAlpha = RippleAlpha(
draggedAlpha = 0f,
focusedAlpha = 0f,
hoveredAlpha = 0.05f,
pressedAlpha = 0.1f,
),
)
// endregion
// region Slot contents
/** Head slot: 40dp token icon with the 12dp gap to the content, like the DS2 row. */
@Composable
internal fun TokenRowHeadIcon(icon: TangemTokenIcon.UiState) {
TangemTokenIcon(
state = icon,
size = TangemTokenIcon.Size.X40,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.HEAD)
.padding(end = 12.dp),
)
}
/** Market subtitle line: rank badge (e.g. `2`) and capitalization (e.g. `1.196T`). */
@Composable
internal fun TokenRowMarketSubtitleContent(
position: TextReference?,
capitalization: TextReference?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (position != null) {
TangemBadge(
text = position,
variant = TangemBadge.Variant.Tinted,
status = TangemBadge.Status.Neutral,
size = TangemBadge.Size.X4,
)
}
if (capitalization != null) {
Text(
text = capitalization.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* Market price line. When [updateDirection] is set, the text flashes in the direction color and
* fades back to primary every time [price] changes ports the live-update blink of the legacy
* `TokenPriceText`. The first composition never blinks.
*/
@Composable
internal fun TokenRowMarketPriceContent(
price: TextReference,
modifier: Modifier = Modifier,
updateDirection: TangemPriceChange.Direction? = null,
) {
val generalColor = TangemTheme.colors3.text.primary
val growColor = TangemTheme.colors3.text.accent.blue
val fallColor = TangemTheme.colors3.text.status.error
val color = remember(generalColor) { Animatable(generalColor) }
var isFirstEmissionSkipped by remember { mutableStateOf(false) }
LaunchedEffect(price) {
if (!isFirstEmissionSkipped) {
isFirstEmissionSkipped = true
return@LaunchedEffect
}
val blinkColor = when (updateDirection) {
TangemPriceChange.Direction.Up -> growColor
TangemPriceChange.Direction.Down -> fallColor
TangemPriceChange.Direction.Neutral, null -> return@LaunchedEffect
}
color.animateTo(blinkColor, snap())
color.animateTo(generalColor, tween(durationMillis = PRICE_BLINK_FADE_DURATION_MILLIS))
}
Text(
text = price.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = color.value,
maxLines = 1,
overflow = TextOverflow.Visible,
modifier = modifier,
)
}
private const val PRICE_BLINK_FADE_DURATION_MILLIS = 500
/** Title line: token name, optional pending-transaction loader, ticker (baseline-aligned), badge. */
@Composable
internal fun TokenRowTitleContent(
title: TextReference,
modifier: Modifier = Modifier,
ticker: TextReference? = null,
badge: TangemTokenRow.Badge? = null,
hasPending: Boolean = false,
isDimmed: Boolean = false,
) {
Row(
modifier = modifier.alpha(if (isDimmed) TOKEN_ROW_DISABLED_ALPHA else 1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(weight = 1f, fill = false)
.alignByBaseline(),
)
if (hasPending) {
TangemLoader(
size = TangemLoaderSize.X16,
color = TangemTheme.colors3.icon.tertiary,
)
}
if (ticker != null) {
Text(
text = ticker.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
modifier = Modifier.alignByBaseline(),
)
}
if (badge != null) {
TangemBadge(
text = badge.text,
variant = badge.variant,
status = badge.status,
size = TangemBadge.Size.X4,
)
}
}
}
/** Subtitle line: quote (e.g. `$1.00`) and the [TangemPriceChange] indicator. */
@Composable
internal fun TokenRowSubtitleContent(
quote: TextReference?,
priceChange: TangemPriceChange.State?,
modifier: Modifier = Modifier,
isDimmed: Boolean = false,
isFlickering: Boolean = false,
) {
Row(
modifier = modifier.alpha(if (isDimmed) TOKEN_ROW_DISABLED_ALPHA else 1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (quote != null) {
Text(
text = quote.resolveReference(),
style = TangemTheme.typography3.caption.medium.applyBladeBrush(
isEnabled = isFlickering,
textColor = TangemTheme.colors3.text.secondary,
),
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (priceChange != null) {
TangemPriceChange(state = priceChange, isFlickering = isFlickering)
}
}
}
/** Fiat balance with optional contract-error and update-error icons in front of it. */
@Composable
internal fun TokenRowBalanceContent(
fiatBalance: TextReference,
modifier: Modifier = Modifier,
showContractWarning: Boolean = false,
showUpdateWarning: Boolean = false,
isFlickering: Boolean = false,
isBalanceHidden: Boolean = false,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showContractWarning) {
Icon(
imageVector = Icons.ic_warning_20,
contentDescription = null,
tint = TangemTheme.colors3.icon.status.warning,
modifier = Modifier.size(20.dp),
)
}
if (showUpdateWarning) {
Icon(
imageVector = Icons.ic_cloud_exclamation_20,
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
modifier = Modifier.size(20.dp),
)
}
Text(
text = fiatBalance.orMaskWithStars(isBalanceHidden).resolveReference(),
style = TangemTheme.typography3.body.medium.applyBladeBrush(
isEnabled = isFlickering,
textColor = TangemTheme.colors3.text.primary,
),
color = TangemTheme.colors3.text.primary,
maxLines = 1,
)
}
}
/** Plain caption line used for secondary amounts (crypto balance, Organize fiat balance). */
@Composable
internal fun TokenRowCaptionText(
text: TextReference,
modifier: Modifier = Modifier,
isFlickering: Boolean = false,
isBalanceHidden: Boolean = false,
) {
Text(
text = text.orMaskWithStars(isBalanceHidden).resolveReference(),
style = TangemTheme.typography3.caption.medium.applyBladeBrush(
isEnabled = isFlickering,
textColor = TangemTheme.colors3.text.secondary,
),
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
modifier = modifier,
)
}
/** Fixed-width shimmer bar sized after a typography line, like the [TangemShimmer] text overload. */
@Composable
internal fun TokenRowShimmerLine(style: TextStyle, width: Dp, modifier: Modifier = Modifier) {
val lineHeight = with(LocalDensity.current) { style.lineHeight.toDp() }
TangemShimmer(
radius = 16.dp,
modifier = modifier
.width(width)
.height(lineHeight)
.padding(vertical = 2.dp),
)
}
// endregion

View file

@ -0,0 +1,315 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.tokenrow
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
import com.tangem.core.ui.ds2.util.TangemPriceChange
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 (DS3) **Token Row Market** a markets-list item: token icon, title with a
* ticker, rank badge + capitalization, price with a [TangemPriceChange] indicator and a trailing
* mini-graph slot.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5724-3510&m=dev)
*
* @param icon Token icon state, rendered at 40dp. See [TangemTokenIcon.UiState].
* @param title Token name. Single line, ellipsized.
* @param modifier Modifier applied to the row container.
* @param ticker Currency ticker after the title (e.g. `"BTC"`), baseline-aligned. `null` hides it.
* @param position Market-rank badge label (e.g. `"2"`). `null` hides the badge (Figma `Position`).
* @param capitalization Market capitalization text (e.g. `"1.196T"`). `null` hides it.
* @param price Current price at the end (e.g. `"$59,723.24"`). `null` hides the line.
* @param priceChange Price change indicator under the [price]. `null` hides it.
* @param priceUpdateDirection Direction of the latest live price update. When set, the [price]
* text flashes in the direction color and fades back each time [price] changes. `null` disables
* the flash.
* @param chart Trailing mini-graph slot, vertically centered. `null` hides it.
* @param onClick Row click handler. `null` makes the row non-interactive (no ripple/focus).
*/
@Suppress("LongParameterList")
@Composable
fun TangemTokenRowMarket(
icon: TangemTokenIcon.UiState,
title: TextReference,
modifier: Modifier = Modifier,
ticker: TextReference? = null,
position: TextReference? = null,
capitalization: TextReference? = null,
price: TextReference? = null,
priceChange: TangemPriceChange.State? = null,
priceUpdateDirection: TangemPriceChange.Direction? = null,
chart: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
TokenRowContainer(
modifier = modifier,
onClick = onClick,
) {
TangemTokenIcon(
state = icon,
size = TangemTokenIcon.Size.X40,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.HEAD),
)
TokenRowTitleContent(
title = title,
ticker = ticker,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_TOP)
.padding(start = 12.dp),
)
if (position != null || capitalization != null) {
TokenRowMarketSubtitleContent(
position = position,
capitalization = capitalization,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_BOTTOM)
.padding(start = 12.dp),
)
}
if (price != null) {
TokenRowMarketPriceContent(
price = price,
updateDirection = priceUpdateDirection,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.END_TOP)
.padding(start = 12.dp),
)
}
if (priceChange != null) {
TangemPriceChange(
state = priceChange,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.END_BOTTOM)
.padding(start = 12.dp),
)
}
if (chart != null) {
Box(
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.TAIL)
.padding(start = 12.dp),
) {
chart()
}
}
}
}
/**
* Design-system v2 (DS3) **Token Row Market** state-driven overload: renders the variant
* described by [TangemTokenRowMarket.State].
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5724-3510&m=dev)
*
* @param state Row state model. See [TangemTokenRowMarket.State].
* @param modifier Modifier applied to the row container.
* @param chart Trailing mini-graph slot; only used by [TangemTokenRowMarket.State.Content].
* Stays a slot (not a state field) because the chart component lives in `:common:ui-charts`.
*/
@Composable
fun TangemTokenRowMarket(
state: TangemTokenRowMarket.State,
modifier: Modifier = Modifier,
chart: (@Composable () -> Unit)? = null,
) {
when (state) {
is TangemTokenRowMarket.State.Content -> TangemTokenRowMarket(
icon = state.icon,
title = state.title,
modifier = modifier,
ticker = state.ticker,
position = state.position,
capitalization = state.capitalization,
price = state.price,
priceChange = state.priceChange,
priceUpdateDirection = state.priceUpdateDirection,
chart = chart,
onClick = state.onClick,
)
is TangemTokenRowMarket.State.Shimmer -> TangemTokenRowMarket.Shimmer(modifier = modifier)
}
}
/** Public API surface of [TangemTokenRowMarket]. */
object TangemTokenRowMarket {
/**
* State model of [TangemTokenRowMarket]. Render it with the `TangemTokenRowMarket(state = )`
* overload.
*/
@Immutable
sealed class State {
/** Unique id, e.g. for `LazyColumn` item keys. */
abstract val id: String
/**
* Loaded market row.
*
* @param id Unique id.
* @param icon Token icon state.
* @param title Token name.
* @param ticker Currency ticker after the title. `null` hides it.
* @param position Market-rank badge label. `null` hides the badge.
* @param capitalization Market capitalization text. `null` hides it.
* @param price Current price at the end. `null` hides the line.
* @param priceChange Price change indicator under the price. `null` hides it.
* @param priceUpdateDirection Direction of the latest live price update the price text
* flashes in this color each time [price] changes. `null` disables the flash.
* @param onClick Row click handler. `null` makes the row non-interactive.
*/
data class Content(
override val id: String,
val icon: TangemTokenIcon.UiState,
val title: TextReference,
val ticker: TextReference? = null,
val position: TextReference? = null,
val capitalization: TextReference? = null,
val price: TextReference? = null,
val priceChange: TangemPriceChange.State? = null,
val priceUpdateDirection: TangemPriceChange.Direction? = null,
val onClick: (() -> Unit)? = null,
) : State()
/**
* Loading variant icon and text-line shimmers.
*
* @param id Unique id.
*/
data class Shimmer(
override val id: String,
) : State()
}
}
/**
* Design-system v2 (DS3) **Token Row Market / Shimmer** loading placeholder: circular icon
* shimmer, text-line bars on both sides and a graph-sized bar at the end.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5724-3510&m=dev)
*
* @param modifier Modifier applied to the row container.
*/
@Composable
fun TangemTokenRowMarket.Shimmer(modifier: Modifier = Modifier) {
TokenRowContainer(modifier = modifier) {
TangemTokenIcon(
state = TangemTokenIcon.UiState.Shimmer,
size = TangemTokenIcon.Size.X40,
modifier = Modifier.layoutId(layoutId = TokenRowLayoutId.HEAD),
)
TokenRowShimmerLine(
style = TangemTheme.typography3.body.medium,
width = 72.dp,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_TOP)
.padding(start = 12.dp),
)
TokenRowShimmerLine(
style = TangemTheme.typography3.caption.medium,
width = 44.dp,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.START_BOTTOM)
.padding(start = 12.dp),
)
TokenRowShimmerLine(
style = TangemTheme.typography3.body.medium,
width = 72.dp,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.END_TOP)
.padding(start = 12.dp),
)
TokenRowShimmerLine(
style = TangemTheme.typography3.caption.medium,
width = 44.dp,
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.END_BOTTOM)
.padding(start = 12.dp),
)
// Graph placeholder — a small bar centered in the 24x32 graph slot.
Box(
modifier = Modifier
.layoutId(layoutId = TokenRowLayoutId.TAIL)
.padding(start = 12.dp)
.size(width = 24.dp, height = 32.dp),
contentAlignment = Alignment.Center,
) {
TangemShimmer(
radius = 4.dp,
modifier = Modifier.size(width = 24.dp, height = 12.dp),
)
}
}
}
// region Previews
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(
name = "Dark",
showBackground = true,
widthDp = 360,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
)
@Composable
private fun TangemTokenRowMarketPreview() {
TangemThemePreviewRedesign {
Column(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
TangemTokenRowMarket(
icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = null)),
title = stringReference("Bitcoin"),
ticker = stringReference("BTC"),
position = stringReference("2"),
capitalization = stringReference("1.196T"),
price = stringReference("$59,723.24"),
priceChange = TangemPriceChange.State(
value = stringReference("2.08%"),
direction = TangemPriceChange.Direction.Up,
),
chart = { PreviewChartPlaceholder() },
onClick = {},
)
TangemTokenRowMarket(
icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = null)),
title = stringReference("Very Long Token Name Coin"),
ticker = stringReference("VLTNC"),
capitalization = stringReference("796.9B"),
price = stringReference("$2,591.65"),
priceChange = TangemPriceChange.State(
value = stringReference("0.42%"),
direction = TangemPriceChange.Direction.Down,
),
onClick = {},
)
TangemTokenRowMarket.Shimmer()
}
}
}
@Composable
private fun PreviewChartPlaceholder() {
Box(
modifier = Modifier
.size(width = 24.dp, height = 32.dp)
.background(TangemTheme.colors3.bg.tertiary),
)
}
// endregion

View file

@ -0,0 +1,166 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.util
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_dot_12_filled
import com.tangem.core.ui.res.generated.icons.ic_triangle_down_12
import com.tangem.core.ui.res.generated.icons.ic_triangle_up_12
/**
* Design-system v2 (DS3) **Util / Price Change** a compact directional indicator: an up/down
* triangle (or a dot for [TangemPriceChange.Direction.Neutral]) and a percent label colored by
* [direction]. A "util" building block, not a complete component embed it next to quotes and
* balances (e.g. inside the token row).
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5724-3922&m=dev)
*
* @param value Formatted percent text (e.g. `"2.08%"`).
* @param direction Change direction driving the arrow and its color. See
* [TangemPriceChange.Direction].
* @param modifier Modifier applied to the indicator root.
* @param isFlickering Runs the blade animation over the label while the value is being refreshed.
*/
@Composable
fun TangemPriceChange(
value: TextReference,
direction: TangemPriceChange.Direction,
modifier: Modifier = Modifier,
isFlickering: Boolean = false,
) {
val textColor = when (direction) {
TangemPriceChange.Direction.Up -> TangemTheme.colors3.text.accent.blue
TangemPriceChange.Direction.Down -> TangemTheme.colors3.text.accent.red
TangemPriceChange.Direction.Neutral -> TangemTheme.colors3.text.tertiary
}
val iconTint = when (direction) {
TangemPriceChange.Direction.Up -> TangemTheme.colors3.icon.accent.blue
TangemPriceChange.Direction.Down -> TangemTheme.colors3.icon.accent.red
TangemPriceChange.Direction.Neutral -> TangemTheme.colors3.icon.tertiary
}
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val arrow = when (direction) {
TangemPriceChange.Direction.Up -> Icons.ic_triangle_up_12
TangemPriceChange.Direction.Down -> Icons.ic_triangle_down_12
TangemPriceChange.Direction.Neutral -> Icons.ic_dot_12_filled
}
Icon(
imageVector = arrow,
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(12.dp),
)
Text(
text = value.resolveReference(),
style = TangemTheme.typography3.caption.medium.applyBladeBrush(
isEnabled = isFlickering,
textColor = textColor,
),
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* Design-system v2 (DS3) **Util / Price Change** state-driven overload of [TangemPriceChange].
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5724-3922&m=dev)
*
* @param state Indicator state model. See [TangemPriceChange.State].
* @param modifier Modifier applied to the indicator root.
* @param isFlickering Runs the blade animation over the label while the value is being refreshed.
*/
@Composable
@NonRestartableComposable
fun TangemPriceChange(state: TangemPriceChange.State, modifier: Modifier = Modifier, isFlickering: Boolean = false) {
TangemPriceChange(
value = state.value,
direction = state.direction,
modifier = modifier,
isFlickering = isFlickering,
)
}
/** Public API surface of [TangemPriceChange]. */
object TangemPriceChange {
/** Direction of the price change, driving the leading icon and its color. */
enum class Direction {
/** Price went up — upward arrow, accent color. */
Up,
/** Price went down — downward arrow, error color. */
Down,
/** No significant change — dot icon, tertiary color. */
Neutral,
}
/**
* Price change state model.
*
* @param value Formatted percent text (e.g. `"2.08%"`).
* @param direction Change direction. See [Direction].
*/
@Immutable
data class State(
val value: TextReference,
val direction: Direction,
)
}
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPriceChangePreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TangemPriceChange.Direction.entries.forEach { direction ->
TangemPriceChange(
value = stringReference("2.08%"),
direction = direction,
)
}
TangemPriceChange(
state = TangemPriceChange.State(
value = stringReference("2.08%"),
direction = TangemPriceChange.Direction.Up,
),
isFlickering = true,
)
}
}
}

View file

@ -84,6 +84,7 @@ dependencies {
api(projects.libs.blockchainSdk)
implementation(projects.common)
implementation(projects.common.ui)
implementation(projects.common.uiCharts)
implementation(projects.libs.auth)
implementation(projects.libs.crypto)
implementation(projects.libs.tangemSdkApi)

View file

@ -16,6 +16,7 @@ import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.ds2.util.TangemPriceChange
internal sealed interface StoryBookPage
@ -446,6 +447,64 @@ internal data class TextStyleStory(
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemTokenRowV2Story(
val variant: Variant,
val direction: TangemPriceChange.Direction,
val hasBadge: Boolean,
val isBadgeFilled: Boolean,
val hasPending: Boolean,
val hasQuote: Boolean,
val hasPriceChange: Boolean,
val hasCryptoBalance: Boolean,
val hasContractWarning: Boolean,
val hasUpdateWarning: Boolean,
val hasMessageBubble: Boolean,
val isBalanceHidden: Boolean,
val isQuoteFlickering: Boolean,
val isBalanceFlickering: Boolean,
val onVariantChange: (Variant) -> Unit,
val onDirectionChange: (TangemPriceChange.Direction) -> Unit,
val onBadgeToggle: () -> Unit,
val onBadgeFilledToggle: () -> Unit,
val onPendingToggle: () -> Unit,
val onQuoteToggle: () -> Unit,
val onPriceChangeToggle: () -> Unit,
val onCryptoBalanceToggle: () -> Unit,
val onContractWarningToggle: () -> Unit,
val onUpdateWarningToggle: () -> Unit,
val onMessageBubbleToggle: () -> Unit,
val onBalanceHiddenToggle: () -> Unit,
val onQuoteFlickeringToggle: () -> Unit,
val onBalanceFlickeringToggle: () -> Unit,
) : DsStoryBookPage {
/** Which [com.tangem.core.ui.ds2.tokenrow.TangemTokenRow] variant/preset is rendered. */
enum class Variant { Default, Organize, Unreachable, NoAddress, Shimmer }
}
@Suppress("BooleanPropertyNaming")
internal data class TangemTokenRowMarketStory(
val direction: TangemPriceChange.Direction,
val isShimmer: Boolean,
val hasTicker: Boolean,
val hasPosition: Boolean,
val hasCapitalization: Boolean,
val hasPrice: Boolean,
val hasPriceChange: Boolean,
val hasChart: Boolean,
val longTitle: Boolean,
val onDirectionChange: (TangemPriceChange.Direction) -> Unit,
val onShimmerToggle: () -> Unit,
val onTickerToggle: () -> Unit,
val onPositionToggle: () -> Unit,
val onCapitalizationToggle: () -> Unit,
val onPriceToggle: () -> Unit,
val onPriceChangeToggle: () -> Unit,
val onChartToggle: () -> Unit,
val onLongTitleToggle: () -> Unit,
) : DsStoryBookPage
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -28,6 +28,8 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSea
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.textStyleStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon.tangemTokenIconStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket.tangemTokenRowMarketStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2.tangemTokenRowV2StoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.tangemTopNavigationStoryFactory
private data class DsStoryItem(val title: String, val factory: StoryPageFactory)
@ -40,6 +42,8 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "⭕ TangemCheckmark", factory = tangemCheckmarkStoryFactory),
DsStoryItem(title = "🎨 TangemTokenIcon", factory = tangemTokenIconStoryFactory),
DsStoryItem(title = "📋 TangemRow", factory = tangemRowStoryFactory),
DsStoryItem(title = "🪙 TangemTokenRow", factory = tangemTokenRowV2StoryFactory),
DsStoryItem(title = "📈 TangemTokenRowMarket", factory = tangemTokenRowMarketStoryFactory),
DsStoryItem(title = "🔎 TangemSearch", factory = tangemSearchStoryFactory),
DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory),
DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory),

View file

@ -0,0 +1,32 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket
import com.tangem.core.ui.ds2.util.TangemPriceChange
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowMarketStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemTokenRowMarketStory>.build(): TangemTokenRowMarketStory {
return TangemTokenRowMarketStory(
direction = TangemPriceChange.Direction.Up,
isShimmer = false,
hasTicker = true,
hasPosition = true,
hasCapitalization = true,
hasPrice = true,
hasPriceChange = true,
hasChart = true,
longTitle = false,
onDirectionChange = { direction -> updateStory { it.copy(direction = direction) } },
onShimmerToggle = { updateStory { it.copy(isShimmer = !it.isShimmer) } },
onTickerToggle = { updateStory { it.copy(hasTicker = !it.hasTicker) } },
onPositionToggle = { updateStory { it.copy(hasPosition = !it.hasPosition) } },
onCapitalizationToggle = { updateStory { it.copy(hasCapitalization = !it.hasCapitalization) } },
onPriceToggle = { updateStory { it.copy(hasPrice = !it.hasPrice) } },
onPriceChangeToggle = { updateStory { it.copy(hasPriceChange = !it.hasPriceChange) } },
onChartToggle = { updateStory { it.copy(hasChart = !it.hasChart) } },
onLongTitleToggle = { updateStory { it.copy(longTitle = !it.longTitle) } },
)
}
internal val tangemTokenRowMarketStoryFactory
get() = storyPageFactory(StateUpdater<TangemTokenRowMarketStory>::build)

View file

@ -0,0 +1,243 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
import com.tangem.core.ui.ds2.tokenrow.Shimmer
import com.tangem.core.ui.ds2.tokenrow.TangemTokenRowMarket
import com.tangem.core.ui.ds2.util.TangemPriceChange
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowMarketStory
import kotlinx.collections.immutable.persistentListOf
private const val SAMPLE_ICON_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/bitcoin.png"
@Composable
internal fun TangemTokenRowMarketStory(state: TangemTokenRowMarketStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ComponentPreview(state = state)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
DirectionSelector(selected = state.direction, onSelect = state.onDirectionChange)
Toggles(state = state)
}
}
}
@Composable
private fun ComponentPreview(state: TangemTokenRowMarketStory) {
val icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = SAMPLE_ICON_URL))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.primary)
.padding(vertical = 16.dp),
) {
if (state.isShimmer) {
TangemTokenRowMarket.Shimmer()
} else {
TangemTokenRowMarket(
icon = icon,
title = if (state.longTitle) {
stringReference("Very Long Token Name Coin")
} else {
stringReference("Bitcoin")
},
ticker = if (state.hasTicker) stringReference("BTC") else null,
position = if (state.hasPosition) stringReference("2") else null,
capitalization = if (state.hasCapitalization) stringReference("1.196T") else null,
price = if (state.hasPrice) stringReference("$59,723.24") else null,
priceChange = if (state.hasPriceChange) {
TangemPriceChange.State(value = stringReference("2.08%"), direction = state.direction)
} else {
null
},
chart = if (state.hasChart) {
{ MarketChart(direction = state.direction) }
} else {
null
},
onClick = {},
)
}
}
}
// The graph slot is filled with the real MarketChartMini from :common:ui-charts, sized 24x32
// (collapsed) per the Figma spec, showing how a feature wires the chart into the DS slot.
@Composable
private fun MarketChart(direction: TangemPriceChange.Direction) {
val type = when (direction) {
TangemPriceChange.Direction.Up -> MarketChartLook.Type.Growing
TangemPriceChange.Direction.Down -> MarketChartLook.Type.Falling
TangemPriceChange.Direction.Neutral -> MarketChartLook.Type.Neutral
}
MarketChartMini(
rawData = PreviewChartData,
type = type,
modifier = Modifier.size(width = 24.dp, height = 32.dp),
)
}
private val PreviewChartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
)
@Composable
private fun DirectionSelector(selected: TangemPriceChange.Direction, onSelect: (TangemPriceChange.Direction) -> Unit) {
Section(label = "Price change") {
ChipGrid(
items = TangemPriceChange.Direction.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun Toggles(state: TangemTokenRowMarketStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "shimmer", checked = state.isShimmer, onToggle = state.onShimmerToggle)
ToggleRow(label = "ticker", checked = state.hasTicker, onToggle = state.onTickerToggle)
ToggleRow(label = "position", checked = state.hasPosition, onToggle = state.onPositionToggle)
ToggleRow(
label = "capitalization",
checked = state.hasCapitalization,
onToggle = state.onCapitalizationToggle,
)
ToggleRow(label = "price", checked = state.hasPrice, onToggle = state.onPriceToggle)
ToggleRow(label = "priceChange", checked = state.hasPriceChange, onToggle = state.onPriceChangeToggle)
ToggleRow(label = "chart", checked = state.hasChart, onToggle = state.onChartToggle)
ToggleRow(label = "longTitle", 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()
.padding(horizontal = 16.dp)
.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

@ -0,0 +1,42 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2
import com.tangem.core.ui.ds2.util.TangemPriceChange
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowV2Story
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemTokenRowV2Story>.build(): TangemTokenRowV2Story {
return TangemTokenRowV2Story(
variant = TangemTokenRowV2Story.Variant.Default,
direction = TangemPriceChange.Direction.Up,
hasBadge = true,
isBadgeFilled = false,
hasPending = false,
hasQuote = true,
hasPriceChange = true,
hasCryptoBalance = true,
hasContractWarning = false,
hasUpdateWarning = false,
hasMessageBubble = true,
isBalanceHidden = false,
isQuoteFlickering = false,
isBalanceFlickering = false,
onVariantChange = { variant -> updateStory { it.copy(variant = variant) } },
onDirectionChange = { direction -> updateStory { it.copy(direction = direction) } },
onBadgeToggle = { updateStory { it.copy(hasBadge = !it.hasBadge) } },
onBadgeFilledToggle = { updateStory { it.copy(isBadgeFilled = !it.isBadgeFilled) } },
onPendingToggle = { updateStory { it.copy(hasPending = !it.hasPending) } },
onQuoteToggle = { updateStory { it.copy(hasQuote = !it.hasQuote) } },
onPriceChangeToggle = { updateStory { it.copy(hasPriceChange = !it.hasPriceChange) } },
onCryptoBalanceToggle = { updateStory { it.copy(hasCryptoBalance = !it.hasCryptoBalance) } },
onContractWarningToggle = { updateStory { it.copy(hasContractWarning = !it.hasContractWarning) } },
onUpdateWarningToggle = { updateStory { it.copy(hasUpdateWarning = !it.hasUpdateWarning) } },
onMessageBubbleToggle = { updateStory { it.copy(hasMessageBubble = !it.hasMessageBubble) } },
onBalanceHiddenToggle = { updateStory { it.copy(isBalanceHidden = !it.isBalanceHidden) } },
onQuoteFlickeringToggle = { updateStory { it.copy(isQuoteFlickering = !it.isQuoteFlickering) } },
onBalanceFlickeringToggle = { updateStory { it.copy(isBalanceFlickering = !it.isBalanceFlickering) } },
)
}
internal val tangemTokenRowV2StoryFactory
get() = storyPageFactory(StateUpdater<TangemTokenRowV2Story>::build)

View file

@ -0,0 +1,308 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.messagebubble.TangemMessageBubble
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
import com.tangem.core.ui.ds2.tokenrow.NoAddress
import com.tangem.core.ui.ds2.tokenrow.Organize
import com.tangem.core.ui.ds2.tokenrow.Shimmer
import com.tangem.core.ui.ds2.tokenrow.TangemTokenRow
import com.tangem.core.ui.ds2.tokenrow.Unreachable
import com.tangem.core.ui.ds2.util.TangemPriceChange
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chart_bar_vertical_16
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowV2Story
private const val SAMPLE_ICON_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/bitcoin.png"
@Composable
internal fun TangemTokenRowV2Story(state: TangemTokenRowV2Story, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ComponentPreview(state = state)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
DirectionSelector(selected = state.direction, onSelect = state.onDirectionChange)
Toggles(state = state)
}
}
}
@Composable
private fun ComponentPreview(state: TangemTokenRowV2Story) {
val icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = SAMPLE_ICON_URL))
val priceChange = if (state.hasPriceChange) {
TangemPriceChange.State(value = stringReference("2.08%"), direction = state.direction)
} else {
null
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.primary)
.padding(vertical = 16.dp),
) {
when (state.variant) {
TangemTokenRowV2Story.Variant.Default -> DefaultRowPreview(
state = state,
icon = icon,
priceChange = priceChange,
)
TangemTokenRowV2Story.Variant.Organize -> TangemTokenRow.Organize(
icon = icon,
title = stringReference("Bitcoin"),
ticker = stringReference("BTC"),
fiatBalance = stringReference("$583.00"),
isBalanceHidden = state.isBalanceHidden,
)
TangemTokenRowV2Story.Variant.Unreachable -> TangemTokenRow.Unreachable(
icon = icon,
title = stringReference("Bitcoin"),
badge = stringReference("Unreachable"),
quote = if (state.hasQuote) stringReference("$1.00") else null,
priceChange = priceChange,
onClick = {},
)
TangemTokenRowV2Story.Variant.NoAddress -> TangemTokenRow.NoAddress(
icon = icon,
title = stringReference("Bitcoin"),
message = stringReference("No address"),
quote = if (state.hasQuote) stringReference("$1.00") else null,
priceChange = priceChange,
onClick = {},
)
TangemTokenRowV2Story.Variant.Shimmer -> TangemTokenRow.Shimmer()
}
}
}
@Composable
private fun DefaultRowPreview(
state: TangemTokenRowV2Story,
icon: TangemTokenIcon.UiState,
priceChange: TangemPriceChange.State?,
) {
TangemTokenRow(
icon = icon,
title = stringReference("Bitcoin"),
badge = if (state.hasBadge) {
TangemTokenRow.Badge(
text = stringReference("APY 5.47%"),
variant = if (state.isBadgeFilled) TangemBadge.Variant.Solid else TangemBadge.Variant.Tinted,
status = if (state.isBadgeFilled) TangemBadge.Status.Success else TangemBadge.Status.Neutral,
)
} else {
null
},
hasPending = state.hasPending,
quote = if (state.hasQuote) stringReference("$1.00") else null,
priceChange = priceChange,
fiatBalance = stringReference("$583.00"),
cryptoBalance = if (state.hasCryptoBalance) stringReference("0,000015 BTC") else null,
showContractWarning = state.hasContractWarning,
showUpdateWarning = state.hasUpdateWarning,
isBalanceHidden = state.isBalanceHidden,
isQuoteFlickering = state.isQuoteFlickering,
isBalanceFlickering = state.isBalanceFlickering,
messageBubble = if (state.hasMessageBubble) {
{
TangemMessageBubble(
text = stringReference("Enable 5.47% APY on your balance"),
variant = TangemMessageBubble.Variant.Success,
icon = Icons.ic_chart_bar_vertical_16,
onClick = {},
onClose = {},
closeContentDescription = "Dismiss",
)
}
} else {
null
},
onClick = {},
)
}
@Composable
private fun VariantSelector(
selected: TangemTokenRowV2Story.Variant,
onSelect: (TangemTokenRowV2Story.Variant) -> Unit,
) {
Section(label = "Variant") {
ChipGrid(
items = TangemTokenRowV2Story.Variant.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun DirectionSelector(selected: TangemPriceChange.Direction, onSelect: (TangemPriceChange.Direction) -> Unit) {
Section(label = "Price change") {
ChipGrid(
items = TangemPriceChange.Direction.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun Toggles(state: TangemTokenRowV2Story) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "badge", checked = state.hasBadge, onToggle = state.onBadgeToggle)
ToggleRow(label = "badgeFilled", checked = state.isBadgeFilled, onToggle = state.onBadgeFilledToggle)
ToggleRow(label = "pending", checked = state.hasPending, onToggle = state.onPendingToggle)
ToggleRow(label = "quote", checked = state.hasQuote, onToggle = state.onQuoteToggle)
ToggleRow(label = "priceChange", checked = state.hasPriceChange, onToggle = state.onPriceChangeToggle)
ToggleRow(label = "cryptoBalance", checked = state.hasCryptoBalance, onToggle = state.onCryptoBalanceToggle)
ToggleRow(
label = "contractWarning",
checked = state.hasContractWarning,
onToggle = state.onContractWarningToggle,
)
ToggleRow(label = "updateWarning", checked = state.hasUpdateWarning, onToggle = state.onUpdateWarningToggle)
ToggleRow(label = "messageBubble", checked = state.hasMessageBubble, onToggle = state.onMessageBubbleToggle)
ToggleRow(label = "balanceHidden", checked = state.isBalanceHidden, onToggle = state.onBalanceHiddenToggle)
ToggleRow(
label = "quoteFlickering",
checked = state.isQuoteFlickering,
onToggle = state.onQuoteFlickeringToggle,
)
ToggleRow(
label = "balanceFlickering",
checked = state.isBalanceFlickering,
onToggle = state.onBalanceFlickeringToggle,
)
}
}
}
@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()
.padding(horizontal = 16.dp)
.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

@ -50,6 +50,8 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSea
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.TextStyleStory
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon.TangemTokenIconStory
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket.TangemTokenRowMarketStory
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2.TangemTokenRowV2Story
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.TangemTopNavigationStory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory
@ -108,6 +110,8 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemFadeStory -> TangemFadeStory(state = storyState)
is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState)
is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState)
is TangemTokenRowV2Story -> TangemTokenRowV2Story(state = storyState)
is TangemTokenRowMarketStory -> TangemTokenRowMarketStory(state = storyState)
is TextStyleStory -> TextStyleStory(state = storyState)
}
}