Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-29 14:08:06 +01:00
parent 7d599256d1
commit 028b21b112
13 changed files with 574 additions and 86 deletions

View file

@ -41,8 +41,9 @@ fun TrendingLoadingArticle(modifier: Modifier = Modifier) {
}
@Composable
fun DefaultLoadingArticle() {
fun DefaultLoadingArticle(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(modifier = Modifier.padding(12.dp)) {

View file

@ -817,6 +817,7 @@
</plurals>
<string name="news_quick_recap">Quick recap</string>
<string name="news_related_news">Related News</string>
<string name="news_related_tokens">Related tokens</string>
<string name="news_sources">Sources</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>

View file

@ -86,6 +86,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
articleId = articleId,
onBackClicked = { onChildBack() },
preselectedArticlesId = preselectedArticlesId,
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
),
),
)
@ -195,6 +196,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
articleId = entryRoute.articleId,
onBackClicked = { router.pop() },
preselectedArticlesId = entryRoute.preselectedArticlesId,
onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) },
),
)
null -> FeedEntryChildFactory.Child.Feed

View file

@ -13,6 +13,8 @@ import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.ui.news.details.NewsDetailsContent
import kotlinx.serialization.Serializable
@ -57,8 +59,7 @@ internal class DefaultNewsDetailsComponent(
data class Params(
val articleId: Int,
val onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val preselectedArticlesId: List<Int> = emptyList(),
val tokenIds: List<String> = emptyList(),
val categoryIds: List<Int> = emptyList(),
)
}

View file

@ -1,19 +1,38 @@
package com.tangem.features.feed.model.news.details
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter
import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter
import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter
import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.news.RelatedToken
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Locale
@ -24,21 +43,31 @@ import javax.inject.Inject
@Suppress("LongParameterList")
internal class NewsDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>()
private val currentLanguage = Locale.getDefault().language
private val relatedTokensCache = mutableMapOf<Int, RelatedTokensUM>()
private val converter = NewsDetailsConverter(
onSourceClick = { url ->
urlOpener.openUrl(url)
},
)
private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl)
private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter()
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.flowOn(dispatchers.default)
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val _state = MutableStateFlow(
NewsDetailsUM(
@ -51,6 +80,14 @@ internal class NewsDetailsModel @Inject constructor(
),
)
private val stateFactory by lazy(LazyThreadSafetyMode.NONE) {
NewsDetailsStateFactory(
currentStateProvider = Provider { _state.value },
shareManager = shareManager,
onStateUpdate = { newState -> _state.update { newState } },
)
}
val state: StateFlow<NewsDetailsUM> = _state.asStateFlow()
init {
@ -62,17 +99,9 @@ internal class NewsDetailsModel @Inject constructor(
}
private fun onArticleIndexChanged(newIndex: Int) {
stateFactory.updateSelectedArticleIndex(newIndex)
val currentArticle = state.value.articles.getOrNull(newIndex)
_state.update { currentState ->
currentState.copy(
selectedArticleIndex = newIndex,
onShareClick = {
currentArticle?.let {
shareManager.shareText(it.newsUrl)
}
},
)
}
currentArticle?.let(::loadRelatedTokens)
}
private fun handlePreselectedArticles() {
@ -87,7 +116,7 @@ internal class NewsDetailsModel @Inject constructor(
.map { articlesMap ->
params.preselectedArticlesId.mapNotNull { articleId ->
articlesMap[articleId]?.let { detailedArticle ->
converter.convert(detailedArticle)
newsDetailsConverter.convert(detailedArticle)
}
}
}
@ -96,20 +125,137 @@ internal class NewsDetailsModel @Inject constructor(
}
.onEach { articles ->
val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0
stateFactory.updateArticles(articles, selectedIndex)
val currentArticle = articles.getOrNull(selectedIndex)
_state.update { newsDetailsUM ->
newsDetailsUM.copy(
articles = articles,
selectedArticleIndex = selectedIndex,
onShareClick = {
currentArticle?.let {
shareManager.shareText(it.newsUrl)
}
},
)
}
currentArticle?.let(::loadRelatedTokens)
}
.launchIn(modelScope)
}
}
private fun loadRelatedTokens(article: ArticleUM) {
modelScope.launch(dispatchers.default) {
val cachedTokens = getCachedRelatedTokens(article.id)
if (cachedTokens != null) {
stateFactory.updateRelatedTokens(cachedTokens)
return@launch
}
stateFactory.updateRelatedTokens(RelatedTokensUM.Loading)
val relatedTokens = article.relatedTokens.take(RELATED_TOKEN_MAX_COUNT)
if (relatedTokens.isEmpty()) {
handleEmptyRelatedTokens(article.id)
return@launch
}
val appCurrency = currentAppCurrency.value
val tokenDataList = loadTokensData(relatedTokens, appCurrency)
if (tokenDataList.isEmpty()) {
handleEmptyTokenData(article.id)
return@launch
}
val resultState = createRelatedTokensState(tokenDataList, appCurrency)
relatedTokensCache[article.id] = resultState
stateFactory.updateRelatedTokens(resultState)
}
}
private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? {
return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading }
}
private fun handleEmptyRelatedTokens(articleId: Int) {
val errorState = RelatedTokensUM.LoadingError
relatedTokensCache[articleId] = errorState
stateFactory.updateRelatedTokens(errorState)
}
private suspend fun CoroutineScope.loadTokensData(
relatedTokens: List<RelatedToken>,
appCurrency: AppCurrency,
): List<Pair<MarketsListItemUM, TokenMarketParams>> {
val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency)
return relatedTokens.map { token ->
async(dispatchers.default) {
loadSingleTokenData(token, appCurrency, relatedTokenConverter)
}
}.awaitAll().filterNotNull()
}
private suspend fun loadSingleTokenData(
token: RelatedToken,
appCurrency: AppCurrency,
relatedTokenConverter: RelatedTokenConverter,
): Pair<MarketsListItemUM, TokenMarketParams>? {
val tokenId = CryptoCurrency.RawID(token.id)
val tokenInfoResult = getTokenMarketInfoUseCase(
appCurrency = appCurrency,
tokenId = tokenId,
tokenSymbol = token.symbol,
)
return tokenInfoResult.fold(
ifLeft = { null },
ifRight = { tokenInfo ->
val chart = loadTokenChart(tokenId, token.symbol, appCurrency)
val tokenItem = relatedTokenConverter.convert(tokenInfo to chart)
val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo)
tokenItem to tokenParams
},
)
}
private suspend fun loadTokenChart(
tokenId: CryptoCurrency.RawID,
tokenSymbol: String,
appCurrency: AppCurrency,
): com.tangem.domain.markets.TokenChart? {
val chartResult = getTokenPriceChartUseCase(
appCurrency = appCurrency,
interval = PriceChangeInterval.H24,
tokenId = tokenId,
tokenSymbol = tokenSymbol,
preview = true,
)
return chartResult.getOrElse { null }
}
private fun handleEmptyTokenData(articleId: Int) {
val errorState = RelatedTokensUM.LoadingError
relatedTokensCache[articleId] = errorState
stateFactory.updateRelatedTokens(errorState)
}
private fun createRelatedTokensState(
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
appCurrency: AppCurrency,
): RelatedTokensUM.Content {
val tokenItems = tokenDataList.map { it.first }
val onTokenClick = createTokenClickHandler(tokenDataList, appCurrency)
return stateFactory.createRelatedTokensContent(
items = tokenItems.toImmutableList(),
onTokenClick = onTokenClick,
)
}
private fun createTokenClickHandler(
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
appCurrency: AppCurrency,
): (MarketsListItemUM) -> Unit {
return { item ->
val tokenData = tokenDataList.find { it.first.id == item.id }
tokenData?.second?.let { tokenParams ->
params.onTokenClick(tokenParams, appCurrency)
}
}
}
companion object {
internal const val RELATED_TOKEN_MAX_COUNT = 5
}
}

View file

