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,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,
)
}
}
}