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,121 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.tangem.common.ui.markets
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.charts.MarketChartMini
|
||||
import com.tangem.common.ui.charts.state.MarketChartLook
|
||||
import com.tangem.common.ui.charts.state.MarketChartRawData
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
|
||||
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
|
||||
import com.tangem.core.ui.ds2.tokenrow.TangemTokenRowMarket
|
||||
import com.tangem.core.ui.ds2.util.TangemPriceChange
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.LocalWindowSize
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.windowsize.WindowSizeType
|
||||
|
||||
/**
|
||||
* DS3 markets-list item — [TangemTokenRowMarket] bound to the legacy [MarketsListItemUM], for a
|
||||
* drop-in migration from [MarketsListItemV2].
|
||||
*
|
||||
* Behavior notes (kept from [MarketsListItemV2]):
|
||||
* - The chart is hidden on small window widths.
|
||||
* - While [MarketsListItemUM.chartData] is `null`, the graph slot shows a shimmer bar.
|
||||
* - On a live price update the price text flashes in the update-direction color
|
||||
* ([MarketsListItemUM.Price.changeType]), like the legacy `TokenPriceText`.
|
||||
*
|
||||
* @param model Legacy markets list item model.
|
||||
* @param modifier Modifier applied to the row container.
|
||||
* @param onClick Row click handler. `null` makes the row non-interactive.
|
||||
*/
|
||||
@Composable
|
||||
fun MarketsListItemV3(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) {
|
||||
val isChartVisible = LocalWindowSize.current.widthAtLeast(WindowSizeType.Small)
|
||||
TangemTokenRowMarket(
|
||||
icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = model.iconUrl)),
|
||||
title = stringReference(model.name),
|
||||
modifier = modifier,
|
||||
ticker = stringReference(model.currencySymbol),
|
||||
position = model.ratingPosition?.let(::stringReference),
|
||||
capitalization = model.marketCap?.let(::stringReference),
|
||||
price = stringReference(model.price.text),
|
||||
priceChange = TangemPriceChange.State(
|
||||
value = stringReference(model.trendPercentText),
|
||||
direction = model.trendType.toPriceChangeDirection(),
|
||||
),
|
||||
priceUpdateDirection = model.price.changeType?.toPriceChangeDirection(),
|
||||
chart = if (isChartVisible) {
|
||||
{ MarketRowChart(chartType = model.chartType, chartRawData = model.chartData) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun PriceChangeType.toPriceChangeDirection(): TangemPriceChange.Direction = when (this) {
|
||||
PriceChangeType.UP -> TangemPriceChange.Direction.Up
|
||||
PriceChangeType.DOWN -> TangemPriceChange.Direction.Down
|
||||
PriceChangeType.NEUTRAL -> TangemPriceChange.Direction.Neutral
|
||||
}
|
||||
|
||||
/** Mini-graph for the [TangemTokenRowMarket] chart slot — 24x32 collapsed, shimmer while loading. */
|
||||
@Composable
|
||||
private fun MarketRowChart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 24.dp, height = 32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (chartRawData != null) {
|
||||
MarketChartMini(
|
||||
rawData = chartRawData,
|
||||
type = chartType,
|
||||
neutralColor = TangemTheme.colors3.border.accent.neutral,
|
||||
growingColor = TangemTheme.colors3.border.accent.blue,
|
||||
fallingColor = TangemTheme.colors3.border.accent.red,
|
||||
)
|
||||
} else {
|
||||
TangemShimmer(
|
||||
radius = 4.dp,
|
||||
modifier = Modifier.size(width = 24.dp, height = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
|
||||
@Preview(name = "Light", showBackground = true, widthDp = 360)
|
||||
@Preview(
|
||||
name = "Dark",
|
||||
showBackground = true,
|
||||
widthDp = 360,
|
||||
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
|
||||
)
|
||||
@Composable
|
||||
private fun MarketsListItemV3Preview(
|
||||
@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM,
|
||||
) {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
|
||||
MarketsListItemV3(model = state, onClick = {})
|
||||
MarketsListItemV3(model = state.copy(chartData = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,7 @@ dependencies {
|
|||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.common)
|
||||
implementation(projects.common.ui)
|
||||
implementation(projects.common.uiCharts)
|
||||
implementation(projects.libs.auth)
|
||||
implementation(projects.libs.crypto)
|
||||
implementation(projects.libs.tangemSdkApi)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
|||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.ds2.util.TangemPriceChange
|
||||
|
||||
internal sealed interface StoryBookPage
|
||||
|
||||
|
|
@ -446,6 +447,64 @@ internal data class TextStyleStory(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("BooleanPropertyNaming")
|
||||
internal data class TangemTokenRowV2Story(
|
||||
val variant: Variant,
|
||||
val direction: TangemPriceChange.Direction,
|
||||
val hasBadge: Boolean,
|
||||
val isBadgeFilled: Boolean,
|
||||
val hasPending: Boolean,
|
||||
val hasQuote: Boolean,
|
||||
val hasPriceChange: Boolean,
|
||||
val hasCryptoBalance: Boolean,
|
||||
val hasContractWarning: Boolean,
|
||||
val hasUpdateWarning: Boolean,
|
||||
val hasMessageBubble: Boolean,
|
||||
val isBalanceHidden: Boolean,
|
||||
val isQuoteFlickering: Boolean,
|
||||
val isBalanceFlickering: Boolean,
|
||||
val onVariantChange: (Variant) -> Unit,
|
||||
val onDirectionChange: (TangemPriceChange.Direction) -> Unit,
|
||||
val onBadgeToggle: () -> Unit,
|
||||
val onBadgeFilledToggle: () -> Unit,
|
||||
val onPendingToggle: () -> Unit,
|
||||
val onQuoteToggle: () -> Unit,
|
||||
val onPriceChangeToggle: () -> Unit,
|
||||
val onCryptoBalanceToggle: () -> Unit,
|
||||
val onContractWarningToggle: () -> Unit,
|
||||
val onUpdateWarningToggle: () -> Unit,
|
||||
val onMessageBubbleToggle: () -> Unit,
|
||||
val onBalanceHiddenToggle: () -> Unit,
|
||||
val onQuoteFlickeringToggle: () -> Unit,
|
||||
val onBalanceFlickeringToggle: () -> Unit,
|
||||
) : DsStoryBookPage {
|
||||
|
||||
/** Which [com.tangem.core.ui.ds2.tokenrow.TangemTokenRow] variant/preset is rendered. */
|
||||
enum class Variant { Default, Organize, Unreachable, NoAddress, Shimmer }
|
||||
}
|
||||
|
||||
@Suppress("BooleanPropertyNaming")
|
||||
internal data class TangemTokenRowMarketStory(
|
||||
val direction: TangemPriceChange.Direction,
|
||||
val isShimmer: Boolean,
|
||||
val hasTicker: Boolean,
|
||||
val hasPosition: Boolean,
|
||||
val hasCapitalization: Boolean,
|
||||
val hasPrice: Boolean,
|
||||
val hasPriceChange: Boolean,
|
||||
val hasChart: Boolean,
|
||||
val longTitle: Boolean,
|
||||
val onDirectionChange: (TangemPriceChange.Direction) -> Unit,
|
||||
val onShimmerToggle: () -> Unit,
|
||||
val onTickerToggle: () -> Unit,
|
||||
val onPositionToggle: () -> Unit,
|
||||
val onCapitalizationToggle: () -> Unit,
|
||||
val onPriceToggle: () -> Unit,
|
||||
val onPriceChangeToggle: () -> Unit,
|
||||
val onChartToggle: () -> Unit,
|
||||
val onLongTitleToggle: () -> Unit,
|
||||
) : DsStoryBookPage
|
||||
|
||||
internal data class TangemBadgeV2Story(
|
||||
val variant: TangemBadge.Variant,
|
||||
val status: TangemBadge.Status,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSea
|
|||
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.textStyleStoryFactory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon.tangemTokenIconStoryFactory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket.tangemTokenRowMarketStoryFactory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2.tangemTokenRowV2StoryFactory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.tangemTopNavigationStoryFactory
|
||||
|
||||
private data class DsStoryItem(val title: String, val factory: StoryPageFactory)
|
||||
|
|
@ -40,6 +42,8 @@ private fun buildDsStories() = listOf(
|
|||
DsStoryItem(title = "⭕ TangemCheckmark", factory = tangemCheckmarkStoryFactory),
|
||||
DsStoryItem(title = "🎨 TangemTokenIcon", factory = tangemTokenIconStoryFactory),
|
||||
DsStoryItem(title = "📋 TangemRow", factory = tangemRowStoryFactory),
|
||||
DsStoryItem(title = "🪙 TangemTokenRow", factory = tangemTokenRowV2StoryFactory),
|
||||
DsStoryItem(title = "📈 TangemTokenRowMarket", factory = tangemTokenRowMarketStoryFactory),
|
||||
DsStoryItem(title = "🔎 TangemSearch", factory = tangemSearchStoryFactory),
|
||||
DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory),
|
||||
DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket
|
||||
|
||||
import com.tangem.core.ui.ds2.util.TangemPriceChange
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowMarketStory
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
|
||||
|
||||
internal fun StateUpdater<TangemTokenRowMarketStory>.build(): TangemTokenRowMarketStory {
|
||||
return TangemTokenRowMarketStory(
|
||||
direction = TangemPriceChange.Direction.Up,
|
||||
isShimmer = false,
|
||||
hasTicker = true,
|
||||
hasPosition = true,
|
||||
hasCapitalization = true,
|
||||
hasPrice = true,
|
||||
hasPriceChange = true,
|
||||
hasChart = true,
|
||||
longTitle = false,
|
||||
onDirectionChange = { direction -> updateStory { it.copy(direction = direction) } },
|
||||
onShimmerToggle = { updateStory { it.copy(isShimmer = !it.isShimmer) } },
|
||||
onTickerToggle = { updateStory { it.copy(hasTicker = !it.hasTicker) } },
|
||||
onPositionToggle = { updateStory { it.copy(hasPosition = !it.hasPosition) } },
|
||||
onCapitalizationToggle = { updateStory { it.copy(hasCapitalization = !it.hasCapitalization) } },
|
||||
onPriceToggle = { updateStory { it.copy(hasPrice = !it.hasPrice) } },
|
||||
onPriceChangeToggle = { updateStory { it.copy(hasPriceChange = !it.hasPriceChange) } },
|
||||
onChartToggle = { updateStory { it.copy(hasChart = !it.hasChart) } },
|
||||
onLongTitleToggle = { updateStory { it.copy(longTitle = !it.longTitle) } },
|
||||
)
|
||||
}
|
||||
|
||||
internal val tangemTokenRowMarketStoryFactory
|
||||
get() = storyPageFactory(StateUpdater<TangemTokenRowMarketStory>::build)
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.charts.MarketChartMini
|
||||
import com.tangem.common.ui.charts.state.MarketChartLook
|
||||
import com.tangem.common.ui.charts.state.MarketChartRawData
|
||||
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
|
||||
import com.tangem.core.ui.ds2.tokenrow.Shimmer
|
||||
import com.tangem.core.ui.ds2.tokenrow.TangemTokenRowMarket
|
||||
import com.tangem.core.ui.ds2.util.TangemPriceChange
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowMarketStory
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
private const val SAMPLE_ICON_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/bitcoin.png"
|
||||
|
||||
@Composable
|
||||
internal fun TangemTokenRowMarketStory(state: TangemTokenRowMarketStory, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ComponentPreview(state = state)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
DirectionSelector(selected = state.direction, onSelect = state.onDirectionChange)
|
||||
Toggles(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComponentPreview(state: TangemTokenRowMarketStory) {
|
||||
val icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = SAMPLE_ICON_URL))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(vertical = 16.dp),
|
||||
) {
|
||||
if (state.isShimmer) {
|
||||
TangemTokenRowMarket.Shimmer()
|
||||
} else {
|
||||
TangemTokenRowMarket(
|
||||
icon = icon,
|
||||
title = if (state.longTitle) {
|
||||
stringReference("Very Long Token Name Coin")
|
||||
} else {
|
||||
stringReference("Bitcoin")
|
||||
},
|
||||
ticker = if (state.hasTicker) stringReference("BTC") else null,
|
||||
position = if (state.hasPosition) stringReference("2") else null,
|
||||
capitalization = if (state.hasCapitalization) stringReference("1.196T") else null,
|
||||
price = if (state.hasPrice) stringReference("$59,723.24") else null,
|
||||
priceChange = if (state.hasPriceChange) {
|
||||
TangemPriceChange.State(value = stringReference("2.08%"), direction = state.direction)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
chart = if (state.hasChart) {
|
||||
{ MarketChart(direction = state.direction) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The graph slot is filled with the real MarketChartMini from :common:ui-charts, sized 24x32
|
||||
// (collapsed) per the Figma spec, showing how a feature wires the chart into the DS slot.
|
||||
@Composable
|
||||
private fun MarketChart(direction: TangemPriceChange.Direction) {
|
||||
val type = when (direction) {
|
||||
TangemPriceChange.Direction.Up -> MarketChartLook.Type.Growing
|
||||
TangemPriceChange.Direction.Down -> MarketChartLook.Type.Falling
|
||||
TangemPriceChange.Direction.Neutral -> MarketChartLook.Type.Neutral
|
||||
}
|
||||
MarketChartMini(
|
||||
rawData = PreviewChartData,
|
||||
type = type,
|
||||
modifier = Modifier.size(width = 24.dp, height = 32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private val PreviewChartData = MarketChartRawData(
|
||||
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun DirectionSelector(selected: TangemPriceChange.Direction, onSelect: (TangemPriceChange.Direction) -> Unit) {
|
||||
Section(label = "Price change") {
|
||||
ChipGrid(
|
||||
items = TangemPriceChange.Direction.entries,
|
||||
label = { it.name },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toggles(state: TangemTokenRowMarketStory) {
|
||||
Section(label = "Flags") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ToggleRow(label = "shimmer", checked = state.isShimmer, onToggle = state.onShimmerToggle)
|
||||
ToggleRow(label = "ticker", checked = state.hasTicker, onToggle = state.onTickerToggle)
|
||||
ToggleRow(label = "position", checked = state.hasPosition, onToggle = state.onPositionToggle)
|
||||
ToggleRow(
|
||||
label = "capitalization",
|
||||
checked = state.hasCapitalization,
|
||||
onToggle = state.onCapitalizationToggle,
|
||||
)
|
||||
ToggleRow(label = "price", checked = state.hasPrice, onToggle = state.onPriceToggle)
|
||||
ToggleRow(label = "priceChange", checked = state.hasPriceChange, onToggle = state.onPriceChangeToggle)
|
||||
ToggleRow(label = "chart", checked = state.hasChart, onToggle = state.onChartToggle)
|
||||
ToggleRow(label = "longTitle", checked = state.longTitle, onToggle = state.onLongTitleToggle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(label: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = label,
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(shape)
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = TangemTheme.colors2.border.neutral.secondary,
|
||||
shape = shape,
|
||||
)
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
Chip(
|
||||
label = label(item),
|
||||
selected = isSelected(item),
|
||||
onClick = { onSelect(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val chipShape = RoundedCornerShape(50)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(chipShape)
|
||||
.background(
|
||||
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
text = if (checked) "ON" else "OFF",
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2
|
||||
|
||||
import com.tangem.core.ui.ds2.util.TangemPriceChange
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowV2Story
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
|
||||
|
||||
internal fun StateUpdater<TangemTokenRowV2Story>.build(): TangemTokenRowV2Story {
|
||||
return TangemTokenRowV2Story(
|
||||
variant = TangemTokenRowV2Story.Variant.Default,
|
||||
direction = TangemPriceChange.Direction.Up,
|
||||
hasBadge = true,
|
||||
isBadgeFilled = false,
|
||||
hasPending = false,
|
||||
hasQuote = true,
|
||||
hasPriceChange = true,
|
||||
hasCryptoBalance = true,
|
||||
hasContractWarning = false,
|
||||
hasUpdateWarning = false,
|
||||
hasMessageBubble = true,
|
||||
isBalanceHidden = false,
|
||||
isQuoteFlickering = false,
|
||||
isBalanceFlickering = false,
|
||||
onVariantChange = { variant -> updateStory { it.copy(variant = variant) } },
|
||||
onDirectionChange = { direction -> updateStory { it.copy(direction = direction) } },
|
||||
onBadgeToggle = { updateStory { it.copy(hasBadge = !it.hasBadge) } },
|
||||
onBadgeFilledToggle = { updateStory { it.copy(isBadgeFilled = !it.isBadgeFilled) } },
|
||||
onPendingToggle = { updateStory { it.copy(hasPending = !it.hasPending) } },
|
||||
onQuoteToggle = { updateStory { it.copy(hasQuote = !it.hasQuote) } },
|
||||
onPriceChangeToggle = { updateStory { it.copy(hasPriceChange = !it.hasPriceChange) } },
|
||||
onCryptoBalanceToggle = { updateStory { it.copy(hasCryptoBalance = !it.hasCryptoBalance) } },
|
||||
onContractWarningToggle = { updateStory { it.copy(hasContractWarning = !it.hasContractWarning) } },
|
||||
onUpdateWarningToggle = { updateStory { it.copy(hasUpdateWarning = !it.hasUpdateWarning) } },
|
||||
onMessageBubbleToggle = { updateStory { it.copy(hasMessageBubble = !it.hasMessageBubble) } },
|
||||
onBalanceHiddenToggle = { updateStory { it.copy(isBalanceHidden = !it.isBalanceHidden) } },
|
||||
onQuoteFlickeringToggle = { updateStory { it.copy(isQuoteFlickering = !it.isQuoteFlickering) } },
|
||||
onBalanceFlickeringToggle = { updateStory { it.copy(isBalanceFlickering = !it.isBalanceFlickering) } },
|
||||
)
|
||||
}
|
||||
|
||||
internal val tangemTokenRowV2StoryFactory
|
||||
get() = storyPageFactory(StateUpdater<TangemTokenRowV2Story>::build)
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.ds2.badge.TangemBadge
|
||||
import com.tangem.core.ui.ds2.messagebubble.TangemMessageBubble
|
||||
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon
|
||||
import com.tangem.core.ui.ds2.tokenrow.NoAddress
|
||||
import com.tangem.core.ui.ds2.tokenrow.Organize
|
||||
import com.tangem.core.ui.ds2.tokenrow.Shimmer
|
||||
import com.tangem.core.ui.ds2.tokenrow.TangemTokenRow
|
||||
import com.tangem.core.ui.ds2.tokenrow.Unreachable
|
||||
import com.tangem.core.ui.ds2.util.TangemPriceChange
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chart_bar_vertical_16
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowV2Story
|
||||
|
||||
private const val SAMPLE_ICON_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/bitcoin.png"
|
||||
|
||||
@Composable
|
||||
internal fun TangemTokenRowV2Story(state: TangemTokenRowV2Story, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ComponentPreview(state = state)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
|
||||
DirectionSelector(selected = state.direction, onSelect = state.onDirectionChange)
|
||||
Toggles(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComponentPreview(state: TangemTokenRowV2Story) {
|
||||
val icon = TangemTokenIcon.UiState.Token(TangemTokenIcon.State(url = SAMPLE_ICON_URL))
|
||||
val priceChange = if (state.hasPriceChange) {
|
||||
TangemPriceChange.State(value = stringReference("2.08%"), direction = state.direction)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(vertical = 16.dp),
|
||||
) {
|
||||
when (state.variant) {
|
||||
TangemTokenRowV2Story.Variant.Default -> DefaultRowPreview(
|
||||
state = state,
|
||||
icon = icon,
|
||||
priceChange = priceChange,
|
||||
)
|
||||
TangemTokenRowV2Story.Variant.Organize -> TangemTokenRow.Organize(
|
||||
icon = icon,
|
||||
title = stringReference("Bitcoin"),
|
||||
ticker = stringReference("BTC"),
|
||||
fiatBalance = stringReference("$583.00"),
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
)
|
||||
TangemTokenRowV2Story.Variant.Unreachable -> TangemTokenRow.Unreachable(
|
||||
icon = icon,
|
||||
title = stringReference("Bitcoin"),
|
||||
badge = stringReference("Unreachable"),
|
||||
quote = if (state.hasQuote) stringReference("$1.00") else null,
|
||||
priceChange = priceChange,
|
||||
onClick = {},
|
||||
)
|
||||
TangemTokenRowV2Story.Variant.NoAddress -> TangemTokenRow.NoAddress(
|
||||
icon = icon,
|
||||
title = stringReference("Bitcoin"),
|
||||
message = stringReference("No address"),
|
||||
quote = if (state.hasQuote) stringReference("$1.00") else null,
|
||||
priceChange = priceChange,
|
||||
onClick = {},
|
||||
)
|
||||
TangemTokenRowV2Story.Variant.Shimmer -> TangemTokenRow.Shimmer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultRowPreview(
|
||||
state: TangemTokenRowV2Story,
|
||||
icon: TangemTokenIcon.UiState,
|
||||
priceChange: TangemPriceChange.State?,
|
||||
) {
|
||||
TangemTokenRow(
|
||||
icon = icon,
|
||||
title = stringReference("Bitcoin"),
|
||||
badge = if (state.hasBadge) {
|
||||
TangemTokenRow.Badge(
|
||||
text = stringReference("APY 5.47%"),
|
||||
variant = if (state.isBadgeFilled) TangemBadge.Variant.Solid else TangemBadge.Variant.Tinted,
|
||||
status = if (state.isBadgeFilled) TangemBadge.Status.Success else TangemBadge.Status.Neutral,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
hasPending = state.hasPending,
|
||||
quote = if (state.hasQuote) stringReference("$1.00") else null,
|
||||
priceChange = priceChange,
|
||||
fiatBalance = stringReference("$583.00"),
|
||||
cryptoBalance = if (state.hasCryptoBalance) stringReference("0,000015 BTC") else null,
|
||||
showContractWarning = state.hasContractWarning,
|
||||
showUpdateWarning = state.hasUpdateWarning,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
isQuoteFlickering = state.isQuoteFlickering,
|
||||
isBalanceFlickering = state.isBalanceFlickering,
|
||||
messageBubble = if (state.hasMessageBubble) {
|
||||
{
|
||||
TangemMessageBubble(
|
||||
text = stringReference("Enable 5.47% APY on your balance"),
|
||||
variant = TangemMessageBubble.Variant.Success,
|
||||
icon = Icons.ic_chart_bar_vertical_16,
|
||||
onClick = {},
|
||||
onClose = {},
|
||||
closeContentDescription = "Dismiss",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariantSelector(
|
||||
selected: TangemTokenRowV2Story.Variant,
|
||||
onSelect: (TangemTokenRowV2Story.Variant) -> Unit,
|
||||
) {
|
||||
Section(label = "Variant") {
|
||||
ChipGrid(
|
||||
items = TangemTokenRowV2Story.Variant.entries,
|
||||
label = { it.name },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectionSelector(selected: TangemPriceChange.Direction, onSelect: (TangemPriceChange.Direction) -> Unit) {
|
||||
Section(label = "Price change") {
|
||||
ChipGrid(
|
||||
items = TangemPriceChange.Direction.entries,
|
||||
label = { it.name },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toggles(state: TangemTokenRowV2Story) {
|
||||
Section(label = "Flags") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ToggleRow(label = "badge", checked = state.hasBadge, onToggle = state.onBadgeToggle)
|
||||
ToggleRow(label = "badgeFilled", checked = state.isBadgeFilled, onToggle = state.onBadgeFilledToggle)
|
||||
ToggleRow(label = "pending", checked = state.hasPending, onToggle = state.onPendingToggle)
|
||||
ToggleRow(label = "quote", checked = state.hasQuote, onToggle = state.onQuoteToggle)
|
||||
ToggleRow(label = "priceChange", checked = state.hasPriceChange, onToggle = state.onPriceChangeToggle)
|
||||
ToggleRow(label = "cryptoBalance", checked = state.hasCryptoBalance, onToggle = state.onCryptoBalanceToggle)
|
||||
ToggleRow(
|
||||
label = "contractWarning",
|
||||
checked = state.hasContractWarning,
|
||||
onToggle = state.onContractWarningToggle,
|
||||
)
|
||||
ToggleRow(label = "updateWarning", checked = state.hasUpdateWarning, onToggle = state.onUpdateWarningToggle)
|
||||
ToggleRow(label = "messageBubble", checked = state.hasMessageBubble, onToggle = state.onMessageBubbleToggle)
|
||||
ToggleRow(label = "balanceHidden", checked = state.isBalanceHidden, onToggle = state.onBalanceHiddenToggle)
|
||||
ToggleRow(
|
||||
label = "quoteFlickering",
|
||||
checked = state.isQuoteFlickering,
|
||||
onToggle = state.onQuoteFlickeringToggle,
|
||||
)
|
||||
ToggleRow(
|
||||
label = "balanceFlickering",
|
||||
checked = state.isBalanceFlickering,
|
||||
onToggle = state.onBalanceFlickeringToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(label: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = label,
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(shape)
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = TangemTheme.colors2.border.neutral.secondary,
|
||||
shape = shape,
|
||||
)
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
Chip(
|
||||
label = label(item),
|
||||
selected = isSelected(item),
|
||||
onClick = { onSelect(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val chipShape = RoundedCornerShape(50)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(chipShape)
|
||||
.background(
|
||||
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
text = if (checked) "ON" else "OFF",
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,8 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSea
|
|||
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.TextStyleStory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon.TangemTokenIconStory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowmarket.TangemTokenRowMarketStory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.tokenrowv2.TangemTokenRowV2Story
|
||||
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.TangemTopNavigationStory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
|
||||
import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory
|
||||
|
|
@ -108,6 +110,8 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
|
|||
is TangemFadeStory -> TangemFadeStory(state = storyState)
|
||||
is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState)
|
||||
is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState)
|
||||
is TangemTokenRowV2Story -> TangemTokenRowV2Story(state = storyState)
|
||||
is TangemTokenRowMarketStory -> TangemTokenRowMarketStory(state = storyState)
|
||||
is TextStyleStory -> TextStyleStory(state = storyState)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue