Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-10 13:13:23 +01:00
parent 8a79e65e2e
commit dbacd5b2a6
12 changed files with 475 additions and 25 deletions

View file

@ -0,0 +1,323 @@
package com.tangem.common.ui.markets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.common.ui.tokens.TokenPriceText
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.res.LocalIsInDarkTheme
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.test.MarketsTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
fun MarketsListItemV2(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketListItemContentV2(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
.clickable(onClick = onClick)
.testTag(MarketsTestTags.TOKENS_LIST_ITEM),
model = model,
)
}
@Composable
fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
TangemRowContainer(
modifier = modifier,
content = {
TangemIcon(
tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44),
modifier = Modifier
.size(40.dp)
.layoutId(layoutId = TangemRowLayoutId.HEAD),
)
TokenTitle(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.START_TOP)
.padding(horizontal = TangemTheme.dimens2.x2),
name = model.name,
currencySymbol = model.currencySymbol,
)
TokenPriceText(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.END_TOP)
.testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT),
price = model.price.text,
priceChangeType = model.price.changeType,
)
TokenSubtitle(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2, start = TangemTheme.dimens2.x3),
ratingPosition = model.ratingPosition,
marketCap = model.marketCap,
// stakingRate = model.stakingRate, TODO in [REDACTED_TASK_KEY]
)
PriceChangeInPercent(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM),
textStyle = TangemTheme.typography2.captionRegular12,
type = model.trendType,
valueInPercent = model.trendPercentText,
)
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Chart(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(layoutId = TangemRowLayoutId.TAIL),
chartType = model.chartType,
chartRawData = model.chartData,
)
}
},
)
}
@Composable
private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) {
Row(modifier = modifier) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
text = name,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW4()
Text(
modifier = Modifier.alignByBaseline(),
text = currencySymbol,
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun TokenSubtitle(
ratingPosition: String?,
marketCap: String?,
// stakingRate: TextReference?, TODO in [REDACTED_TASK_KEY]
modifier: Modifier = Modifier,
) {
val ratingColor = mapRatingToColor(ratingPosition)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
TokenRatingPlace(
ratingPosition = ratingPosition,
ratingColor = ratingColor,
)
if (marketCap != null) {
TokenMarketCapText(
ratingColor = ratingColor,
modifier = Modifier.weight(1f, fill = false),
text = marketCap,
)
}
}
}
@Composable
private fun RowScope.TokenRatingPlace(ratingPosition: String?, ratingColor: Color) {
Row(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens2.x4),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Icon(
modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2),
imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_left),
tint = ratingColor,
contentDescription = null,
)
Text(
textAlign = TextAlign.Center,
text = ratingPosition ?: MINUS,
color = ratingColor,
style = TangemTheme.typography2.captionSemibold12.copy(letterSpacing = 0.sp),
maxLines = 1,
)
Icon(
modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2),
imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_right),
tint = ratingColor,
contentDescription = null,
)
}
}
@Composable
private fun RowScope.TokenMarketCapText(text: String, ratingColor: Color, modifier: Modifier = Modifier) {
Text(
modifier = modifier.alignByBaseline(),
text = text,
color = ratingColor,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.padding(vertical = 6.dp)
.size(height = TangemTheme.dimens2.x6, width = TangemTheme.dimens2.x12),
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
)
} else {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens2.x3)
.align(Alignment.Center),
radius = 3.dp,
)
}
}
}
@Composable
private fun mapRatingToColor(rating: String?): Color {
val isDarkTheme = LocalIsInDarkTheme.current
return when (rating) {
"1" -> if (isDarkTheme) Color(GOLD_PLACE_COLOR_NIGHT) else Color(GOLD_PLACE_COLOR_LIGHT)
"2" -> if (isDarkTheme) Color(SILVER_PLACE_COLOR_NIGHT) else Color(SILVER_PLACE_COLOR_LIGHT)
"3" -> if (isDarkTheme) Color(BRONZE_PLACE_COLOR_NIGHT) else Color(BRONZE_PLACE_COLOR_LIGHT)
else -> TangemTheme.colors2.text.neutral.secondary
}
}
private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76
private const val GOLD_PLACE_COLOR_LIGHT = 0xFFD9B900
private const val SILVER_PLACE_COLOR_NIGHT = 0xFFAABEF7
private const val SILVER_PLACE_COLOR_LIGHT = 0xFF6680CC
private const val BRONZE_PLACE_COLOR_NIGHT = 0xFFFF9976
private const val BRONZE_PLACE_COLOR_LIGHT = 0xFFCC7F66
// region preview
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Composable
private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) {
TangemThemePreviewRedesign {
var state1 by remember { mutableStateOf(state) }
var state2 by remember { mutableStateOf(state) }
var prices by remember {
mutableStateOf(
listOf(
100 to PriceChangeType.NEUTRAL,
200 to PriceChangeType.NEUTRAL,
),
)
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItemV2(
modifier = Modifier,
model = state1,
)
MarketsListItemV2(
modifier = Modifier,
model = state2,
)
Row {
Button(
onClick = {
state1 = state1.copy(
trendType = PriceChangeType.entries.random(),
)
state2 = state2.copy(
trendType = PriceChangeType.entries.random(),
)
},
) { Text(text = "trend") }
Button(
onClick = {
prices = prices.map { (price, _) ->
if (Random.nextBoolean()) {
price.inc() to PriceChangeType.UP
} else {
price.dec() to PriceChangeType.DOWN
}
}
state1 = state1.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[0].first}023 $",
changeType = prices[0].second,
),
)
state2 = state2.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[1].first}023 $",
changeType = prices[1].second,
),
)
},
) { Text(text = "price") }
}
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.common.ui.markets
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.res.LocalRedesignEnabled
@Composable
fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
if (LocalRedesignEnabled.current) {
MarketsListItemV2(
model = model,
modifier = modifier,
onClick = onClick,
)
} else {
MarketsListItemV1(
model = model,
modifier = modifier,
onClick = onClick,
)
}
}

