diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemV3.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemV3.kt new file mode 100644 index 0000000000..aeb7189dcf --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemV3.kt @@ -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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebubble/TangemMessageBubble.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebubble/TangemMessageBubble.kt new file mode 100644 index 0000000000..1f550f45ac --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebubble/TangemMessageBubble.kt @@ -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", + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRow.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRow.kt new file mode 100644 index 0000000000..7c6e418249 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRow.kt @@ -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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRowInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRowInternal.kt new file mode 100644 index 0000000000..4a9d882197 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRowInternal.kt @@ -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.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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRowMarket.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRowMarket.kt new file mode 100644 index 0000000000..62c5eb8eb0 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenrow/TangemTokenRowMarket.kt @@ -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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/util/TangemPriceChange.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/util/TangemPriceChange.kt new file mode 100644 index 0000000000..2d52812894 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/util/TangemPriceChange.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 5f455e2701..aa67401f4b 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -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) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index 12076f46e3..15e5e3084c 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -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, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 3efeb305ae..d539569f2c 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -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), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowmarket/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowmarket/Build.kt new file mode 100644 index 0000000000..9dcfda3834 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowmarket/Build.kt @@ -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.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::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowmarket/TangemTokenRowMarketStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowmarket/TangemTokenRowMarketStory.kt new file mode 100644 index 0000000000..de1509c7ba --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowmarket/TangemTokenRowMarketStory.kt @@ -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 ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowv2/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowv2/Build.kt new file mode 100644 index 0000000000..abe220afa4 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowv2/Build.kt @@ -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.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::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowv2/TangemTokenRowV2Story.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowv2/TangemTokenRowV2Story.kt new file mode 100644 index 0000000000..70bc41b779 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenrowv2/TangemTokenRowV2Story.kt @@ -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 ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index fbfc2eab43..2629bd2024 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -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) } }