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,