Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-21 12:18:57 +01:00
parent ca9e191cb8
commit ba08d91950
16 changed files with 1760 additions and 12 deletions

View file

@ -0,0 +1,388 @@
package com.tangem.features.feed.ui.feed
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.FeedListCallbacks
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.MarketChartConfig
import com.tangem.features.feed.ui.feed.state.MarketChartUM
import com.tangem.features.feed.ui.market.components.MarketsListItem
import com.tangem.features.feed.ui.market.components.MarketsListItemPlaceholder
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun FeedList(state: FeedListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.drawBehind { drawRect(background) },
) {
SearchBar(
modifier = Modifier
.drawBehind { drawRect(background) }
.padding(
start = 16.dp,
end = 16.dp,
bottom = 8.dp,
)
.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(coordinates.size.height.toDp())
}
}
}
.padding(bottom = 4.dp),
state = state.searchBar,
)
SpacerH(20.dp)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResourceSafe(R.string.feed_market_and_news),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = state.currentDate,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(32.dp)
MarketBlock(
marketChartConfig = state.marketChartConfig,
feedListCallbacks = state.feedListCallbacks,
)
NewsBlock(
news = state.news,
feedListCallbacks = state.feedListCallbacks,
trendingArticle = state.trendingArticle,
)
MarketPulseBlock(
marketChartConfig = state.marketChartConfig,
feedListCallbacks = state.feedListCallbacks,
)
}
}
@Composable
private fun MarketBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
if (marketChartConfig.marketCharts.isNotEmpty()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
)
SpacerH(12.dp)
marketChartConfig.marketCharts[SortByTypeUM.Rating]?.let { chart ->
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = chart,
)
}
SpacerH(32.dp)
}
}
@Composable
private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
if (marketChartConfig.marketCharts.isNotEmpty()) {
LazyRow(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
state = rememberLazyListState(),
) {
items(
items = marketChartConfig.getFilterPreset(),
key = SortByTypeUM::name,
) { sortByTypeUM ->
FilterChip(
sortByTypeUM = sortByTypeUM,
isSelected = sortByTypeUM == marketChartConfig.currentSortByType,
onClick = { feedListCallbacks.onSortTypeClick(sortByTypeUM) },
)
}
}
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType) },
)
SpacerH(12.dp)
AnimatedContent(
targetState = marketChartConfig.currentSortByType,
label = "MarketPulseChartAnimation",
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentSortType ->
marketChartConfig.marketCharts[currentSortType]?.let { chart ->
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = chart,
)
}
}
SpacerH(32.dp)
}
}
@Suppress("CanBeNonNullable")
@Composable
private fun NewsBlock(
feedListCallbacks: FeedListCallbacks,
news: ImmutableList<ArticleConfigUM>,
trendingArticle: ArticleConfigUM?,
) {
if (news.isNotEmpty()) {
Header(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
SpacerW(4.dp)
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20),
contentDescription = null,
)
SpacerW(2.dp)
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle().copy(
brush = Brush.linearGradient(
GRADIENT_START to LinearGradientFirstPart,
GRADIENT_END to LinearGradientSecondPart,
),
),
) {
append(stringResourceSafe(R.string.feed_tangem_ai))
}
},
style = TangemTheme.typography.subtitle1,
)
}
},
onSeeAllClick = feedListCallbacks.onOpenAllNews,
)
SpacerH(12.dp)
trendingArticle?.let { article ->
ArticleCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
)
SpacerH(12.dp)
}
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
) {
items(
items = news,
key = ArticleConfigUM::id,
) { article ->
ArticleCard(
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = Modifier.size(164.dp),
)
}
}
}
}
@Composable
private fun Header(title: @Composable () -> Unit, onSeeAllClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
title()
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
}
}
@Composable
private fun Charts(
marketChart: MarketChartUM,
onItemClick: (MarketsListItemUM) -> Unit,
modifier: Modifier = Modifier,
) {
BlockCard(modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
when (marketChart) {
MarketChartUM.Loading -> {
repeat(DEFAULT_CHART_SIZE_IN_MARKET) {
MarketsListItemPlaceholder()
}
}
is MarketChartUM.LoadingError -> {
// TODO will be created in [REDACTED_TASK_KEY]
}
is MarketChartUM.Content -> {
marketChart.items.fastForEach { chart ->
MarketsListItem(
model = chart,
onClick = { onItemClick(chart) },
)
}
}
}
}
}
}
@Composable
private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(shape = RoundedCornerShape(12.dp))
.background(
color = if (isSelected) {
TangemTheme.colors.button.primary
} else {
TangemTheme.colors.button.secondary
},
)
.clickable(
onClick = onClick,
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
)
.padding(vertical = 8.dp, horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = sortByTypeUM.text.resolveReference(),
style = TangemTheme.typography.button,
color = if (isSelected) {
TangemTheme.colors.text.primary2
} else {
TangemTheme.colors.text.primary1
},
)
}
}
private const val DEFAULT_CHART_SIZE_IN_MARKET = 5
private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f
private val LinearGradientFirstPart = Color(0xFF635EEC)
private val LinearGradientSecondPart = Color(0xFFE05AED)
@Preview(showBackground = true)
@Composable
private fun FeedListPreview() {
TangemThemePreview {
FeedList(
state = createFeedPreviewState(),
onHeaderSizeChange = {},
)
}
}

View file

@ -0,0 +1,256 @@
package com.tangem.features.feed.ui.feed.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.common.ui.news.ArticleTagUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.*
@Suppress("MagicNumber")
internal object FeedListPreviewDataProvider {
fun createFeedPreviewState(): FeedListUM {
val articles = createSampleArticles()
val marketItems = createSampleMarketItems()
return FeedListUM(
currentDate = "20 November",
searchBar = SearchBarUM(
placeholderText = TextReference.Str("Search tokens & news"),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
),
feedListCallbacks = FeedListCallbacks(
onSearchClick = {},
onMarketOpenClick = {},
onArticleClick = {},
onOpenAllNews = {},
onMarketItemClick = {},
onSortTypeClick = {},
),
news = articles.filter { it.isTrending.not() }.toImmutableList(),
trendingArticle = articles.first { it.isTrending },
marketChartConfig = MarketChartConfig(
marketCharts = createMarketCharts(marketItems, includeErrorState = false),
currentSortByType = SortByTypeUM.TopGainers,
),
)
}
private fun createMarketCharts(
items: ImmutableList<MarketsListItemUM>,
includeErrorState: Boolean,
): ImmutableMap<SortByTypeUM, MarketChartUM> {
val baseCharts = mapOf(
SortByTypeUM.TopGainers to createChartContent(
sortByType = SortByTypeUM.TopGainers,
items = items,
isSelected = true,
),
SortByTypeUM.Trending to createChartContent(
sortByType = SortByTypeUM.Trending,
items = items,
isSelected = false,
),
SortByTypeUM.ExperiencedBuyers to createChartContent(
sortByType = SortByTypeUM.ExperiencedBuyers,
items = items,
isSelected = false,
),
SortByTypeUM.Staking to MarketChartUM.Loading,
SortByTypeUM.TopLosers to MarketChartUM.Loading,
)
val ratingChart = if (includeErrorState) {
MarketChartUM.LoadingError(onRetryClicked = {})
} else {
createChartContent(
sortByType = SortByTypeUM.Rating,
items = items,
isSelected = false,
)
}
return persistentMapOf(
SortByTypeUM.Rating to ratingChart,
*baseCharts.entries.map { it.toPair() }.toTypedArray(),
)
}
private fun createChartContent(
sortByType: SortByTypeUM,
items: ImmutableList<MarketsListItemUM>,
isSelected: Boolean,
): MarketChartUM.Content {
return MarketChartUM.Content(
items = items,
triggerScrollReset = consumedEvent(),
sortChartConfig = SortChartConfigUM(
sortByType = sortByType,
isSelected = isSelected,
),
)
}
private fun createSampleArticles(): ImmutableList<ArticleConfigUM> = persistentListOf(
ArticleConfigUM(
id = 1,
title = "Bitcoin ETF reaches new highs, institutions pile in",
score = 0.82f,
createdAt = "2h ago",
isTrending = true,
tags = createArticleTags(),
isViewed = false,
),
ArticleConfigUM(
id = 2,
title = "Layer 2 networks battle for dominance amid fee wars",
score = 0.71f,
createdAt = "4h ago",
isTrending = false,
tags = createArticleTags(),
isViewed = true,
),
ArticleConfigUM(
id = 3,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
isTrending = false,
tags = createArticleTags(),
isViewed = false,
),
ArticleConfigUM(
id = 4,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
isTrending = false,
tags = createArticleTags(),
isViewed = false,
),
ArticleConfigUM(
id = 5,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
isTrending = false,
tags = createArticleTags(),
isViewed = false,
),
ArticleConfigUM(
id = 6,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
isTrending = false,
tags = createArticleTags(),
isViewed = false,
),
)
private fun createArticleTags(): ImmutableSet<ArticleTagUM> {
return persistentSetOf(
ArticleTagUM.Token(
title = TextReference.Str("BTC"),
iconState = CurrencyIconState.CoinIcon(
url = "",
fallbackResId = 0,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
ArticleTagUM.Category(TextReference.Str("Regulation")),
ArticleTagUM.Category(TextReference.Str("BTC")),
ArticleTagUM.Category(TextReference.Str("Supply")),
ArticleTagUM.Category(TextReference.Str("Demand")),
)
}
private fun createSampleMarketItems(): ImmutableList<MarketsListItemUM> = persistentListOf(
createMarketItem(
id = "btc",
name = "Bitcoin",
symbol = "BTC",
trendType = PriceChangeType.UP,
rating = "1",
marketCap = "$1.2T",
percent = "12.3%",
),
createMarketItem(
id = "eth",
name = "Ethereum",
symbol = "ETH",
trendType = PriceChangeType.DOWN,
rating = "2",
marketCap = "$480B",
percent = "-3.1%",
),
createMarketItem(
id = "sol",
name = "Solana",
symbol = "SOL",
trendType = PriceChangeType.NEUTRAL,
rating = "7",
marketCap = "$110B",
percent = "0.4%",
),
createMarketItem(
id = "bnb",
name = "BNB",
symbol = "BNB",
trendType = PriceChangeType.NEUTRAL,
rating = "7",
marketCap = "$110B",
percent = "0.4%",
),
createMarketItem(
id = "doge",
name = "Dodge",
symbol = "DOG",
trendType = PriceChangeType.NEUTRAL,
rating = "7",
marketCap = "$110B",
percent = "0.4%",
),
)
@Suppress("LongParameterList")
private fun createMarketItem(
id: String,
name: String,
symbol: String,
trendType: PriceChangeType,
rating: String,
marketCap: String,
percent: String,
): MarketsListItemUM {
return MarketsListItemUM(
id = CryptoCurrency.RawID(id),
name = name,
currencySymbol = symbol,
iconUrl = null,
ratingPosition = rating,
marketCap = marketCap,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = percent,
trendType = trendType,
chartData = 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),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
)
}
}

View file

@ -0,0 +1,55 @@
package com.tangem.features.feed.ui.feed.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toPersistentList
internal data class FeedListUM(
val currentDate: String,
val searchBar: SearchBarUM,
val feedListCallbacks: FeedListCallbacks,
val news: ImmutableList<ArticleConfigUM>,
val trendingArticle: ArticleConfigUM?,
val marketChartConfig: MarketChartConfig,
)
internal data class FeedListCallbacks(
val onSearchClick: () -> Unit,
val onMarketOpenClick: (sortBy: SortByTypeUM) -> Unit,
val onArticleClick: (id: Int) -> Unit,
val onOpenAllNews: () -> Unit,
val onMarketItemClick: (MarketsListItemUM) -> Unit,
val onSortTypeClick: (SortByTypeUM) -> Unit,
)
internal data class MarketChartConfig(
val marketCharts: ImmutableMap<SortByTypeUM, MarketChartUM>,
val currentSortByType: SortByTypeUM = SortByTypeUM.TopGainers,
) {
fun getFilterPreset() = SortByTypeUM.entries.filter { it != SortByTypeUM.Rating }.toPersistentList()
}
@Immutable
internal sealed interface MarketChartUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val triggerScrollReset: StateEvent<Unit>,
val sortChartConfig: SortChartConfigUM,
) : MarketChartUM
data object Loading : MarketChartUM
data class LoadingError(val onRetryClicked: () -> Unit) : MarketChartUM
}
data class SortChartConfigUM(
val sortByType: SortByTypeUM,
val isSelected: Boolean,
)

View file

@ -0,0 +1,322 @@
package com.tangem.features.feed.ui.market.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
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.RectangleShape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
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.tokens.TokenPriceText
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.features.feed.ui.market.preview.MarketChartListItemPreviewDataProvider
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContent(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
.clickable(onClick = onClick)
.testTag(MarketsTestTags.TOKENS_LIST_ITEM),
model = model,
)
}
@Composable
private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
Row(
modifier = modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing15,
),
verticalAlignment = Alignment.CenterVertically,
) {
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size36),
url = model.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
SpacerW12()
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TokenTitle(
modifier = Modifier.weight(1f, fill = false),
name = model.name,
currencySymbol = model.currencySymbol,
)
SpacerW8()
TokenPriceText(
modifier = Modifier.alignByBaseline(),
price = model.price.text,
priceChangeType = model.price.changeType,
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
TokenSubtitle(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
ratingPosition = model.ratingPosition,
marketCap = model.marketCap,
stakingRate = model.stakingRate,
)
PriceChangeInPercent(
modifier = Modifier.alignByBaseline(),
textStyle = TangemTheme.typography.caption2,
type = model.trendType,
valueInPercent = model.trendPercentText,
)
}
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Spacer(Modifier.width(TangemTheme.dimens.spacing10))
Chart(
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.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW4()
Text(
modifier = Modifier.alignByBaseline(),
text = currencySymbol,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun TokenSubtitle(
ratingPosition: String?,
marketCap: String?,
stakingRate: TextReference?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
TokenRatingPlace(ratingPosition = ratingPosition)
if (marketCap != null) {
SpacerW4()
TokenMarketCapText(
modifier = Modifier.weight(1f, fill = false),
text = marketCap,
)
}
if (stakingRate != null) {
SpacerW4()
StakingRate(stakingRate = stakingRate.resolveReference())
}
}
}
@Composable
private fun RowScope.TokenRatingPlace(ratingPosition: String?) {
Box(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.background(
color = TangemTheme.colors.field.primary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.spacing5),
) {
Text(
text = ratingPosition ?: MINUS,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun RowScope.StakingRate(stakingRate: String) {
Box(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.border(
width = TangemTheme.dimens.size1,
color = TangemTheme.colors.field.primary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.spacing5),
) {
Text(
text = stakingRate,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun RowScope.TokenMarketCapText(text: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier.alignByBaseline(),
text = text,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) {
val chartWidth = TangemTheme.dimens.size56
Box(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing2)
.size(height = TangemTheme.dimens.size24, width = chartWidth),
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
)
} else {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size12)
.align(Alignment.Center),
radius = TangemTheme.dimens.radius3,
)
}
}
}
// region preview
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 260, name = "small width")
@Composable
private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) {
TangemThemePreview {
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)) {
MarketsListItem(
modifier = Modifier,
model = state1,
)
MarketsListItem(
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,99 @@
package com.tangem.features.feed.ui.market.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.windowsize.WindowSizeType
@Suppress("LongMethod")
@Composable
fun MarketsListItemPlaceholder() {
val density = LocalDensity.current
val windowSize = LocalWindowSize.current
val sp12 = with(density) { 12.sp.toDp() }
Row(
modifier = Modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing15,
),
verticalAlignment = Alignment.CenterVertically,
) {
CircleShimmer(Modifier.size(TangemTheme.dimens.size36))
SpacerW12()
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing4),
contentAlignment = Alignment.CenterStart,
) {
RectangleShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size70)
.height(sp12),
radius = TangemTheme.dimens.radius3,
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing2),
contentAlignment = Alignment.CenterStart,
) {
RectangleShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size52)
.height(sp12),
radius = TangemTheme.dimens.radius3,
)
}
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Spacer(Modifier.width(TangemTheme.dimens.spacing10))
Box {
RectangleShimmer(
modifier = Modifier
.align(Alignment.Center)
.width(TangemTheme.dimens.size56)
.height(TangemTheme.dimens.size12),
radius = TangemTheme.dimens.radius3,
)
}
}
}
}
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 320, name = "small width")
@Composable
private fun Preview() {
TangemThemePreview {
Column(Modifier.background(TangemTheme.colors.background.tertiary)) {
repeat(20) {
MarketsListItemPlaceholder()
}
}
}
}

View file

@ -0,0 +1,115 @@
package com.tangem.features.feed.ui.market.preview
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import kotlinx.collections.immutable.persistentListOf
@Suppress("MagicNumber")
internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider<MarketsListItemUM>(
collection = listOf(
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = 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),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.NEUTRAL,
chartData = null,
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.DOWN,
chartData = 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),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = null,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = 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),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = 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),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = null,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = 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),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
),
)

View file

@ -0,0 +1,46 @@
package com.tangem.features.feed.ui.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
@Immutable
data class MarketsListItemUM(
val id: CryptoCurrency.RawID,
val name: String,
val currencySymbol: String,
val iconUrl: String?,
val ratingPosition: String?,
val marketCap: String?,
val price: Price,
val trendPercentText: String,
val trendType: PriceChangeType,
val chartData: MarketChartRawData?,
val isUnder100kMarketCap: Boolean,
val stakingRate: TextReference?,
val updateTimestamp: Long?,
) {
val chartType: MarketChartLook.Type = when (trendType) {
PriceChangeType.UP -> MarketChartLook.Type.Growing
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
@Immutable
data class Price(
val text: String,
val changeType: PriceChangeType? = null,
)
@Suppress("NullableToStringCall")
fun getComposeKey(): String {
return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.toString() + updateTimestamp
}
companion object {
const val TOKEN_LAZY_LIST_ID_SEPARATOR = "@"
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.features.feed.ui.market.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
internal data class MarketsListUM(
val list: ListUM,
val searchBar: SearchBarUM,
val selectedSortBy: SortByTypeUM,
val sortByBottomSheet: TangemBottomSheetConfig,
val selectedInterval: TrendInterval,
val onIntervalClick: (TrendInterval) -> Unit,
val onSortByButtonClick: () -> Unit,
val stakingNotificationMaxApy: BigDecimal?,
val onStakingNotificationClick: () -> Unit,
val onStakingNotificationCloseClick: () -> Unit,
) {
val isInSearchMode
get() = searchBar.isActive
enum class TrendInterval(val text: TextReference) {
H24(resourceReference(R.string.markets_selector_interval_24h_title)),
D7(resourceReference(R.string.markets_selector_interval_7d_title)),
M1(resourceReference(R.string.markets_selector_interval_1m_title)),
}
}
enum class SortByTypeUM(val text: TextReference) {
Rating(resourceReference(R.string.markets_sort_by_rating_title)),
Trending(resourceReference(R.string.markets_sort_by_trending_title)),
ExperiencedBuyers(resourceReference(R.string.markets_sort_by_experienced_buyers_title)),
TopGainers(resourceReference(R.string.markets_sort_by_top_gainers_title)),
TopLosers(resourceReference(R.string.markets_sort_by_top_losers_title)),
Staking(resourceReference(R.string.common_staking)),
}
@Immutable
sealed class ListUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val shouldShowUnder100kTokensNotification: Boolean,
val shouldShowUnder100kTokensNotificationWasHidden: Boolean,
val loadMore: () -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
val onShowTokensUnder100kClicked: () -> Unit,
val triggerScrollReset: StateEvent<Unit>,
val onItemClick: (MarketsListItemUM) -> Unit,
) : ListUM()
data object Loading : ListUM()
data class LoadingError(
val onRetryClicked: () -> Unit,
) : ListUM()
data object SearchNothingFound : ListUM()
}