@ -37,6 +37,7 @@ internal class NewsDetailsConverter(
content = value.content,
sources = buildSources(value),
newsUrl = value.newsUrl,
relatedTokens = value.relatedTokens.toImmutableList(),
)
}
@ -66,6 +67,7 @@ internal class NewsDetailsConverter(
publishedAt = mapFormattedDate(originalArticle.publishedAt),
url = originalArticle.url,
onClick = { onSourceClick(originalArticle.url) },
imageUrl = originalArticle.imageUrl,
)
}.toImmutableList()
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.feed.model.news.details.converter
import androidx.compose.runtime.Stable
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenChart
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
@Stable
internal class RelatedTokenConverter(private val appCurrency: AppCurrency) :
Converter<Pair<TokenMarketInfo, TokenChart?>, MarketsListItemUM> {
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
override fun convert(value: Pair<TokenMarketInfo, TokenChart?>): MarketsListItemUM {
val (tokenInfo, chart) = value
val tokenId = CryptoCurrency.RawID(tokenInfo.id)
return MarketsListItemUM(
id = tokenId,
name = tokenInfo.name,
currencySymbol = tokenInfo.symbol,
ratingPosition = tokenInfo.metrics?.marketRating?.toString(),
marketCap = getMarketCap(tokenInfo),
iconUrl = getTokenIconUrlFromDefaultHost(tokenId),
price = getCurrentPrice(tokenInfo),
trendPercentText = getTrendPercent(tokenInfo),
trendType = getTrendType(tokenInfo),
chartData = getChartData(chart),
isUnder100kMarketCap = tokenInfo.metrics?.marketCap?.let { it < BigDecimal(MARKET_CAP_100K) } == true,
stakingRate = null,
updateTimestamp = null,
)
}
private fun getMarketCap(tokenInfo: TokenMarketInfo): String? {
val value = tokenInfo.metrics?.marketCap?.takeIf { it != BigDecimal.ZERO } ?: return null
return value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).compact(
threeDigitsMethod = true,
)
}
}
private fun getCurrentPrice(tokenInfo: TokenMarketInfo): MarketsListItemUM.Price {
val priceText = tokenInfo.quotes.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).price()
}
return MarketsListItemUM.Price(
text = priceText,
changeType = null,
)
}
private fun getChartData(chart: TokenChart?): MarketChartRawData? {
return chart?.let { ct ->
priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY.toImmutableList(),
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted(),
)
}
}
@Suppress("MagicNumber")
private fun getTrendType(tokenInfo: TokenMarketInfo): PriceChangeType {
val scaled = tokenInfo.quotes.h24ChangePercent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
private fun getTrendPercent(tokenInfo: TokenMarketInfo): String {
return tokenInfo.quotes.h24ChangePercent?.format { percent() } ?: "0%"
}
companion object {
private const val MARKET_CAP_100K = "100000"
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.feed.model.news.details.converter
import androidx.compose.runtime.Stable
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.converter.Converter
@Stable
internal class TokenMarketInfoToParamsConverter : Converter<TokenMarketInfo, TokenMarketParams> {
override fun convert(value: TokenMarketInfo): TokenMarketParams {
val tokenId = CryptoCurrency.RawID(value.id)
return TokenMarketParams(
id = tokenId,
name = value.name,
symbol = value.symbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = value.quotes.currentPrice,
h24Percent = value.quotes.h24ChangePercent,
weekPercent = value.quotes.weekChangePercent,
monthPercent = value.quotes.monthChangePercent,
),
imageUrl = getTokenIconUrlFromDefaultHost(tokenId),
)
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.features.feed.model.news.details.factory
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.navigation.share.ShareManager
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toImmutableList
internal class NewsDetailsStateFactory(
private val currentStateProvider: Provider<NewsDetailsUM>,
private val shareManager: ShareManager,
private val onStateUpdate: (NewsDetailsUM) -> Unit,
) {
fun updateArticles(articles: List<ArticleUM>, selectedIndex: Int) {
val currentState = currentStateProvider()
val currentArticle = articles.getOrNull(selectedIndex)
onStateUpdate(
currentState.copy(
articles = articles.toImmutableList(),
selectedArticleIndex = selectedIndex,
onShareClick = {
currentArticle?.let {
shareManager.shareText(it.newsUrl)
}
},
),
)
}
fun updateSelectedArticleIndex(newIndex: Int) {
val currentState = currentStateProvider()
val currentArticle = currentState.articles.getOrNull(newIndex)
onStateUpdate(
currentState.copy(
selectedArticleIndex = newIndex,
onShareClick = {
currentArticle?.let {
shareManager.shareText(it.newsUrl)
}
},
),
)
}
fun updateRelatedTokens(relatedTokens: RelatedTokensUM) {
val currentState = currentStateProvider()
onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens))
}
fun createRelatedTokensContent(
items: List<MarketsListItemUM>,
onTokenClick: (MarketsListItemUM) -> Unit,
): RelatedTokensUM.Content {
return RelatedTokensUM.Content(
items = items.toImmutableList(),
onTokenClick = onTokenClick,
)
}
}

View file

@ -16,19 +16,23 @@ import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.tangem.common.ui.news.ArticleHeader
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.pager.PagerIndicator
import com.tangem.core.ui.extensions.resolveReference
@ -36,10 +40,8 @@ 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.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.SourceUM
import com.tangem.features.feed.ui.news.details.components.RelatedTokensBlock
import com.tangem.features.feed.ui.news.details.state.*
@Composable
internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) {
@ -49,18 +51,19 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
pageCount = { state.articles.size },
)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.collect { page ->
state.onArticleIndexChanged(page)
}
}
LaunchedEffect(state.selectedArticleIndex) {
if (pagerState.currentPage != state.selectedArticleIndex) {
pagerState.scrollToPage(state.selectedArticleIndex)
}
}
LaunchedEffect(pagerState.currentPage) {
if (pagerState.currentPage != state.selectedArticleIndex) {
state.onArticleIndexChanged(pagerState.currentPage)
}
}
Column(
modifier = modifier
.fillMaxSize()
@ -76,6 +79,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
article = state.articles[page],
modifier = Modifier.fillMaxSize(),
onLikeClick = state.onLikeClick,
relatedTokensUM = state.relatedTokensUM,
)
}
if (state.articles.size > 1) {
@ -93,13 +97,18 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
@Suppress("LongMethod")
@Composable
private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onLikeClick: () -> Unit) {
private fun ArticleDetail(
article: ArticleUM,
onLikeClick: () -> Unit,
relatedTokensUM: RelatedTokensUM,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(bottom = 56.dp + WindowInsets.navigationBars.getBottom(density).dp),
) {
item {
item("content") {
ArticleHeader(
title = article.title,
createdAt = article.createdAt.resolveReference(),
@ -138,7 +147,14 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL
onClick = onLikeClick,
)
// TODO [REDACTED_TASK_KEY] add related tokens block
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,
onItemClick = when (relatedTokensUM) {
is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick
else -> null
},
modifier = Modifier.padding(horizontal = 16.dp),
)
if (article.sources.isNotEmpty()) {
SpacerH(24.dp)
@ -161,7 +177,7 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL
}
if (article.sources.isNotEmpty()) {
item {
item("sources") {
LazyRow(
modifier = Modifier.padding(vertical = 12.dp),
state = rememberLazyListState(),
@ -227,47 +243,64 @@ private fun QuickRecap(content: String, modifier: Modifier = Modifier) {
private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.widthIn(max = 216.dp)
.heightIn(min = 132.dp)
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(12.dp),
)
.sizeIn(maxWidth = 256.dp, minHeight = 132.dp)
.background(color = TangemTheme.colors.background.action, shape = RoundedCornerShape(12.dp))
.clickable(onClick = source.onClick)
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_explore_16),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.size(16.dp),
)
SpacerW(4.dp)
Text(
text = source.source.name,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors.text.tertiary,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp)) {
Icon(
painter = painterResource(id = R.drawable.ic_explore_16),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.size(16.dp),
)
SpacerW(4.dp)
Text(
text = source.source.name,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors.text.tertiary,
)
}
if (source.title.isNotEmpty()) {
SpacerH(4.dp)
Text(
text = source.title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
if (source.imageUrl != null) {
SubcomposeAsyncImage(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
model = ImageRequest.Builder(context = LocalContext.current)
.data(source.imageUrl)
.crossfade(enable = false)
.allowHardware(true)
.memoryCachePolicy(CachePolicy.DISABLED)
.build(),
loading = {
RectangleShimmer(
modifier = Modifier.size(40.dp),
radius = 4.dp,
)
},
error = {},
contentDescription = source.source.name,
)
}
}
if (source.title.isNotEmpty()) {
SpacerH(4.dp)
Text(
text = source.title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
SpacerHMax()
Text(
text = source.publishedAt.resolveReference(),
style = TangemTheme.typography.caption2,
@ -288,6 +321,7 @@ private fun PreviewNewsDetailsContent() {
onShareClick = {},
onLikeClick = {},
onBackClick = {},
onArticleIndexChanged = {},
),
)
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.feed.ui.news.details.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM
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.components.block.TangemBlockCardColors
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.model.news.details.NewsDetailsModel.Companion.RELATED_TOKEN_MAX_COUNT
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
@Composable
internal fun RelatedTokensBlock(
relatedTokensUM: RelatedTokensUM,
onItemClick: ((MarketsListItemUM) -> Unit)?,
modifier: Modifier = Modifier,
) {
val isVisible = remember(relatedTokensUM) {
when (relatedTokensUM) {
is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty()
RelatedTokensUM.Loading -> true
RelatedTokensUM.LoadingError -> false
}
}
if (!isVisible) return
Column(modifier = modifier) {
SpacerH(40.dp)
Text(
text = stringResourceSafe(R.string.news_related_tokens),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
SpacerH(12.dp)
BlockCard(
colors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
),
) {
Column(modifier = Modifier.fillMaxWidth()) {
when (relatedTokensUM) {
is RelatedTokensUM.Content -> {
relatedTokensUM.items.fastForEach { marketsListItemUM ->
MarketsListItem(
model = marketsListItemUM,
onClick = { onItemClick?.invoke(marketsListItemUM) },
)
}
}
RelatedTokensUM.Loading -> {
repeat(RELATED_TOKEN_MAX_COUNT) {
MarketsListItemPlaceholder()
}
}
RelatedTokensUM.LoadingError -> Unit
}
}
}
}
}

View file

@ -31,6 +31,7 @@ internal object MockArticlesFactory {
publishedAt = TextReference.Str("1h ago"),
url = "https://cointelegraph.com",
onClick = {},
imageUrl = null,
),
SourceUM(
id = 2,
@ -42,9 +43,11 @@ internal object MockArticlesFactory {
publishedAt = TextReference.Str("2h ago"),
url = "https://investing.com",
onClick = {},
imageUrl = null,
),
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 2,
@ -68,9 +71,11 @@ internal object MockArticlesFactory {
publishedAt = TextReference.Str("3h ago"),
url = "https://bloomberg.com",
onClick = {},
imageUrl = null,
),
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 3,
@ -94,9 +99,11 @@ internal object MockArticlesFactory {
publishedAt = TextReference.Str("5h ago"),
url = "https://coindesk.com",
onClick = {},
imageUrl = null,
),
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 4,
@ -120,9 +127,11 @@ internal object MockArticlesFactory {
publishedAt = TextReference.Str("7h ago"),
url = "https://theblock.co",
onClick = {},
imageUrl = null,
),
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 5,
@ -136,6 +145,7 @@ internal object MockArticlesFactory {
content = "A newly launched protocol unveiled innovative yield farming mechanism.\n\nAPY rates range from 15% to 30%.",
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 6,
@ -150,6 +160,7 @@ internal object MockArticlesFactory {
content = "The US Senate Banking Committee advanced landmark crypto regulation bill.\n\nKey provisions include asset definitions.",
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 7,
@ -163,6 +174,7 @@ internal object MockArticlesFactory {
content = "Major financial institution announced comprehensive crypto custody services.\n\nSupporting Bitcoin and Ethereum initially.",
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 8,
@ -176,6 +188,7 @@ internal object MockArticlesFactory {
content = "Prominent NFT marketplace reported 300% increase in trading volume.\n\nNew features include lower fees.",
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 9,
@ -190,6 +203,7 @@ internal object MockArticlesFactory {
content = "Layer 2 solution processed 100,000 transactions per second.\n\nUsing zero-knowledge proof technology.",
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
ArticleUM(
id = 10,
@ -204,6 +218,7 @@ internal object MockArticlesFactory {
content = "Stablecoin market cap reached \$180 billion all-time high.\n\nDriven by DeFi activity and institutional adoption.",
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
),
).toPersistentList()
}

View file

@ -1,7 +1,10 @@
package com.tangem.features.feed.ui.news.details.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.news.RelatedToken
import kotlinx.collections.immutable.ImmutableList
internal data class NewsDetailsUM(
@ -10,7 +13,8 @@ internal data class NewsDetailsUM(
val onShareClick: () -> Unit,
val onLikeClick: () -> Unit,
val onBackClick: () -> Unit,
val onArticleIndexChanged: (Int) -> Unit = {},
val onArticleIndexChanged: (Int) -> Unit,
val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading,
)
internal data class ArticleUM(
@ -23,6 +27,7 @@ internal data class ArticleUM(
val content: String,
val sources: ImmutableList<SourceUM>,
val newsUrl: String,
val relatedTokens: ImmutableList<RelatedToken>,
)
internal data class SourceUM(
@ -32,8 +37,22 @@ internal data class SourceUM(
val publishedAt: TextReference,
val url: String,
val onClick: () -> Unit,
val imageUrl: String?,
)
@Immutable
internal sealed interface RelatedTokensUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val onTokenClick: (MarketsListItemUM) -> Unit,
) : RelatedTokensUM
data object Loading : RelatedTokensUM
data object LoadingError : RelatedTokensUM
}
internal data class Source(
val id: Int,
val name: String,