diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt
index 4ef5f1ce75..300d41f104 100644
--- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt
+++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleLoadingCard.kt
@@ -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)) {
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 2e2e66c3af..bd4e096fbd 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -817,6 +817,7 @@
Quick recap
Related News
+ Related tokens
Sources
Stay in the loop
NFC is not available on your device
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt
index 28f4ccb71c..172c7d6108 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt
@@ -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
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt
index 004f1db029..fe6ca72911 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/details/DefaultNewsDetailsComponent.kt
@@ -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 = emptyList(),
- val tokenIds: List = emptyList(),
- val categoryIds: List = emptyList(),
)
}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt
index b749723815..17397d9042 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt
@@ -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()
-
private val currentLanguage = Locale.getDefault().language
+ private val relatedTokensCache = mutableMapOf()
- 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 = _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,
+ appCurrency: AppCurrency,
+ ): List> {
+ 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? {
+ 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>,
+ 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>,
+ 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
+ }
}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt
index 939c67a294..a877901aa0 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt
@@ -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()
}
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt
new file mode 100644
index 0000000000..27c51c52e0
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/RelatedTokenConverter.kt
@@ -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, MarketsListItemUM> {
+
+ private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
+
+ override fun convert(value: Pair): 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"
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt
new file mode 100644
index 0000000000..89e0a020ca
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/TokenMarketInfoToParamsConverter.kt
@@ -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 {
+
+ 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),
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt
new file mode 100644
index 0000000000..00553ec601
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsStateFactory.kt
@@ -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,
+ private val shareManager: ShareManager,
+ private val onStateUpdate: (NewsDetailsUM) -> Unit,
+) {
+
+ fun updateArticles(articles: List, 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,
+ onTokenClick: (MarketsListItemUM) -> Unit,
+ ): RelatedTokensUM.Content {
+ return RelatedTokensUM.Content(
+ items = items.toImmutableList(),
+ onTokenClick = onTokenClick,
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt
index adb45733c1..eca1d5c4ee 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt
@@ -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 = {},
),
)
}
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt
new file mode 100644
index 0000000000..d43ebbb5a9
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt
@@ -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
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt
index 97aec724e5..4720658f0a 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/MockArticlesFactory.kt
@@ -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()
}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt
index d0049ca02b..0fe906afa9 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/state/NewsDetailsUM.kt
@@ -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,
val newsUrl: String,
+ val relatedTokens: ImmutableList,
)
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,
+ val onTokenClick: (MarketsListItemUM) -> Unit,
+ ) : RelatedTokensUM
+
+ data object Loading : RelatedTokensUM
+
+ data object LoadingError : RelatedTokensUM
+}
+
internal data class Source(
val id: Int,
val name: String,