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,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")),
)
}
}
}

View file

@ -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<ArticleTagUM>, 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 = {},
)
}
}
}

View file

@ -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<ArticleTagUM>,
val isViewed: Boolean,
)

View file

@ -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
}

View file

@ -167,7 +167,7 @@
<string name="card_reset_alert_finish_message">All Tangem devices have been reset. You can now continue upgrading your wallet.</string>
<string name="card_reset_alert_finish_ok_button">Upgrade again</string>
<string name="card_reset_alert_finish_title">Reset complete</string>
<string name="card_reset_alert_incomplete_message">You havent reset all your Tangem devices</string>
<string name="card_reset_alert_incomplete_message">We recommend completing the reset process for all Tangem devices in this wallet.</string>
<string name="card_reset_alert_incomplete_title">You havent reset all your Tangem devices</string>
<string name="card_settings_access_code_recovery_disabled_description">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.</string>
<string name="card_settings_access_code_recovery_enabled_description">Allows you to use this card to reset access code on other cards in this wallet</string>
@ -275,6 +275,7 @@
<string name="common_forget">Forget</string>
<string name="common_free">Free</string>
<string name="common_from">From</string>
<string name="common_from_wallet_name">From %s</string>
<string name="common_generate_addresses">Synchronize addresses</string>
<string name="common_get_started">Get started</string>
<string name="common_get_token">Get token</string>
@ -300,12 +301,13 @@
<item quantity="other">%d networks</item>
</plurals>
<string name="common_new_address">New address</string>
<string name="common_news">News</string>
<string name="common_next">Next</string>
<string name="common_nft">NFT</string>
<string name="common_no">No</string>
<string name="common_no_address">No address</string>
<string name="common_not_added">Not Added</string>
<string name="common_not_now">Not Now</string>
<string name="common_not_now">Not now</string>
<string name="common_now">Now</string>
<string name="common_ok">OK</string>
<string name="common_open_in_browser">Open in Browser</string>
@ -357,6 +359,7 @@
<string name="common_terms_and_conditions">terms and conditions</string>
<string name="common_terms_of_use">Terms of Use</string>
<string name="common_to">To</string>
<string name="common_to_wallet_name">To %s</string>
<string name="common_today">Today</string>
<plurals name="common_tokens_count">
<item quantity="one">%d token</item>
@ -519,6 +522,9 @@
<string name="express_transaction_id">ID: %s</string>
<string name="express_transaction_id_copied">Transaction ID copied</string>
<string name="exсhange_token_description">Swap any asset in your portfolio for this token</string>
<string name="feed_market_and_news">Market &amp; News</string>
<string name="feed_tangem_ai">Tangem AI</string>
<string name="feed_trending_now">Trending Now</string>
<string name="feedback_data_collection_message">The following information is optional. You can erase it if you don\'t want to share it.</string>
<string name="feedback_preface_rate_negative">Tell us what functions you are missing, and we will try to help you.</string>
<string name="feedback_preface_scan_failed">Please tell us what card or ring do you have</string>
@ -605,7 +611,7 @@
<string name="hw_remove_wallet_notification_title">Forget this wallet?</string>
<string name="hw_remove_wallet_warning_access">I understand that if I haven\'t backed up my wallet before removing it, I may lose access to it.</string>
<string name="hw_remove_wallet_warning_device">I understand that removing my wallet does not delete it—but simply removes it from my device.</string>
<string name="hw_upgrade_backup_description">No seed phrase is needed anymore — your Tangem card or ring becomes your secure backup.</string>
<string name="hw_upgrade_backup_description">No seed phrase is needed anymore. Your Tangem card or ring becomes your secure backup.</string>
<string name="hw_upgrade_backup_title">Backup with Tangem</string>
<string name="hw_upgrade_error_card_already_has_wallet">Cant upgrade. A wallet already exists on this device.</string>
<string name="hw_upgrade_error_card_key_import">Pick another device. This one cant be used for the upgrade.</string>
@ -772,6 +778,8 @@
<string name="markets_token_details_volume">Volume</string>
<string name="markets_tooltip_message">Pull this up or tap the search bar to add tokens directly from the market</string>
<string name="markets_tooltip_title">Add tokens</string>
<string name="news_all_news">All news</string>
<string name="news_stay_in_the_loop">Stay in the loop</string>
<string name="nfc_error_unavailable">NFC is not available on your device</string>
<string name="nft_about_title">About NFT</string>
<string name="nft_asset">NFT asset</string>
@ -1070,7 +1078,7 @@
<string name="send_alert_fee_too_low_text">You specified a commission below the recommended amount, which could cause a delay in your transaction. Continue?</string>
<string name="send_alert_transaction_failed_text">Reason: %1$s\nCode: %2$s</string>
<string name="send_alert_transaction_failed_title">The transaction is not completed</string>
<string name="send_amount_convert_to_another_token">Convert to another token</string>
<string name="send_amount_convert_to_another_token">Swap to another token or network</string>
<string name="send_amount_label">Amount</string>
<string name="send_amount_receive_token_subtitle">Will be sent to recipient</string>
<string name="send_bitcoin_custom_fee_footer">You can set your transaction fee by adjusting the value in the Satoshi per vByte field.</string>
@ -1651,7 +1659,7 @@
<string name="wallet_create_scan_question">Using a Tangem Wallet already?</string>
<string name="wallet_create_scan_title">Scan now</string>
<string name="wallet_create_title">Pick a wallet setup method</string>
<string name="wallet_import_buy_question">Ready to get a Tangem Wallet?</string>
<string name="wallet_import_buy_question">Want to purchase Tangem Wallet?</string>
<string name="wallet_import_buy_title">Buy now</string>
<string name="wallet_import_google_drive_description">Recover existing wallet via Google Drive backup</string>
<string name="wallet_import_google_drive_title">Import from Google Drive</string>
@ -1673,7 +1681,7 @@
<string name="wallet_promo_banner_button_title">Get now with 10% off</string>
<string name="wallet_promo_banner_description">Access 13,000+ cryptocurrencies. Buy, sell, swap, and stake with a single tap.\nLink up to three cards for a backup.</string>
<string name="wallet_promo_banner_title">Discover Tangem Wallet</string>
<string name="wallet_settings_access_code_description">This secret code protects your wallet and is used to log in and sign transactions.</string>
<string name="wallet_settings_access_code_description">This access code protects your wallet and is used to log in and sign transactions.</string>
<string name="wallet_settings_access_code_title">Set/Change access code</string>
<string name="wallet_settings_change_access_code_title">Change access code</string>
<string name="wallet_settings_push_notifications_description">Stay notified on wallet incoming transactions and Tangem updates.</string>
@ -1869,7 +1877,7 @@
<string name="wc_uri_already_used_title">URI already used</string>
<string name="wc_wallet_connect">WalletConnect</string>
<string name="wc_warning_transaction">Suspicious transaction</string>
<string name="welcome_create_wallet_already_have">Already have Tangem?</string>
<string name="welcome_create_wallet_already_have">Already have Tangem Wallet?</string>
<string name="welcome_create_wallet_feature_assets">Thousands of assets</string>
<string name="welcome_create_wallet_feature_class">Best in class hardware wallet</string>
<string name="welcome_create_wallet_feature_delivery">Fast delivery</string>
@ -1878,7 +1886,7 @@
<string name="welcome_create_wallet_feature_use">Simple to use</string>
<string name="welcome_create_wallet_hardware_description">Create a hardware wallet with Tangem. Slim as a bank card, secure as a bank vault.</string>
<string name="welcome_create_wallet_mobile_description">Create or import a software wallet</string>
<string name="welcome_create_wallet_mobile_description_full">Create or import a software wallet on your phone </string>
<string name="welcome_create_wallet_mobile_description_full">Create or import a software wallet on your phone.</string>
<string name="welcome_create_wallet_mobile_title">Start with Mobile Wallet</string>
<string name="welcome_create_wallet_other_method">Other method</string>
<string name="welcome_create_wallet_use_hardware_description">Use Tangem Hardware Wallet</string>
@ -1899,7 +1907,7 @@
<string name="xtz_withdrawal_message_warning">To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ</string>
<string name="yield_module_alert_description">When Yield Mode is active, all future top-ups to this address will be supplied to Aave. You can still manage your funds freely.</string>
<string name="yield_module_alert_title">Your %s is supplied to Aave</string>
<string name="yield_module_amount_not_transfered_to_aave_title">Supplying %1$s %2$s to Aave is pending</string>
<string name="yield_module_amount_not_transfered_to_aave_title">Supplying %1$s %2$s to Aave</string>
<string name="yield_module_approve_needed_notification_cta">Approve</string>
<string name="yield_module_approve_needed_notification_description">Your token\'s approval has been revoked. Grant it again to resume the service\'s functionality.</string>
<string name="yield_module_approve_needed_notification_title">Approve needed</string>
@ -1975,9 +1983,9 @@
<string name="yield_module_token_details_earn_notification_earning_on_your_balance_title">Yield Mode</string>
<string name="yield_module_token_details_earn_notification_processing">Enabling Yield Mode</string>
<string name="yield_module_token_details_earn_notification_title">Yield Mode</string>
<string name="yield_module_transaction_enter">Yield mode on</string>
<string name="yield_module_transaction_exit">Yield mode off</string>
<string name="yield_module_transaction_topup">Yield mode top-up</string>
<string name="yield_module_transaction_enter">Yield Mode on</string>
<string name="yield_module_transaction_exit">Yield Mode off</string>
<string name="yield_module_transaction_topup">Yield Mode top-up</string>
<string name="yield_module_transfer_mode_automatic">Automatic</string>
<string name="yield_module_unable_to_cover_fee_description">Add some %1$s %2$s to cover the network fee for transactions.</string>
<string name="yield_module_unable_to_cover_fee_title">Unable to cover %s fee</string>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M5.191,7.952C5.001,7.952 4.853,7.822 4.836,7.614C4.559,5.337 4.464,5.268 2.126,4.904C1.901,4.87 1.771,4.749 1.771,4.55C1.771,4.359 1.901,4.229 2.083,4.195C4.455,3.753 4.559,3.753 4.836,1.476C4.853,1.277 5.001,1.139 5.191,1.139C5.381,1.139 5.52,1.277 5.537,1.468C5.84,3.796 5.91,3.866 8.29,4.195C8.463,4.221 8.602,4.359 8.602,4.55C8.602,4.74 8.472,4.87 8.29,4.904C5.901,5.346 5.84,5.355 5.537,7.64C5.52,7.822 5.373,7.952 5.191,7.952ZM11.268,17.57C10.974,17.57 10.74,17.353 10.688,17.05C10.004,12.401 9.398,11.804 4.845,11.189C4.524,11.146 4.308,10.913 4.308,10.609C4.308,10.307 4.533,10.064 4.845,10.03C9.407,9.519 10.065,8.826 10.688,4.177C10.74,3.866 10.965,3.649 11.268,3.649C11.563,3.649 11.788,3.866 11.84,4.177C12.506,8.826 13.13,9.484 17.683,10.03C18.003,10.073 18.229,10.307 18.229,10.609C18.229,10.913 18.003,11.155 17.683,11.189C13.121,11.7 12.463,12.401 11.84,17.05C11.788,17.353 11.563,17.57 11.268,17.57Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M5.191,7.952C5.001,7.952 4.853,7.822 4.836,7.614C4.559,5.337 4.464,5.268 2.126,4.904C1.901,4.87 1.771,4.749 1.771,4.55C1.771,4.359 1.901,4.229 2.083,4.195C4.455,3.753 4.559,3.753 4.836,1.476C4.853,1.277 5.001,1.139 5.191,1.139C5.381,1.139 5.52,1.277 5.537,1.468C5.84,3.796 5.91,3.866 8.29,4.195C8.463,4.221 8.602,4.359 8.602,4.55C8.602,4.74 8.472,4.87 8.29,4.904C5.901,5.346 5.84,5.355 5.537,7.64C5.52,7.822 5.373,7.952 5.191,7.952ZM11.268,17.57C10.974,17.57 10.74,17.353 10.688,17.05C10.004,12.401 9.398,11.804 4.845,11.189C4.524,11.146 4.308,10.913 4.308,10.609C4.308,10.307 4.533,10.064 4.845,10.03C9.407,9.519 10.065,8.826 10.688,4.177C10.74,3.866 10.965,3.649 11.268,3.649C11.563,3.649 11.788,3.866 11.84,4.177C12.506,8.826 13.13,9.484 17.683,10.03C18.003,10.073 18.229,10.307 18.229,10.609C18.229,10.913 18.003,11.155 17.683,11.189C13.121,11.7 12.463,12.401 11.84,17.05C11.788,17.353 11.563,17.57 11.268,17.57Z"
android:fillColor="#6B5EEC"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="12dp"
android:viewportWidth="12"
android:viewportHeight="12">
<path
android:pathData="M0,6C0,2.686 2.686,0 6,0C9.314,0 12,2.686 12,6C12,9.314 9.314,12 6,12C2.686,12 0,9.314 0,6Z"
android:fillColor="#FFB71B"/>
<path
android:pathData="M5.703,2.789C5.829,2.55 6.172,2.55 6.298,2.789L7.152,4.407C7.201,4.5 7.29,4.564 7.393,4.582L9.196,4.894C9.462,4.94 9.568,5.266 9.38,5.46L8.104,6.773C8.032,6.848 7.998,6.952 8.013,7.055L8.273,8.867C8.312,9.135 8.034,9.336 7.791,9.217L6.149,8.409C6.055,8.363 5.946,8.363 5.852,8.409L4.21,9.217C3.967,9.336 3.689,9.135 3.728,8.867L3.988,7.055C4.003,6.952 3.969,6.848 3.897,6.773L2.621,5.46C2.433,5.266 2.538,4.94 2.805,4.894L4.608,4.582C4.711,4.564 4.8,4.5 4.849,4.407L5.703,2.789Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -9,6 +9,12 @@ plugins {
android {
namespace = "com.tangem.features.feed.impl"
packaging {
resources {
merges += "paymentrequest.proto"
}
}
}
dependencies {

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()
}