From 348d7500548ef90d73b5cd1a1aa353f386e24cf8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Dec 2025 08:52:15 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../news/repository/DefaultNewsRepository.kt | 76 +++++--- .../feed/components/FeedEntryChildFactory.kt | 7 +- .../news/list/DefaultNewsListComponent.kt | 2 +- .../model/converter/BatchListStateManager.kt | 98 +++++++++++ ...ShortArticleToArticleConfigUMConverter.kt} | 12 +- .../feed/model/feed/FeedComponentModel.kt | 2 + .../feed/state/FeedMarketsBatchFlowManager.kt | 133 ++++---------- .../feed/state/TrendingNewsStateFactory.kt | 53 ++---- .../details/MarketsTokenDetailsModel.kt | 8 +- .../MarketsListBatchFlowManager.kt | 115 ++++--------- .../model/news/details/NewsDetailsModel.kt | 21 ++- .../feed/model/news/list/NewsListModel.kt | 116 +++++++++++-- .../statemanager/NewsListBatchFlowManager.kt | 122 +++++++++++++ .../ui/news/details/NewsDetailsContent.kt | 12 +- .../feed/ui/news/list/NewsListContent.kt | 45 +++-- .../list/components/NewsListLazyColumn.kt | 162 ++++++++++++++++++ .../feed/ui/news/list/state/NewsListUM.kt | 14 +- 17 files changed, 698 insertions(+), 300 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/{market/details/converter/RelatedNewsConverter.kt => converter/ShortArticleToArticleConfigUMConverter.kt} (80%) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/{ui => model}/feed/state/FeedMarketsBatchFlowManager.kt (70%) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/{ui => model}/feed/state/TrendingNewsStateFactory.kt (55%) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 96fe7bd6d8..57d4b113f8 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -13,20 +13,13 @@ import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.repository.NewsRepository -import com.tangem.pagination.BatchFetchResult -import com.tangem.pagination.BatchListSource +import com.tangem.pagination.* import com.tangem.pagination.exception.EndOfPaginationException import com.tangem.pagination.fetcher.BatchFetcher -import com.tangem.pagination.toBatchFlow import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import timber.log.Timber /** @@ -42,12 +35,14 @@ internal class DefaultNewsRepository( ) : NewsRepository { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { - return BatchListSource( + val newsBatchFlow = BatchListSource( fetchDispatcher = dispatchers.io, context = context, generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY }, batchFetcher = createBatchFetcher(batchSize), ).toBatchFlow() + + return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope) } override suspend fun getNews(config: NewsListConfig, limit: Int): List { @@ -115,11 +110,13 @@ internal class DefaultNewsRepository( } override suspend fun getCategories(): List { - return newsApi.getCategories().getOrThrow().items.map { dto -> - ArticleCategory( - id = dto.id, - name = dto.name, - ) + return withContext(dispatchers.io) { + newsApi.getCategories().getOrThrow().items.map { dto -> + ArticleCategory( + id = dto.id, + name = dto.name, + ) + } } } @@ -127,6 +124,38 @@ internal class DefaultNewsRepository( newsViewedStore.updateViewed(articleIds, viewed) } + private fun updateViewedStatusForNewsBatch( + newsBatchFlow: NewsListBatchFlow, + scope: CoroutineScope, + ): NewsListBatchFlow { + return object : NewsListBatchFlow { + override val state: StateFlow>> = + combine( + newsBatchFlow.state, + newsViewedStore.getAll(), + ) { batchListState, viewedFlags -> + val updatedBatches = batchListState.data.map { batch -> + val updatedArticles = batch.data.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + Batch(key = batch.key, data = updatedArticles) + } + BatchListState( + data = updatedBatches, + status = batchListState.status, + ) + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = BatchListState(emptyList(), newsBatchFlow.state.value.status), + ) + + override val updateResults: SharedFlow>>> = + newsBatchFlow.updateResults + } + } + private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = withContext(dispatchers.io) { if (newsIds.isEmpty()) return@withContext @@ -141,7 +170,7 @@ internal class DefaultNewsRepository( if (idsToFetch.isEmpty()) return@withContext - val fetchedArticles = coroutineScope { + val fetchedArticles = supervisorScope { idsToFetch.map { newsId -> async { newsApi.getNewsDetails(newsId = newsId, language = language) @@ -160,13 +189,12 @@ internal class DefaultNewsRepository( private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) { return withContext(dispatchers.io) { - val apiResponse = newsApi.getTrendingNews(limit = limit, language = language) - when (val result = apiResponse) { + when (val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)) { is ApiResponse.Error -> { Timber.e( - result.cause.cause, + apiResponse.cause.cause, "Trending news fetch failed cause: ${ - when (val error = result.cause) { + when (val error = apiResponse.cause) { is ApiResponseError.HttpException -> error.code is ApiResponseError.NetworkException -> "NetworkException" is ApiResponseError.TimeoutException -> "TimeoutException" @@ -179,14 +207,14 @@ internal class DefaultNewsRepository( key = TRENDING_NEWS_KEY, value = TrendingNews.Error( NewsError.Unknown( - message = result.cause.message, + message = apiResponse.cause.message, code = null, ), ), ) } is ApiResponse.Success -> { - val freshArticles = result.data.items.map { it.toDomainShortArticle() } + val freshArticles = apiResponse.data.items.map { it.toDomainShortArticle() } val articles = freshArticles.take(limit) trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles)) TrendingNews.Data(articles) @@ -271,7 +299,7 @@ internal class DefaultNewsRepository( page = page, limit = limit, language = params.language, - snapshot = snapshotOverride, + snapshot = snapshotOverride?.takeIf { it.isNotEmpty() }, tokenIds = params.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, ).getOrThrow() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index cfc874b8b1..5b9a6132d6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -78,7 +78,12 @@ internal class FeedEntryChildFactory @Inject constructor( DefaultNewsListComponent( appComponentContext = appComponentContext, params = DefaultNewsListComponent.Params( - onArticleClicked = { feedEntryClickIntents.onArticleClick(articleId = it) }, + onArticleClicked = { currentArticle, prefetchedArticles -> + feedEntryClickIntents.onArticleClick( + articleId = currentArticle, + preselectedArticlesId = prefetchedArticles, + ) + }, onBackClick = onBackClicked, ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index 2cec3b93c7..a1ca9e03d3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -51,7 +51,7 @@ internal class DefaultNewsListComponent( @Serializable data class Params( - val onArticleClicked: (Int) -> Unit, + val onArticleClicked: (currentArticle: Int, prefetchedArticles: List) -> Unit, val onBackClick: () -> Unit, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt new file mode 100644 index 0000000000..e55c4c6fcb --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/BatchListStateManager.kt @@ -0,0 +1,98 @@ +package com.tangem.features.feed.model.converter + +import com.tangem.pagination.Batch +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +internal class BatchListStateManager( + private val converter: BatchItemConverter, + private val dispatchers: CoroutineDispatcherProvider, +) { + val state = MutableStateFlow(BatchListState()) + + suspend fun update(newList: List>>, forceUpdate: Boolean) = + withContext(dispatchers.default) { + state.update { currentState -> + val uiBatches = currentState.uiBatches + val previousList = currentState.processedItems + + if (newList.isEmpty()) { + return@update BatchListState(uiBatches = emptyList(), processedItems = emptyList()) + } + + val isInitialLoading = forceUpdate || + previousList.isNullOrEmpty() || + newList.firstOrNull()?.key != previousList.firstOrNull()?.key + + val outItems = if (isInitialLoading) { + newList.map { batch -> + Batch(key = batch.key, data = batch.data.map { converter.convert(it) }) + } + } else { + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + uiBatches + newBatches.map { batch -> + Batch(key = batch.key, data = batch.data.map { converter.convert(it) }) + } + } else { + uiBatches.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (prevBatch == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, currentUiItem -> + val prevItem = prevBatch.data.getOrNull(index) + val newItem = newBatch.data.getOrNull(index) + + if (prevItem != null && newItem != null) { + converter.update(prevItem, currentUiItem, newItem) + } else if (newItem != null) { + converter.convert(newItem) + } else { + currentUiItem + } + }, + ) + } + } + } + + coroutineContext.ensureActive() + + BatchListState( + uiBatches = outItems, + processedItems = newList, + ) + } + } +} + +internal data class BatchListState( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = emptyList(), +) + +internal interface BatchItemConverter { + fun convert(item: Domain): UI + + fun update(prevDomain: Domain, currentUI: UI, newDomain: Domain): UI { + return convert(newDomain) + } +} + +internal fun Flow>>>.distinctBatchesContent(): Flow>>> { + return this.distinctUntilChanged { old, new -> + old.size == new.size && + old.map { it.key } == new.map { it.key } && + old.flatMap { it.data } == new.flatMap { it.data } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt similarity index 80% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt index f3967a83fc..245f6b84c7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.model.market.details.converter +package com.tangem.features.feed.model.converter import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM @@ -8,12 +8,16 @@ import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.features.feed.ui.utils.mapFormattedDate +import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet -class RelatedNewsConverter : Converter, ImmutableList> { +internal class ShortArticleToArticleConfigUMConverter( + private val isTrending: Provider, +) : Converter, ImmutableList> { override fun convert(value: List): ImmutableList { return value.map { shortArticle -> @@ -21,7 +25,7 @@ class RelatedNewsConverter : Converter, ImmutableList
, ImmutableList
{ + private fun buildArticleTags(article: ShortArticle): ImmutableSet { val categoryLabels = article.categories.map { category -> LabelUM(text = TextReference.Str(category.name)) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 2fb7016135..203f4dd6df 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -16,6 +16,8 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager +import com.tangem.features.feed.model.feed.state.TrendingNewsStateFactory import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.state.* import com.tangem.utils.Provider diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt similarity index 70% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index 6c2014aedf..8a6c1ff9a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -1,13 +1,12 @@ -package com.tangem.features.feed.ui.feed.state +package com.tangem.features.feed.model.feed.state import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.feed.model.converter.MarketsTokenItemConverter +import com.tangem.features.feed.model.converter.* import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM -import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.PaginationStatus @@ -18,8 +17,11 @@ import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class FeedMarketsBatchFlowManager( @@ -44,7 +46,7 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -60,7 +62,7 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -76,7 +78,7 @@ internal class FeedMarketsBatchFlowManager( } }.stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = emptyMap(), ) @@ -141,11 +143,29 @@ internal class FeedMarketsBatchFlowManager( private val dispatchers: CoroutineDispatcherProvider, ) { private val updateStateJob = JobHolder() - private val resultBatches = MutableStateFlow(ResultBatches()) - private val uiBatches = resultBatches.map { it.uiBatches } + + private val batchConverter = object : BatchItemConverter { + + private val internalConverter: MarketsTokenItemConverter + get() = MarketsTokenItemConverter( + currentTrendInterval = MarketsListUM.TrendInterval.H24, + appCurrency = currentAppCurrency(), + ) + + override fun convert(item: TokenMarket) = internalConverter.convert(item) + + override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) = + internalConverter.update(prevDomain, currentUI, newDomain) + } + + private val stateManager = BatchListStateManager( + converter = batchConverter, + dispatchers = dispatchers, + ) val uiItems: StateFlow> = - uiBatches + stateManager.state + .map { it.uiBatches } .map { batches -> batches.asSequence() .map { it.data } @@ -155,7 +175,7 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = persistentListOf(), ) @@ -170,7 +190,7 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = false, ) @@ -185,7 +205,7 @@ internal class FeedMarketsBatchFlowManager( .distinctUntilChanged() .stateIn( scope = modelScope, - started = SharingStarted.Companion.Eagerly, + started = Eagerly, initialValue = false, ) @@ -210,15 +230,11 @@ internal class FeedMarketsBatchFlowManager( init { batchFlow.state .map { it.data } - .distinctUntilChanged { a, b -> - a.size == b.size && - a.map { it.key } == b.map { it.key } && - a.map { it.data }.flatten() == b.map { it.data }.flatten() - } - .onEach { + .distinctBatchesContent() + .onEach { newList -> coroutineScope { launch { - updateState(it) + stateManager.update(newList = newList, forceUpdate = false) }.saveIn(updateStateJob) } } @@ -226,77 +242,9 @@ internal class FeedMarketsBatchFlowManager( .launchIn(modelScope) } - private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = - withContext(dispatchers.default) { - resultBatches.update { resultBatches -> - val items = resultBatches.uiBatches - val previousList = resultBatches.processedItems - - val converter = MarketsTokenItemConverter( - currentTrendInterval = MarketsListUM.TrendInterval.H24, - appCurrency = currentAppCurrency(), - ) - - if (newList.isEmpty()) { - return@update ResultBatches(processedItems = emptyList()) - } - - val isInitialLoading = - forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key - - val outItems = if (isInitialLoading) { - newList.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - // As nextBatchSize = 0, we only have one batch, but keep the logic for safety - if (previousList.size != newList.size) { - val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = newList.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = newList[batchIndex] - if (prevBatch == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data.getOrNull(index) - val newItem = newBatch.data.getOrNull(index) - if (prevItem != null && newItem != null) { - converter.update(prevItem, marketsListItemUM, newItem) - } else { - newItem?.let { converter.convert(it) } ?: marketsListItemUM - } - }, - ) - } - } - } - - currentCoroutineContext().ensureActive() - - ResultBatches( - uiBatches = outItems, - processedItems = newList, - ) - } - } - fun reload(fiatPriceCurrency: String) { modelScope.launch(dispatchers.default) { - resultBatches.value = ResultBatches() + stateManager.state.value = BatchListState() actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( @@ -366,16 +314,11 @@ internal class FeedMarketsBatchFlowManager( } fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? { - return resultBatches.value.processedItems + return stateManager.state.value.processedItems ?.asSequence() ?.flatMap { it.data } ?.firstOrNull { it.id == tokenId } } - - private data class ResultBatches( - val uiBatches: List>> = emptyList(), - val processedItems: List>>? = null, - ) } private fun TokenMarketListConfig.Order.toSortByTypeUM(): SortByTypeUM { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt similarity index 55% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt index f5c7fe446b..a132fd706f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/TrendingNewsStateFactory.kt @@ -1,19 +1,14 @@ -package com.tangem.features.feed.ui.feed.state +package com.tangem.features.feed.model.feed.state -import com.tangem.common.ui.news.ArticleConfigUM -import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM -import com.tangem.core.ui.components.label.entity.LabelUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.models.news.TrendingNews -import com.tangem.features.feed.ui.utils.mapFormattedDate +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.NewsUM +import com.tangem.features.feed.ui.feed.state.NewsUMState import com.tangem.utils.Provider -import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlinx.collections.immutable.toPersistentSet internal class TrendingNewsStateFactory( private val currentStateProvider: Provider, @@ -31,7 +26,9 @@ internal class TrendingNewsStateFactory( private fun handleDataState(currentState: FeedListUM, articles: List) { val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) - val commonArticlesUM = commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList() + val commonArticlesUM = getShortArticleConfigConverter(isTrending = false) + .convert(commonArticles) + .toPersistentList() val updatedNews = when (currentState.news.newsUMState) { NewsUMState.CONTENT -> currentState.news.copy(content = commonArticlesUM) NewsUMState.LOADING, @@ -45,7 +42,10 @@ internal class TrendingNewsStateFactory( onStateUpdate( currentState.copy( - trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) }, + trendingArticle = trendingArticle?.let { article -> + getShortArticleConfigConverter(isTrending = true) + .convert(listOf(article)) + }?.firstOrNull(), news = updatedNews, ), ) @@ -77,32 +77,7 @@ internal class TrendingNewsStateFactory( } } - private fun mapToArticleConfigUM(article: ShortArticle, isTrending: Boolean): ArticleConfigUM { - return ArticleConfigUM( - id = article.id, - title = article.title, - score = article.score, - isTrending = isTrending, - tags = buildArticleTags(article), - createdAt = mapFormattedDate(article.createdAt), - isViewed = article.viewed, - ) - } - - private fun buildArticleTags(article: ShortArticle): ImmutableSet { - val categoryLabels = article.categories.map { category -> - LabelUM(text = TextReference.Str(category.name)) - } - val tokenLabels = article.relatedTokens.map { token -> - LabelUM( - text = TextReference.Str(token.symbol), - leadingContent = LabelLeadingContentUM.Token( - iconUrl = getTokenIconUrlFromDefaultHost( - tokenId = CryptoCurrency.RawID(token.id), - ), - ), - ) - } - return (categoryLabels + tokenLabels).toPersistentSet() + private fun getShortArticleConfigConverter(isTrending: Boolean): ShortArticleToArticleConfigUMConverter { + return ShortArticleToArticleConfigUMConverter(isTrending = Provider { isTrending }) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 3f6bce01b5..f81479096a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -40,11 +40,11 @@ import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.converter.DescriptionConverter import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter -import com.tangem.features.feed.model.market.details.converter.RelatedNewsConverter import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoConverter import com.tangem.features.feed.model.market.details.formatter.* import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater import com.tangem.features.feed.model.market.details.state.TokenNetworksState +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM import com.tangem.lib.crypto.BlockchainUtils @@ -143,8 +143,8 @@ internal class MarketsTokenDetailsModel @Inject constructor( // ================== ) - private val relatedNewsConverter by lazy { - RelatedNewsConverter() + private val shortArticleToArticleConfigUMConverter by lazy { + ShortArticleToArticleConfigUMConverter(isTrending = Provider { false }) } private val descriptionConverter = DescriptionConverter( @@ -311,7 +311,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), ).onRight { articles -> state.update { marketsTokenDetailsUM -> - val relatedNews = relatedNewsConverter.convert(articles) + val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) marketsTokenDetailsUM.copy( relatedNews = marketsTokenDetailsUM.relatedNews.copy( articles = relatedNews, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt index c4632a41af..5e87294672 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -4,12 +4,15 @@ import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.model.converter.BatchItemConverter +import com.tangem.features.feed.model.converter.BatchListStateManager import com.tangem.features.feed.model.converter.MarketsTokenItemConverter +import com.tangem.features.feed.model.converter.distinctBatchesContent +import com.tangem.features.feed.model.market.list.state.MarketsListUM +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.utils.logAction import com.tangem.features.feed.model.market.list.utils.logStatus import com.tangem.features.feed.model.market.list.utils.logUpdateResults -import com.tangem.features.feed.model.market.list.state.MarketsListUM -import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult @@ -48,8 +51,27 @@ internal class MarketsListBatchFlowManager( batchFlowType = batchFlowType, ) + private val batchConverter = object : BatchItemConverter { + + private val internalConverter: MarketsTokenItemConverter + get() = MarketsTokenItemConverter( + currentTrendInterval = currentTrendInterval(), // Теперь тут всегда актуальное значение + appCurrency = currentAppCurrency(), + ) + + override fun convert(item: TokenMarket) = internalConverter.convert(item) + + override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) = + internalConverter.update(prevDomain, currentUI, newDomain) + } + + private val stateManager = BatchListStateManager( + converter = batchConverter, + dispatchers = dispatchers, + ) + private val resultBatches = MutableStateFlow(ResultBatches()) - private val uiBatches = resultBatches.map { it.uiBatches } + private val uiBatches = stateManager.state.map { it.uiBatches } val uiItems: StateFlow> get() = uiBatches @@ -128,15 +150,11 @@ internal class MarketsListBatchFlowManager( init { batchFlow.state .map { it.data } - .distinctUntilChanged { a, b -> - a.size == b.size && - a.map { it.key } == b.map { it.key } && - a.map { it.data }.flatten() == b.map { it.data }.flatten() - } - .onEach { + .distinctBatchesContent() + .onEach { newList -> coroutineScope { launch { - updateState(it) + stateManager.update(newList = newList, forceUpdate = false) }.saveIn(updateStateJob) } } @@ -159,70 +177,12 @@ internal class MarketsListBatchFlowManager( } } - private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = - withContext(dispatchers.default) { - resultBatches.update { resultBatches -> - val items = resultBatches.uiBatches - val previousList = resultBatches.processedItems - - val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency()) - - if (newList.isEmpty()) { - return@update ResultBatches(processedItems = emptyList()) - } - - val isInitialLoading = - forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key - - val outItems = if (isInitialLoading) { - newList.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - if (previousList.size != newList.size) { - val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = newList.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = newList[batchIndex] - if (previousList == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data[index] - val newItem = newBatch.data[index] - - converter.update( - prevItem, - marketsListItemUM, - newItem, - ) - }, - ) - } - } - } - - currentCoroutineContext().ensureActive() - - ResultBatches( - uiBatches = outItems, - processedItems = newList, - ) - } - } + fun updateUIWithSameState() { + modelScope.launch(dispatchers.default) { + val current = batchFlow.state.value.data + stateManager.update(current, forceUpdate = true) + }.saveIn(updateStateJob) + } fun reload(searchText: String? = null) { modelScope.launch { @@ -250,13 +210,6 @@ internal class MarketsListBatchFlowManager( } } - fun updateUIWithSameState() { - modelScope.launch(dispatchers.default) { - val current = batchFlow.state.value.data - updateState(current, forceUpdate = true) - }.saveIn(updateStateJob) - } - fun loadCharts(batchKeys: Set, interval: MarketsListUM.TrendInterval) { if (batchKeys.isEmpty()) return 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 17397d9042..cde555e61c 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 @@ -2,6 +2,7 @@ package com.tangem.features.feed.model.news.details import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,16 +13,16 @@ 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.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.news.RelatedToken +import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase 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 @@ -49,6 +50,7 @@ internal class NewsDetailsModel @Inject constructor( private val shareManager: ShareManager, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase, paramsContainer: ParamsContainer, ) : Model() { @@ -101,7 +103,10 @@ internal class NewsDetailsModel @Inject constructor( private fun onArticleIndexChanged(newIndex: Int) { stateFactory.updateSelectedArticleIndex(newIndex) val currentArticle = state.value.articles.getOrNull(newIndex) - currentArticle?.let(::loadRelatedTokens) + currentArticle?.let { article -> + markArticleAsViewed(article.id) + loadRelatedTokens(article) + } } private fun handlePreselectedArticles() { @@ -126,8 +131,6 @@ 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) - currentArticle?.let(::loadRelatedTokens) } .launchIn(modelScope) } @@ -255,6 +258,12 @@ internal class NewsDetailsModel @Inject constructor( } } + private fun markArticleAsViewed(articleId: Int) { + modelScope.launch(dispatchers.default) { + markArticleAsViewedUseCase.markAsViewed(articleId) + } + } + companion object { internal const val RELATED_TOKEN_MAX_COUNT = 5 } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index ccaf7b8c33..5bdf4d1705 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -1,19 +1,28 @@ package com.tangem.features.feed.model.news.list +import com.tangem.common.ui.R 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.ui.components.chip.entity.ChipUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,24 +31,57 @@ import javax.inject.Inject internal class NewsListModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, paramsContainer: ParamsContainer, ) : Model() { private val params = paramsContainer.require() + private val selectedCategoryId = MutableStateFlow(null) + private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() + + private val batchFlowManager by lazy { + NewsListBatchFlowManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = Provider { currentLanguage }, + currentCategoryIds = Provider { + selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty() + }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } private val _state = MutableStateFlow( NewsListUM( - selectedCategoryId = 0, + selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, filters = persistentListOf(), - articles = persistentListOf(), - onArticleClick = params.onArticleClicked, + newsListState = NewsListState.Loading, + listOfArticles = persistentListOf(), + onArticleClick = { articleId -> + params.onArticleClicked( + /* currentArticle */ articleId, + /* prefetchedArticles */ getCurrentFetchedArticlesIds(), + ) + }, onBackClick = params.onBackClick, ), ) val state = _state.asStateFlow() init { + loadCategories() + observeNewsList() + batchFlowManager.reload() + } + + private fun loadCategories() { modelScope.launch(dispatchers.default) { + val allCategoriesChip = ChipUM( + id = DEFAULT_ALL_NEWS_CATEGORIES_ID, + text = TextReference.Res(R.string.news_all_news), + isSelected = true, + onClick = { onCategoryClick(DEFAULT_ALL_NEWS_CATEGORIES_ID) }, + ) val filterChips = getNewsCategoriesUseCase .invoke() .fold( @@ -47,7 +89,7 @@ internal class NewsListModel @Inject constructor( persistentListOf() }, ifRight = { categories -> - categories.map { articleCategory -> + (listOf(allCategoriesChip) + categories.map { articleCategory -> ChipUM( id = articleCategory.id, text = TextReference.Str(articleCategory.name), @@ -56,7 +98,7 @@ internal class NewsListModel @Inject constructor( onCategoryClick(articleCategory.id) }, ) - }.toImmutableList() + }).toPersistentList() }, ) _state.update { currentState -> @@ -65,18 +107,68 @@ internal class NewsListModel @Inject constructor( } } - private fun onCategoryClick(categoryId: Int) { - _state.update { currentState -> - currentState.copy( - selectedCategoryId = categoryId, - filters = updateFilterChips(categoryId), - ) + private fun observeNewsList() { + modelScope.launch(dispatchers.default) { + combine( + batchFlowManager.uiItems, + batchFlowManager.isInInitialLoadingErrorState, + batchFlowManager.paginationStatus, + ) { articles, isError, paginationStatus -> + when { + isError -> NewsListState.LoadingError( + onRetryClicked = { + loadCategories() + batchFlowManager.reload() + }, + ) to persistentListOf() + paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { + NewsListState.Loading to persistentListOf() + } + articles.isEmpty() -> { + NewsListState.Loading to persistentListOf() + } + else -> { + NewsListState.Content(loadMore = { batchFlowManager.loadMore() }) to articles + } + } + }.collect { (listState, articles) -> + _state.update { currentState -> + currentState.copy( + listOfArticles = articles, + newsListState = listState, + ) + } + } } } - private fun updateFilterChips(categoryId: Int): ImmutableList { + private fun onCategoryClick(categoryId: Int) { + val newCategoryId = if (state.value.selectedCategoryId == categoryId) { + DEFAULT_ALL_NEWS_CATEGORIES_ID + } else { + categoryId + } + selectedCategoryId.value = newCategoryId + _state.update { currentState -> + currentState.copy( + selectedCategoryId = newCategoryId, + filters = updateFilterChips(newCategoryId), + ) + } + batchFlowManager.reload() + } + + private fun updateFilterChips(categoryId: Int?): ImmutableList { return state.value.filters.map { chip -> chip.copy(isSelected = chip.id == categoryId) }.toImmutableList() } + + private fun getCurrentFetchedArticlesIds(): List { + return state.value.listOfArticles.map { it.id } + } + + companion object { + private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1 + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt new file mode 100644 index 0000000000..3029f0a192 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt @@ -0,0 +1,122 @@ +package com.tangem.features.feed.model.news.list.statemanager + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter +import com.tangem.features.feed.model.converter.distinctBatchesContent +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class NewsListBatchFlowManager( + getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + private val currentLanguage: Provider, + private val currentCategoryIds: Provider>, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val actionsFlow = MutableSharedFlow>() + private val converter by lazy { + ShortArticleToArticleConfigUMConverter(isTrending = Provider { false }) + } + + private val batchFlow = getNewsListBatchFlowUseCase( + context = NewsListBatchingContext( + actionsFlow = actionsFlow, + coroutineScope = modelScope, + ), + batchSize = DEFAULT_BATCH_SIZE, + ) + + private val resultBatches = MutableStateFlow>>>(emptyList()) + + val uiItems: StateFlow> + get() = batchFlow.state + .map { batchListState -> + batchListState.data + .flatMap { batch -> batch.data } + .let { articles -> converter.convert(articles) } + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + + val isInInitialLoadingErrorState = batchFlow.state + .map { it.status is PaginationStatus.InitialLoadingError } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + val paginationStatus: StateFlow>> + get() = batchFlow.state + .map { it.status } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = PaginationStatus.InitialLoading, + ) + + init { + batchFlow.state + .map { it.data } + .distinctBatchesContent() + .onEach { batches -> + resultBatches.value = batches.map { batch -> + Batch( + key = batch.key, + data = converter.convert(batch.data), + ) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + fun reload() { + modelScope.launch(dispatchers.default) { + resultBatches.value = emptyList() + actionsFlow.emit( + BatchAction.Reload( + requestParams = createNewsListConfig(), + ), + ) + } + } + + fun loadMore() { + modelScope.launch(dispatchers.default) { + actionsFlow.emit(BatchAction.LoadMore()) + } + } + + private fun createNewsListConfig(): NewsListConfig { + return NewsListConfig( + language = currentLanguage(), + snapshot = null, + tokenIds = emptyList(), + categoryIds = currentCategoryIds(), + ) + } + + private companion object { + private const val DEFAULT_BATCH_SIZE = 20 + } +} \ 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 eca1d5c4ee..b60edbbb14 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 @@ -51,11 +51,13 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif pageCount = { state.articles.size }, ) - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage } - .collect { page -> - state.onArticleIndexChanged(page) - } + if (state.articles.isNotEmpty()) { + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect { page -> + state.onArticleIndexChanged(page) + } + } } LaunchedEffect(state.selectedArticleIndex) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt index d9b5b00948..5dd1413007 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt @@ -1,25 +1,27 @@ package com.tangem.features.feed.ui.news.list import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.common.ui.news.ArticleCard import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.chip.Chip import com.tangem.core.ui.components.chip.entity.ChipUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference 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.list.components.NewsListLazyColumn +import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet @@ -27,6 +29,8 @@ import kotlinx.collections.immutable.toImmutableSet @Composable internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value + val lazyListState = rememberLazyListState() + Column( modifier = modifier .fillMaxSize() @@ -43,25 +47,15 @@ internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) { Chip(state = filter) } } - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - ) { - items( - items = state.articles, - key = ArticleConfigUM::id, - ) { article -> - ArticleCard( - articleConfigUM = article, - onArticleClick = { state.onArticleClick(article.id) }, - modifier = Modifier - .fillMaxWidth() - .height(164.dp), - colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), - ) - SpacerH(12.dp) - } - } + + SpacerH(16.dp) + + NewsListLazyColumn( + newsListState = state.newsListState, + listOfArticles = state.listOfArticles, + lazyListState = lazyListState, + onArticleClick = state.onArticleClick, + ) } } @@ -137,7 +131,8 @@ private fun NewsListContentPreview() { state = NewsListUM( selectedCategoryId = 0, filters = filters, - articles = articles, + listOfArticles = articles, + newsListState = NewsListState.Content(loadMore = {}), onArticleClick = {}, onBackClick = {}, ), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt new file mode 100644 index 0000000000..52d66787a6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt @@ -0,0 +1,162 @@ +package com.tangem.features.feed.ui.news.list.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.news.ArticleCard +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.common.ui.news.DefaultLoadingArticle +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.news.list.state.NewsListState +import kotlinx.collections.immutable.ImmutableList + +private const val LOAD_NEXT_PAGE_ON_END_INDEX = 40 + +@Composable +internal fun NewsListLazyColumn( + listOfArticles: ImmutableList, + newsListState: NewsListState, + lazyListState: LazyListState, + onArticleClick: (Int) -> Unit, +) { + val screenState by remember(listOfArticles, newsListState) { + derivedStateOf { + val isListEmpty = listOfArticles.isEmpty() + when { + isListEmpty && newsListState is NewsListState.Loading -> NewsListScreenState.InitialLoading + isListEmpty && newsListState is NewsListState.LoadingError -> NewsListScreenState.InitialError + else -> NewsListScreenState.Content + } + } + } + + AnimatedContent(targetState = screenState, label = "NewsListTransition") { state -> + when (state) { + NewsListScreenState.Content -> { + Content( + listOfArticles = listOfArticles, + newsListState = newsListState, + lazyListState = lazyListState, + onArticleClick = onArticleClick, + ) + } + NewsListScreenState.InitialLoading -> { + LazyColumn( + state = rememberLazyListState(), + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + userScrollEnabled = false, + ) { + items( + count = 10, + key = { "initial_loading_$it" }, + ) { + DefaultLoadingArticle( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + ) + SpacerH(12.dp) + } + } + } + NewsListScreenState.InitialError -> { + val errorState = newsListState as? NewsListState.LoadingError + LoadingErrorItem( + modifier = Modifier.fillMaxSize(), + onTryAgain = errorState?.onRetryClicked ?: {}, + ) + } + } + } +} + +@Composable +private fun Content( + listOfArticles: ImmutableList, + newsListState: NewsListState, + lazyListState: LazyListState, + onArticleClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + state = lazyListState, + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + userScrollEnabled = true, + ) { + items( + items = listOfArticles, + key = ArticleConfigUM::id, + ) { article -> + ArticleCard( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + articleConfigUM = article, + onArticleClick = { + onArticleClick(article.id) + }, + ) + SpacerH(12.dp) + } + + if (newsListState is NewsListState.Loading) { + items( + count = 10, + key = { "loading_footer_$it" }, + ) { + DefaultLoadingArticle( + modifier = Modifier + .fillMaxWidth() + .height(164.dp), + ) + SpacerH(12.dp) + } + } + } + + if (newsListState is NewsListState.Content) { + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_NEXT_PAGE_ON_END_INDEX, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(newsListState) { + { + newsListState.loadMore() + true + } + }, + ) + } +} + +private enum class NewsListScreenState { + InitialLoading, + InitialError, + Content, +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(vertical = 35.dp, horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt index c1f6174111..12fe9861f3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt @@ -7,9 +7,17 @@ import kotlinx.collections.immutable.ImmutableList @Immutable data class NewsListUM( - val selectedCategoryId: Int?, + val selectedCategoryId: Int, val filters: ImmutableList, - val articles: ImmutableList, + val listOfArticles: ImmutableList, + val newsListState: NewsListState, val onArticleClick: (Int) -> Unit, val onBackClick: () -> Unit, -) \ No newline at end of file +) + +@Immutable +sealed class NewsListState { + data class Content(val loadMore: () -> Unit) : NewsListState() + data object Loading : NewsListState() + data class LoadingError(val onRetryClicked: () -> Unit) : NewsListState() +} \ No newline at end of file