Updated on 2026-08-14
This commit is contained in:
parent
ddba5fe3cf
commit
5ce84d1500
14 changed files with 2832 additions and 0 deletions
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue