From ba08d919503836fa55dad822f20141f390e82b6b Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 21 Nov 2025 12:18:57 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/common/ui/news/ArticleBadge.kt | 76 ++++ .../com/tangem/common/ui/news/ArticleCard.kt | 256 ++++++++++++ .../tangem/common/ui/news/ArticleConfigUM.kt | 13 + .../com/tangem/common/ui/news/ArticleTagUM.kt | 18 + core/res/src/main/res/values/strings.xml | 32 +- core/ui/src/main/res/drawable/ic_stars_20.xml | 12 + .../main/res/drawable/ic_start_circle_12.xml | 12 + features/feed/impl/build.gradle.kts | 6 + .../tangem/features/feed/ui/feed/FeedList.kt | 388 ++++++++++++++++++ .../preview/FeedListPreviewDataProvider.kt | 256 ++++++++++++ .../features/feed/ui/feed/state/FeedListUM.kt | 55 +++ .../ui/market/components/MarketListItem.kt | 322 +++++++++++++++ .../components/MarketsListItemPlaceholder.kt | 99 +++++ .../MarketChartListItemPreviewDataProvider.kt | 115 ++++++ .../feed/ui/market/state/MarketsListItemUM.kt | 46 +++ .../feed/ui/market/state/MarketsListUM.kt | 66 +++ 16 files changed, 1760 insertions(+), 12 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/news/ArticleBadge.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/news/ArticleTagUM.kt create mode 100644 core/ui/src/main/res/drawable/ic_stars_20.xml create mode 100644 core/ui/src/main/res/drawable/ic_start_circle_12.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleBadge.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleBadge.kt new file mode 100644 index 0000000000..157a36e08d --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleBadge.kt @@ -0,0 +1,76 @@ +package com.tangem.common.ui.news + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +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.unit.dp +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun ArticleBadge(articleTagUM: ArticleTagUM, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .heightIn(min = 24.dp) + .background( + color = TangemTheme.colors.icon.informative.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + when (articleTagUM) { + is ArticleTagUM.Category -> Unit + is ArticleTagUM.Token -> { + CurrencyIcon( + state = articleTagUM.iconState, + shouldDisplayNetwork = false, + iconSize = 16.dp, + ) + } + } + Text( + text = articleTagUM.title.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ArticleBadgePreview() { + TangemThemePreview { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ArticleBadge( + articleTagUM = ArticleTagUM.Token( + TextReference.Str("BTC"), + iconState = CurrencyIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + ) + ArticleBadge( + articleTagUM = ArticleTagUM.Category(TextReference.Str("Regulation")), + ) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt new file mode 100644 index 0000000000..259c72b98d --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt @@ -0,0 +1,256 @@ +package com.tangem.common.ui.news + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun ArticleCard(articleConfigUM: ArticleConfigUM, onArticleClick: () -> Unit, modifier: Modifier = Modifier) { + BlockCard( + modifier = modifier, + onClick = onArticleClick, + ) { + if (articleConfigUM.isTrending) { + TrendingArticle(articleConfigUM = articleConfigUM) + } else { + DefaultArticle(articleConfigUM = articleConfigUM) + } + } +} + +@Composable +private fun TrendingArticle(articleConfigUM: ArticleConfigUM) { + Column( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + text = stringResourceSafe(R.string.feed_trending_now), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + ) + + SpacerH(12.dp) + + Text( + text = articleConfigUM.title, + color = if (articleConfigUM.isViewed) { + TangemTheme.colors.text.tertiary + } else { + TangemTheme.colors.text.primary1 + }, + style = TangemTheme.typography.h3, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + + SpacerH(8.dp) + + ArticleInfo( + score = articleConfigUM.score, + createdAt = articleConfigUM.createdAt, + ) + + SpacerH(32.dp) + + Tags( + modifier = Modifier.padding(horizontal = 46.dp), + tags = articleConfigUM.tags.toImmutableList(), + ) + } +} + +@Composable +private fun DefaultArticle(articleConfigUM: ArticleConfigUM) { + Column(modifier = Modifier.padding(12.dp)) { + ArticleInfo( + score = articleConfigUM.score, + createdAt = articleConfigUM.createdAt, + ) + + SpacerH(8.dp) + + Text( + text = articleConfigUM.title, + color = if (articleConfigUM.isViewed) { + TangemTheme.colors.text.tertiary + } else { + TangemTheme.colors.text.primary1 + }, + style = TangemTheme.typography.subtitle1, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + + SpacerH(16.dp) + + Tags(tags = articleConfigUM.tags.toImmutableList()) + } +} + +@Composable +private fun ArticleInfo(score: Float, createdAt: String, modifier: Modifier = Modifier) { + val dotColor = TangemTheme.colors.text.secondary + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_start_circle_12), + contentDescription = null, + ) + + Text( + text = score.toString(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + + Spacer( + modifier = Modifier + .size(4.dp) + .drawWithCache { + val radius = size.minDimension / 2f + onDrawBehind { + drawCircle( + color = dotColor, + radius = radius, + ) + } + }, + ) + + Text( + text = createdAt, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Tags(tags: ImmutableList, modifier: Modifier = Modifier) { + val expandIndicator = remember { + ContextualFlowRowOverflow.expandIndicator { + val remainingItems = tags.size - shownItemCount + ArticleBadge(articleTagUM = ArticleTagUM.Category(TextReference.Str("${StringsSigns.PLUS}$remainingItems"))) + } + } + ContextualFlowRow( + itemCount = tags.size, + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxLines = 1, + overflow = expandIndicator, + ) { index -> + ArticleBadge(articleTagUM = tags[index]) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TagsPreview() { + TangemThemePreview { + Tags( + tags = listOf( + ArticleTagUM.Category(TextReference.Str("Hype")), + ArticleTagUM.Category(TextReference.Str("BTC")), + ArticleTagUM.Category(TextReference.Str("Supply")), + ArticleTagUM.Category(TextReference.Str("Demand")), + ArticleTagUM.Category(TextReference.Str("Best rate")), + ArticleTagUM.Category(TextReference.Str("Breaking news")), + ).toPersistentList(), + ) + } +} + +@Preview(showBackground = true, widthDp = 360, backgroundColor = 0xFF000000) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ArticleCardsPreview() { + val tags = listOf( + ArticleTagUM.Category(TextReference.Str("Hype")), + ArticleTagUM.Category(TextReference.Str("BTC")), + ArticleTagUM.Category(TextReference.Str("Supply")), + ArticleTagUM.Category(TextReference.Str("Demand")), + ArticleTagUM.Category(TextReference.Str("Best rate")), + ArticleTagUM.Category(TextReference.Str("Breaking news")), + ).toImmutableSet() + + val config = ArticleConfigUM( + id = 1, + title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)", + score = 9.5f, + createdAt = "1h ago", + isTrending = true, + tags = tags, + isViewed = false, + ) + + TangemThemePreview { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + ArticleCard( + articleConfigUM = config, + onArticleClick = {}, + ) + + SpacerH(20.dp) + + ArticleCard( + articleConfigUM = config.copy(isViewed = true), + onArticleClick = {}, + ) + + SpacerH(20.dp) + + ArticleCard( + articleConfigUM = config.copy(isTrending = false), + onArticleClick = {}, + ) + + SpacerH(20.dp) + + ArticleCard( + articleConfigUM = config.copy(isTrending = false, isViewed = true), + onArticleClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt new file mode 100644 index 0000000000..e6e9477790 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt @@ -0,0 +1,13 @@ +package com.tangem.common.ui.news + +import kotlinx.collections.immutable.ImmutableSet + +data class ArticleConfigUM( + val id: Int, + val title: String, + val score: Float, + val createdAt: String, + val isTrending: Boolean, + val tags: ImmutableSet, + val isViewed: Boolean, +) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleTagUM.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleTagUM.kt new file mode 100644 index 0000000000..24eb867ad2 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleTagUM.kt @@ -0,0 +1,18 @@ +package com.tangem.common.ui.news + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface ArticleTagUM { + + val title: TextReference + + data class Category(override val title: TextReference) : ArticleTagUM + + data class Token( + override val title: TextReference, + val iconState: CurrencyIconState, + ) : ArticleTagUM +} \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 6c0c43f6d2..ba7cf8ed61 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -167,7 +167,7 @@ All Tangem devices have been reset. You can now continue upgrading your wallet. Upgrade again Reset complete - You haven’t reset all your Tangem devices + We recommend completing the reset process for all Tangem devices in this wallet. You haven’t reset all your Tangem devices Disable this option if you don\'t want this card to be used to reset access codes on other cards or rings in this wallet. Please note that this will also prevent you from resetting the access code on this card. Allows you to use this card to reset access code on other cards in this wallet @@ -275,6 +275,7 @@ Forget Free From + From %s Synchronize addresses Get started Get token @@ -300,12 +301,13 @@ %d networks New address + News Next NFT No No address Not Added - Not Now + Not now Now OK Open in Browser @@ -357,6 +359,7 @@ terms and conditions Terms of Use To + To %s Today %d token @@ -519,6 +522,9 @@ ID: %s Transaction ID copied Swap any asset in your portfolio for this token + Market & News + Tangem AI + Trending Now The following information is optional. You can erase it if you don\'t want to share it. Tell us what functions you are missing, and we will try to help you. Please tell us what card or ring do you have @@ -605,7 +611,7 @@ Forget this wallet? I understand that if I haven\'t backed up my wallet before removing it, I may lose access to it. I understand that removing my wallet does not delete it—but simply removes it from my device. - No seed phrase is needed anymore — your Tangem card or ring becomes your secure backup. + No seed phrase is needed anymore. Your Tangem card or ring becomes your secure backup. Backup with Tangem Can’t upgrade. A wallet already exists on this device. Pick another device. This one can’t be used for the upgrade. @@ -772,6 +778,8 @@ Volume Pull this up or tap the search bar to add tokens directly from the market Add tokens + All news + Stay in the loop NFC is not available on your device About NFT NFT asset @@ -1070,7 +1078,7 @@ You specified a commission below the recommended amount, which could cause a delay in your transaction. Continue? Reason: %1$s\nCode: %2$s The transaction is not completed - Convert to another token + Swap to another token or network Amount Will be sent to recipient You can set your transaction fee by adjusting the value in the Satoshi per vByte field. @@ -1651,7 +1659,7 @@ Using a Tangem Wallet already? Scan now Pick a wallet setup method - Ready to get a Tangem Wallet? + Want to purchase Tangem Wallet? Buy now Recover existing wallet via Google Drive backup Import from Google Drive @@ -1673,7 +1681,7 @@ Get now with 10% off Access 13,000+ cryptocurrencies. Buy, sell, swap, and stake with a single tap.\nLink up to three cards for a backup. Discover Tangem Wallet - This secret code protects your wallet and is used to log in and sign transactions. + This access code protects your wallet and is used to log in and sign transactions. Set/Change access code Change access code Stay notified on wallet incoming transactions and Tangem updates. @@ -1869,7 +1877,7 @@ URI already used WalletConnect Suspicious transaction - Already have Tangem? + Already have Tangem Wallet? Thousands of assets Best in class hardware wallet Fast delivery @@ -1878,7 +1886,7 @@ Simple to use Create a hardware wallet with Tangem. Slim as a bank card, secure as a bank vault. Create or import a software wallet - Create or import a software wallet on your phone + Create or import a software wallet on your phone. Start with Mobile Wallet Other method Use Tangem Hardware Wallet @@ -1899,7 +1907,7 @@ To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ When Yield Mode is active, all future top-ups to this address will be supplied to Aave. You can still manage your funds freely. Your %s is supplied to Aave - Supplying %1$s %2$s to Aave is pending + Supplying %1$s %2$s to Aave Approve Your token\'s approval has been revoked. Grant it again to resume the service\'s functionality. Approve needed @@ -1975,9 +1983,9 @@ Yield Mode Enabling Yield Mode Yield Mode - Yield mode on - Yield mode off - Yield mode top-up + Yield Mode on + Yield Mode off + Yield Mode top-up Automatic Add some %1$s %2$s to cover the network fee for transactions. Unable to cover %s fee diff --git a/core/ui/src/main/res/drawable/ic_stars_20.xml b/core/ui/src/main/res/drawable/ic_stars_20.xml new file mode 100644 index 0000000000..fef8ac03d9 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_stars_20.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_start_circle_12.xml b/core/ui/src/main/res/drawable/ic_start_circle_12.xml new file mode 100644 index 0000000000..7846697a80 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_start_circle_12.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 2a54428d6f..c309d45ccf 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -9,6 +9,12 @@ plugins { android { namespace = "com.tangem.features.feed.impl" + + packaging { + resources { + merges += "paymentrequest.proto" + } + } } dependencies { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt new file mode 100644 index 0000000000..51c97516b7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -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, + 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 = {}, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt new file mode 100644 index 0000000000..734644fa3d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -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, + includeErrorState: Boolean, + ): ImmutableMap { + 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, + isSelected: Boolean, + ): MarketChartUM.Content { + return MarketChartUM.Content( + items = items, + triggerScrollReset = consumedEvent(), + sortChartConfig = SortChartConfigUM( + sortByType = sortByType, + isSelected = isSelected, + ), + ) + } + + private fun createSampleArticles(): ImmutableList = 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 { + 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 = 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, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt new file mode 100644 index 0000000000..7e002be1f4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -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, + 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, + 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, + val triggerScrollReset: StateEvent, + val sortChartConfig: SortChartConfigUM, + ) : MarketChartUM + + data object Loading : MarketChartUM + + data class LoadingError(val onRetryClicked: () -> Unit) : MarketChartUM +} + +data class SortChartConfigUM( + val sortByType: SortByTypeUM, + val isSelected: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt new file mode 100644 index 0000000000..9f011aa99a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt @@ -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") } + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt new file mode 100644 index 0000000000..ab52d344f0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt @@ -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() + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt new file mode 100644 index 0000000000..98dd938ce2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt @@ -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( + 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, + ), + ), +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt new file mode 100644 index 0000000000..5e518373dc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt @@ -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 = "@" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt new file mode 100644 index 0000000000..ea7e5f740a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt @@ -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, + val shouldShowUnder100kTokensNotification: Boolean, + val shouldShowUnder100kTokensNotificationWasHidden: Boolean, + val loadMore: () -> Unit, + val visibleIdsChanged: (List) -> Unit, + val onShowTokensUnder100kClicked: () -> Unit, + val triggerScrollReset: StateEvent, + val onItemClick: (MarketsListItemUM) -> Unit, + ) : ListUM() + + data object Loading : ListUM() + + data class LoadingError( + val onRetryClicked: () -> Unit, + ) : ListUM() + + data object SearchNothingFound : ListUM() +} \ No newline at end of file