View file

@ -38,8 +38,8 @@ import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContent(
fun MarketsListItemV1(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContentV1(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
@ -50,7 +50,7 @@ fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onC
}
@Composable
private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) {
private fun MarketsListItemContentV1(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
Row(
@ -273,11 +273,11 @@ private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::cl
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItem(
MarketsListItemV1(
modifier = Modifier,
model = state1,
)
MarketsListItem(
MarketsListItemV1(
modifier = Modifier,
model = state2,
)

View file

@ -16,7 +16,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = "",
ratingPosition = "10",
ratingPosition = "1",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
@ -33,7 +33,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
ratingPosition = "2",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",

View file

@ -6,8 +6,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
/**
@ -18,6 +22,23 @@ import com.tangem.core.ui.res.TangemTheme
*/
@Composable
fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
if (LocalRedesignEnabled.current) {
TokenPriceTextV2(
price = price,
modifier = modifier,
priceChangeType = priceChangeType,
)
} else {
TokenPriceTextV1(
price = price,
modifier = modifier,
priceChangeType = priceChangeType,
)
}
}
@Composable
private fun TokenPriceTextV1(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
val growColor = TangemTheme.colors.text.accent
val fallColor = TangemTheme.colors.text.warning
val generalColor = TangemTheme.colors.text.primary1
@ -52,4 +73,60 @@ fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType
style = TangemTheme.typography.body2,
overflow = TextOverflow.Visible,
)
}
@Composable
private fun TokenPriceTextV2(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
val growColor = TangemTheme.colors2.text.status.accent
val fallColor = TangemTheme.colors2.text.status.warning
val generalColor = TangemTheme.colors2.text.neutral.primary
val decimalColor = TangemTheme.colors2.text.neutral.secondary
val color = remember(generalColor) { Animatable(generalColor) }
var isAnimationSkipped by remember { mutableStateOf(false) }
LaunchedEffect(price) {
if (!isAnimationSkipped) {
isAnimationSkipped = true
return@LaunchedEffect
}
if (priceChangeType != null) {
val nextColor = when (priceChangeType) {
PriceChangeType.UP -> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@LaunchedEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
}
val annotatedText = remember(price) {
buildAnnotatedString {
val dotIndex = price.indexOf(".")
if (dotIndex == -1) {
append(price)
} else {
append(price.take(dotIndex))
withStyle(
style = SpanStyle(color = decimalColor),
) {
append(price.substring(dotIndex))
}
}
}
}
Text(
modifier = modifier,
text = annotatedText,
color = color.value,
maxLines = 1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Visible,
)
}

View file

@ -2,9 +2,6 @@ package com.tangem.core.ui.ds.image
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
@ -50,7 +47,8 @@ sealed interface TangemIconUM {
/** Image represented from network by url */
data class Url(
val url: String,
val url: String?,
@DrawableRes val fallbackRes: Int,
) : TangemIconUM
}
@ -91,14 +89,12 @@ fun TangemIcon(tangemIconUM: TangemIconUM, modifier: Modifier = Modifier) {
.crossfade(enable = true)
.allowHardware(enable = false)
.build(),
loading = { CircleShimmer() },
loading = { CircleShimmer(modifier) },
error = {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors2.surface.level3,
shape = CircleShape,
),
Icon(
imageVector = ImageVector.vectorResource(tangemIconUM.fallbackRes),
contentDescription = null,
modifier = modifier,
)
},
contentDescription = null,

View file

@ -121,7 +121,13 @@ private fun BoxScope.BackgroundLayer(icon: TangemIconUM, blurRadius: Dp = 26.dp)
is TangemIconUM.Icon -> SolidColorBackground(icon.tintReference(), blurRadius)
is TangemIconUM.Ident -> Unit
is TangemIconUM.Image -> ResBackground(icon.imageRes, blurRadius)
is TangemIconUM.Url -> UrlColorBackground(icon.url, blurRadius)
is TangemIconUM.Url -> {
icon.url?.let { iconUrl ->
UrlColorBackground(iconUrl, blurRadius)
} ?: run {
ResBackground(icon.fallbackRes, blurRadius)
}
}
}
}

View file

@ -54,13 +54,13 @@ fun TangemRowContainer(
// Head composable measurement
val headPlaceable = measurables.measure(
layoutId = TangemRowLayoutId.HEAD,
constraints = constraints,
constraints = constraints.copy(minWidth = 0),
)
// Tail composable measurement
val tailPlaceable = measurables.measure(
layoutId = TangemRowLayoutId.TAIL,
constraints = constraints,
constraints = constraints.copy(minWidth = 0),
)
val availableWidthForBody = layoutWidth - headPlaceable.widthOrZero() - tailPlaceable.widthOrZero()
@ -70,7 +70,7 @@ fun TangemRowContainer(
layoutId = TangemRowLayoutId.END_TOP,
constraints = constraints.copy(
minWidth = 0,
maxWidth = availableWidthForBody - startTopMinWidth,
maxWidth = max(0, availableWidthForBody - startTopMinWidth),
),
)
@ -79,7 +79,7 @@ fun TangemRowContainer(
layoutId = TangemRowLayoutId.END_BOTTOM,
constraints = constraints.copy(
minWidth = 0,
maxWidth = availableWidthForBody - startBottomMinWidth,
maxWidth = max(0, availableWidthForBody - startTopMinWidth),
),
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -154,7 +154,10 @@ private fun ArticleHeaderV2(
text = tag.text,
tangemIconUM = when (val content = tag.leadingContent) {
LabelLeadingContentUM.None -> null
is LabelLeadingContentUM.Token -> TangemIconUM.Url(content.iconUrl)
is LabelLeadingContentUM.Token -> TangemIconUM.Url(
url = content.iconUrl,
fallbackRes = R.drawable.ic_alert_24,
)
},
shape = TangemBadgeShape.Rounded,
size = TangemBadgeSize.X9,

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.SubcomposeMeasureScope
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
@ -40,7 +41,10 @@ internal fun Tags(tags: ImmutableList<LabelUM>, modifier: Modifier = Modifier) {
text = tag.text,
tangemIconUM = when (val content = tag.leadingContent) {
LabelLeadingContentUM.None -> null
is LabelLeadingContentUM.Token -> TangemIconUM.Url(content.iconUrl)
is LabelLeadingContentUM.Token -> TangemIconUM.Url(
url = content.iconUrl,
fallbackRes = R.drawable.ic_alert_24,
)
},
shape = TangemBadgeShape.Rounded,
size = TangemBadgeSize.X6,