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 57d4b113f8..f503535500 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 @@ -45,8 +45,8 @@ internal class DefaultNewsRepository( return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope) } - override suspend fun getNews(config: NewsListConfig, limit: Int): List { - return withContext(dispatchers.io) { + override fun getNews(config: NewsListConfig, limit: Int): Flow> { + return flow { val response = newsApi.getNews( page = FIRST_PAGE, limit = limit, @@ -56,25 +56,16 @@ internal class DefaultNewsRepository( categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, ).getOrThrow() - val articles = response.items.map { it.toDomainShortArticle() } - val viewedFlags = newsViewedStore.getSync() - - articles.map { article -> - val isViewed = viewedFlags[article.id] == true - article.copy(viewed = isViewed) + val shortArticles = response.items.map { it.toDomainShortArticle() } + emit(shortArticles) + } + .flowOn(dispatchers.io) + .combine(newsViewedStore.getAll()) { articlesToUpdate, viewedFlags -> + articlesToUpdate.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + }.sortedBy { it.viewed } } - } - } - - override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { - val cached = newsDetailsStore.getSyncOrNull(newsId) - if (cached != null) return cached - - fetchDetailedArticlesInternal(newsIds = listOf(newsId), language = language) - - return requireNotNull(newsDetailsStore.getSyncOrNull(newsId)) { - "Unable to load detailed article with id=$newsId" - } } override fun observeDetailedArticles(): Flow> { @@ -98,10 +89,11 @@ internal class DefaultNewsRepository( ) { trendingNews, viewedFlags -> when (trendingNews) { is TrendingNews.Data -> { - val articlesWithViewedFlags = trendingNews.articles.map { article -> - val isViewed = viewedFlags[article.id] == true - article.copy(viewed = isViewed) - } + val articlesWithViewedFlags = trendingNews.articles + .map { article -> + article.copy(viewed = viewedFlags[article.id] == true) + } + .sortedBy { it.viewed } TrendingNews.Data(articlesWithViewedFlags) } is TrendingNews.Error -> trendingNews @@ -128,32 +120,11 @@ internal class DefaultNewsRepository( 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 - } + return NewsBatchFlowWithViewedStatus( + upstream = newsBatchFlow, + newsViewedStore = newsViewedStore, + scope = scope, + ) } private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = @@ -340,6 +311,37 @@ internal class DefaultNewsRepository( val params: NewsListConfig, ) + private class NewsBatchFlowWithViewedStatus( + private val upstream: NewsListBatchFlow, + newsViewedStore: NewsViewedStore, + scope: CoroutineScope, + ) : NewsListBatchFlow { + + override val state: StateFlow>> = combine( + upstream.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(), upstream.state.value.status), + ) + + override val updateResults: SharedFlow>>> + get() = upstream.updateResults + } + private companion object { private const val INITIAL_BATCH_KEY = 0 private const val FIRST_PAGE = 1 diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 2d2e5043a0..4522fe3a7c 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -23,18 +23,11 @@ interface NewsRepository { fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow /** - * Returns list of short article by config. + * Returns flow of list of short article by config. * * @param config config for getting news list */ - suspend fun getNews(config: NewsListConfig, limit: Int): List - - /** - * Returns detailed article by id with locale configuration. - * @param newsId news identification - * @param language current locale - */ - suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle + fun getNews(config: NewsListConfig, limit: Int): Flow> /** * Observes cached detailed articles. diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt index 74d8533a59..adaa426bfd 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt @@ -4,10 +4,11 @@ import arrow.core.Either import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.repository.NewsRepository +import kotlinx.coroutines.flow.Flow class GetNewsUseCase(private val repository: NewsRepository) { - suspend fun getNews(limit: Int, newsListConfig: NewsListConfig): Either> = + fun getNews(limit: Int, newsListConfig: NewsListConfig): Either>> = Either.catch { repository.getNews( config = newsListConfig, 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 172c7d6108..a66d53fe10 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 @@ -19,6 +19,7 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent @@ -79,13 +80,18 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) } - override fun onArticleClick(articleId: Int, preselectedArticlesId: List) { + override fun onArticleClick( + articleId: Int, + preselectedArticlesId: List, + paginationConfig: NewsListConfig?, + ) { innerRouter.push( FeedEntryChildFactory.Child.NewsDetails( params = DefaultNewsDetailsComponent.Params( articleId = articleId, onBackClicked = { onChildBack() }, preselectedArticlesId = preselectedArticlesId, + paginationConfig = paginationConfig, onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, ), ), @@ -179,7 +185,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( }, onBackClicked = { router.pop() }, onArticleClick = { articleId, preselectedArticlesId -> - clickIntents.onArticleClick(articleId, preselectedArticlesId) + clickIntents.onArticleClick( + articleId = articleId, + preselectedArticlesId = preselectedArticlesId, + paginationConfig = null, + ) }, ), ) 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 5b9a6132d6..8bf90f863d 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,10 +78,11 @@ internal class FeedEntryChildFactory @Inject constructor( DefaultNewsListComponent( appComponentContext = appComponentContext, params = DefaultNewsListComponent.Params( - onArticleClicked = { currentArticle, prefetchedArticles -> + onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig -> feedEntryClickIntents.onArticleClick( articleId = currentArticle, preselectedArticlesId = prefetchedArticles, + paginationConfig = paginationConfig, ) }, onBackClick = onBackClicked, 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 fe6ca72911..c663891a5e 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 @@ -15,8 +15,10 @@ 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.domain.news.model.NewsListConfig import com.tangem.features.feed.model.news.details.NewsDetailsModel import com.tangem.features.feed.ui.news.details.NewsDetailsContent +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import kotlinx.serialization.Serializable internal class DefaultNewsDetailsComponent( @@ -41,7 +43,8 @@ internal class DefaultNewsDetailsComponent( endButton = TopAppBarButtonUM.Icon( iconRes = R.drawable.ic_share_24, onClicked = state.onShareClick, - isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED, + isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED && + state.articlesStateUM is ArticlesStateUM.Content, ), ) } @@ -61,5 +64,6 @@ internal class DefaultNewsDetailsComponent( val onBackClicked: () -> Unit, val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), val preselectedArticlesId: List = emptyList(), + val paginationConfig: NewsListConfig? = null, ) } \ No newline at end of file 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 a1ca9e03d3..73676ae03b 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 @@ -14,6 +14,7 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.model.news.list.NewsListModel import com.tangem.features.feed.ui.news.list.NewsListContent import kotlinx.serialization.Serializable @@ -51,7 +52,11 @@ internal class DefaultNewsListComponent( @Serializable data class Params( - val onArticleClicked: (currentArticle: Int, prefetchedArticles: List) -> Unit, + val onArticleClicked: ( + currentArticle: Int, + prefetchedArticles: List, + paginationConfig: NewsListConfig?, + ) -> 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 index e55c4c6fcb..0cf83cd01f 100644 --- 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 @@ -13,7 +13,12 @@ internal class BatchListStateManager( private val converter: BatchItemConverter, private val dispatchers: CoroutineDispatcherProvider, ) { - val state = MutableStateFlow(BatchListState()) + val state = MutableStateFlow( + BatchListState( + uiBatches = emptyList(), + processedItems = emptyList(), + ), + ) suspend fun update(newList: List>>, forceUpdate: Boolean) = withContext(dispatchers.default) { @@ -26,7 +31,7 @@ internal class BatchListStateManager( } val isInitialLoading = forceUpdate || - previousList.isNullOrEmpty() || + previousList.isEmpty() || newList.firstOrNull()?.key != previousList.firstOrNull()?.key val outItems = if (isInitialLoading) { @@ -77,8 +82,8 @@ internal class BatchListStateManager( } internal data class BatchListState( - val uiBatches: List>> = emptyList(), - val processedItems: List>>? = emptyList(), + val uiBatches: List>>, + val processedItems: List>>, ) internal interface BatchItemConverter { 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 203f4dd6df..f9c544b6e0 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 @@ -324,6 +324,7 @@ internal class FeedComponentModel @Inject constructor( NewsUMState.ERROR, -> emptyList() }, + paginationConfig = null, ) }, onOpenAllNews = { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 66d705a427..d2fa25d6e6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.model.feed import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.news.model.NewsListConfig import com.tangem.features.feed.model.market.list.state.SortByTypeUM /** @@ -10,6 +11,17 @@ import com.tangem.features.feed.model.market.list.state.SortByTypeUM internal interface FeedModelClickIntents { fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) fun onMarketOpenClick(sortBy: SortByTypeUM?) - fun onArticleClick(articleId: Int, preselectedArticlesId: List = emptyList()) + + /** + * Method to open article of news from different places. + * @param articleId - id of article, which should be opened. + * @param preselectedArticlesId - ids of articles, which were prefetched before opening news details. + * @param paginationConfig - config of current pagination from news list. Must be null, when open from other places. + */ + fun onArticleClick( + articleId: Int, + preselectedArticlesId: List = emptyList(), + paginationConfig: NewsListConfig? = null, + ) fun onOpenAllNews() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt index 8a6c1ff9a2..026527248e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedMarketsBatchFlowManager.kt @@ -244,7 +244,10 @@ internal class FeedMarketsBatchFlowManager( fun reload(fiatPriceCurrency: String) { modelScope.launch(dispatchers.default) { - stateManager.state.value = BatchListState() + stateManager.state.value = BatchListState( + processedItems = emptyList(), + uiBatches = emptyList(), + ) actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( @@ -315,9 +318,9 @@ internal class FeedMarketsBatchFlowManager( fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? { return stateManager.state.value.processedItems - ?.asSequence() - ?.flatMap { it.data } - ?.firstOrNull { it.id == tokenId } + .asSequence() + .flatMap { it.data } + .firstOrNull { it.id == tokenId } } } 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 f81479096a..2750d9d6c3 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 @@ -309,20 +309,22 @@ internal class MarketsTokenDetailsModel @Inject constructor( snapshot = null, tokenIds = listOf(params.token.id.value), ), - ).onRight { articles -> - state.update { marketsTokenDetailsUM -> - val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) - marketsTokenDetailsUM.copy( - relatedNews = marketsTokenDetailsUM.relatedNews.copy( - articles = relatedNews, - onArticledClicked = { articledId -> - params.onArticleClick( - /* articledId */ articledId, - /* preselectedIds */ relatedNews.map { it.id }, - ) - }, - ), - ) + ).onRight { listOfArticles -> + listOfArticles.collect { articles -> + state.update { marketsTokenDetailsUM -> + val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) + marketsTokenDetailsUM.copy( + relatedNews = marketsTokenDetailsUM.relatedNews.copy( + articles = relatedNews, + onArticledClicked = { articledId -> + params.onArticleClick( + /* articledId */ articledId, + /* preselectedIds */ relatedNews.map { it.id }, + ) + }, + ), + ) + } } } } 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 cde555e61c..ab257ebb6b 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,7 +2,6 @@ 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,28 +11,22 @@ 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.markets.TokenMarketParams -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.news.RelatedToken +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase 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.factory.NewsDetailsIndexManager 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.loader.NewsRelatedTokensLoader import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM 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 @@ -46,6 +39,7 @@ internal class NewsDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, + private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, private val urlOpener: UrlOpener, private val shareManager: ShareManager, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, @@ -56,10 +50,29 @@ internal class NewsDetailsModel @Inject constructor( private val params = paramsContainer.require() private val currentLanguage = Locale.getDefault().language - private val relatedTokensCache = mutableMapOf() private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl) - private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter() + + private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config -> + NewsDetailsPaginationManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = Provider { config.language }, + currentCategoryIds = Provider { config.categoryIds }, + modelScope = modelScope, + dispatchers = dispatchers, + observeNewsDetailsUseCase = observeNewsDetailsUseCase, + prefetchedIds = params.preselectedArticlesId.toSet(), + ) + } + + private val relatedTokensLoader by lazy { + NewsRelatedTokensLoader( + getTokenMarketInfoUseCase = getTokenMarketInfoUseCase, + getTokenPriceChartUseCase = getTokenPriceChartUseCase, + dispatchers = dispatchers, + maxCount = RELATED_TOKEN_MAX_COUNT, + ) + } private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } @@ -73,8 +86,9 @@ internal class NewsDetailsModel @Inject constructor( private val _state = MutableStateFlow( NewsDetailsUM( + articlesStateUM = ArticlesStateUM.Loading, articles = persistentListOf(), - selectedArticleIndex = 0, + selectedArticleIndex = -1, onShareClick = {}, onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, onBackClick = params.onBackClicked, @@ -87,174 +101,100 @@ internal class NewsDetailsModel @Inject constructor( currentStateProvider = Provider { _state.value }, shareManager = shareManager, onStateUpdate = { newState -> _state.update { newState } }, + onRetryClick = ::onRetryClicked, ) } val state: StateFlow = _state.asStateFlow() init { - if (params.preselectedArticlesId.isNotEmpty()) { - handlePreselectedArticles() - } else { - // TODO handle pagination [REDACTED_TASK_KEY] - } + loadNews() } private fun onArticleIndexChanged(newIndex: Int) { stateFactory.updateSelectedArticleIndex(newIndex) - val currentArticle = state.value.articles.getOrNull(newIndex) + val currentArticle = when (state.value.articlesStateUM) { + is ArticlesStateUM.Content -> { + paginationManager?.checkAndLoadMoreIfNeeded( + currentIndex = newIndex, + totalArticlesCount = state.value.articles.size, + ) + state.value.articles.getOrNull(newIndex) + } + ArticlesStateUM.Loading, + is ArticlesStateUM.LoadingError, + -> null + } currentArticle?.let { article -> markArticleAsViewed(article.id) loadRelatedTokens(article) } } - private fun handlePreselectedArticles() { + private fun loadNews() { modelScope.launch { - observeNewsDetailsUseCase.prefetch( - newsIds = params.preselectedArticlesId, - language = currentLanguage, - ) - - observeNewsDetailsUseCase - .invoke() - .map { articlesMap -> - params.preselectedArticlesId.mapNotNull { articleId -> - articlesMap[articleId]?.let { detailedArticle -> - newsDetailsConverter.convert(detailedArticle) - } + initialPrefetch() + paginationManager?.start() + val articleIdsFlow = paginationManager?.cachedPrefetchedIds ?: flowOf(params.preselectedArticlesId) + combine( + articleIdsFlow, + observeNewsDetailsUseCase.invoke(), + ) { articleIds, articlesMap -> + articleIds.mapNotNull { articleId -> + articlesMap[articleId]?.let { detailedArticle -> + newsDetailsConverter.convert(detailedArticle) } } + } .map { articles -> articles.toImmutableList() } - .onEach { articles -> - val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0 - stateFactory.updateArticles(articles, selectedIndex) + .onEach { newArticles -> + val currentState = _state.value + val newIndex = NewsDetailsIndexManager.calculateNewIndex( + currentState = NewsDetailsIndexManager.NewsDetailsState( + articles = currentState.articles, + selectedArticleIndex = currentState.selectedArticleIndex, + ), + newArticles = newArticles, + defaultArticleId = params.articleId, + ) + stateFactory.updateArticles(newArticles, newIndex) } .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 suspend fun initialPrefetch() { + observeNewsDetailsUseCase.prefetch( + newsIds = params.preselectedArticlesId, + language = currentLanguage, + ).mapLeft { + stateFactory.createErrorState() } } - private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? { - return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading } + private fun onRetryClicked() { + stateFactory.createLoadingState() + modelScope.launch(dispatchers.default) { + initialPrefetch() + paginationManager?.reload() + } } - 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) - } + private fun loadRelatedTokens(article: ArticleUM) { + modelScope.launch(dispatchers.default) { + stateFactory.updateRelatedTokens(RelatedTokensUM.Loading) + val appCurrency = currentAppCurrency.value + val resultState = relatedTokensLoader.load( + articleId = article.id, + relatedTokens = article.relatedTokens, + appCurrency = appCurrency, + onTokenClick = { tokenParams, currency -> + params.onTokenClick(tokenParams, currency) + }, + ) + stateFactory.updateRelatedTokens(resultState) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt new file mode 100644 index 0000000000..54ad5c4ec6 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt @@ -0,0 +1,112 @@ +package com.tangem.features.feed.model.news.details + +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.PaginationStatus +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class NewsDetailsPaginationManager( + private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, + private val currentLanguage: Provider, + getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, + dispatchers: CoroutineDispatcherProvider, + currentCategoryIds: Provider>, + modelScope: CoroutineScope, + prefetchedIds: Set, +) : NewsListBatchFlowManager( + getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, + currentLanguage = currentLanguage, + currentCategoryIds = currentCategoryIds, + modelScope = modelScope, + dispatchers = dispatchers, +) { + + private val _cachedPrefetchedIds = MutableStateFlow(prefetchedIds) + val cachedPrefetchedIds = _cachedPrefetchedIds.asStateFlow() + + init { + reload() + } + + fun start() { + modelScope.launch(dispatchers.default) { + combine( + rawArticlesFlow, + observeNewsDetailsUseCase(), + _cachedPrefetchedIds, + ) { articles, cachedArticles, prefetched -> + calculateIdsToFetch( + visibleIds = articles.map { it.id }.toSet(), + cachedIds = cachedArticles.keys, + alreadyPrefetched = prefetched, + ) + } + .distinctUntilChanged() + .collect { newIds -> + if (newIds.isNotEmpty()) { + observeNewsDetailsUseCase.prefetch( + newsIds = newIds, + language = currentLanguage(), + ) + _cachedPrefetchedIds.update { it + newIds } + } + } + } + } + + private fun calculateIdsToFetch(visibleIds: Set, cachedIds: Set, alreadyPrefetched: Set): Set { + return visibleIds - cachedIds - alreadyPrefetched + } + + fun checkAndLoadMoreIfNeeded( + currentIndex: Int, + totalArticlesCount: Int, + preloadThreshold: Int = PRELOAD_THRESHOLD, + ) { + if (currentIndex >= totalArticlesCount - preloadThreshold) { + loadMoreIfPossible() + } + } + + private fun loadMoreIfPossible() { + modelScope.launch(dispatchers.default) { + when (val status = paginationStatus.value) { + is PaginationStatus.InitialLoading, + is PaginationStatus.NextBatchLoading, + is PaginationStatus.EndOfPagination, + -> { + return@launch + } + + is PaginationStatus.InitialLoadingError -> { + reload() + return@launch + } + + is PaginationStatus.Paginating -> { + val lastResult = status.lastResult + val canLoadMore = lastResult is BatchFetchResult.Success && !lastResult.last + + if (canLoadMore) { + loadMore() + } + } + + is PaginationStatus.None -> { + reload() + } + } + } + } + + companion object { + private const val PRELOAD_THRESHOLD = 5 + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt new file mode 100644 index 0000000000..b7f4c844b4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/factory/NewsDetailsIndexManager.kt @@ -0,0 +1,30 @@ +package com.tangem.features.feed.model.news.details.factory + +import com.tangem.features.feed.ui.news.details.state.ArticleUM +import com.tangem.utils.extensions.indexOfFirstOrNull +import kotlinx.collections.immutable.ImmutableList + +internal object NewsDetailsIndexManager { + + /** + * Calculate new index of article, trying to save current selected article while is loading + */ + fun calculateNewIndex( + currentState: NewsDetailsState, + newArticles: ImmutableList, + defaultArticleId: Int, + ): Int { + val currentArticleId = if (currentState.articles.isEmpty()) { + defaultArticleId + } else { + currentState.articles.getOrNull(currentState.selectedArticleIndex)?.id + ?: defaultArticleId + } + return newArticles.indexOfFirstOrNull { it.id == currentArticleId } ?: 0 + } + + data class NewsDetailsState( + val articles: ImmutableList, + val selectedArticleIndex: Int, + ) +} \ 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 index 00553ec601..d52db614b1 100644 --- 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 @@ -1,8 +1,8 @@ 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.ArticlesStateUM 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 @@ -12,6 +12,7 @@ internal class NewsDetailsStateFactory( private val currentStateProvider: Provider, private val shareManager: ShareManager, private val onStateUpdate: (NewsDetailsUM) -> Unit, + private val onRetryClick: () -> Unit, ) { fun updateArticles(articles: List, selectedIndex: Int) { @@ -20,6 +21,7 @@ internal class NewsDetailsStateFactory( onStateUpdate( currentState.copy( articles = articles.toImmutableList(), + articlesStateUM = ArticlesStateUM.Content, selectedArticleIndex = selectedIndex, onShareClick = { currentArticle?.let { @@ -50,13 +52,13 @@ internal class NewsDetailsStateFactory( onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens)) } - fun createRelatedTokensContent( - items: List, - onTokenClick: (MarketsListItemUM) -> Unit, - ): RelatedTokensUM.Content { - return RelatedTokensUM.Content( - items = items.toImmutableList(), - onTokenClick = onTokenClick, - ) + fun createErrorState() { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.LoadingError(onRetryClick))) + } + + fun createLoadingState() { + val currentState = currentStateProvider() + onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.Loading)) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt new file mode 100644 index 0000000000..50294b3ffc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/loader/NewsRelatedTokensLoader.kt @@ -0,0 +1,150 @@ +package com.tangem.features.feed.model.news.details.loader + +import arrow.core.getOrElse +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.domain.models.news.RelatedToken +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.ui.news.details.state.RelatedTokensUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +internal class NewsRelatedTokensLoader( + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val dispatchers: CoroutineDispatcherProvider, + private val maxCount: Int, +) { + private val relatedTokensCache = mutableMapOf() + private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter() + + /** + * Loads data about related tokens. + * Returns the cached result if available, otherwise loads the data. + * + * @param articleId article ID for caching + * @param relatedTokens list of related tokens + * @param appCurrency application currency + * @param onTokenClick token click handler + * @return state of related tokens + */ + suspend fun load( + articleId: Int, + relatedTokens: List, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): RelatedTokensUM = withContext(dispatchers.default) { + val cached = getCachedRelatedTokens(articleId) + if (cached != null) { + return@withContext cached + } + + val tokensToLoad = relatedTokens.take(maxCount) + if (tokensToLoad.isEmpty()) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + return@withContext errorState + } + + val tokenDataList = loadTokensData(tokensToLoad, appCurrency) + + if (tokenDataList.isEmpty()) { + val errorState = RelatedTokensUM.LoadingError + relatedTokensCache[articleId] = errorState + return@withContext errorState + } + + val resultState = createRelatedTokensState(tokenDataList, appCurrency, onTokenClick) + relatedTokensCache[articleId] = resultState + resultState + } + + private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? { + return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading } + } + + 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, + ): TokenChart? { + val chartResult = getTokenPriceChartUseCase( + appCurrency = appCurrency, + interval = PriceChangeInterval.H24, + tokenId = tokenId, + tokenSymbol = tokenSymbol, + preview = true, + ) + return chartResult.getOrElse { null } + } + + private fun createRelatedTokensState( + tokenDataList: List>, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): RelatedTokensUM.Content { + val tokenItems = tokenDataList.map { it.first } + val onTokenClickHandler = createTokenClickHandler(tokenDataList, appCurrency, onTokenClick) + + return RelatedTokensUM.Content( + items = tokenItems.toImmutableList(), + onTokenClick = onTokenClickHandler, + ) + } + + private fun createTokenClickHandler( + tokenDataList: List>, + appCurrency: AppCurrency, + onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + ): (MarketsListItemUM) -> Unit { + return { item -> + val tokenData = tokenDataList.find { it.first.id == item.id } + tokenData?.second?.let { tokenParams -> + onTokenClick(tokenParams, appCurrency) + } + } + } +} \ No newline at end of file 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 5bdf4d1705..1e205fe187 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,25 +1,24 @@ 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.model.NewsListConfig 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.calculator.NewsListStateManager +import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader 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 @@ -39,6 +38,14 @@ internal class NewsListModel @Inject constructor( private val selectedCategoryId = MutableStateFlow(null) private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() + private val categoriesLoader by lazy { + NewsCategoriesLoader( + getNewsCategoriesUseCase = getNewsCategoriesUseCase, + defaultAllNewsCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, + onCategoryClick = ::onCategoryClick, + ) + } + private val batchFlowManager by lazy { NewsListBatchFlowManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, @@ -61,6 +68,7 @@ internal class NewsListModel @Inject constructor( params.onArticleClicked( /* currentArticle */ articleId, /* prefetchedArticles */ getCurrentFetchedArticlesIds(), + /* paginationConfig */ createNewsListConfig(), ) }, onBackClick = params.onBackClick, @@ -76,31 +84,7 @@ internal class NewsListModel @Inject constructor( 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( - ifLeft = { - persistentListOf() - }, - ifRight = { categories -> - (listOf(allCategoriesChip) + categories.map { articleCategory -> - ChipUM( - id = articleCategory.id, - text = TextReference.Str(articleCategory.name), - isSelected = false, - onClick = { - onCategoryClick(articleCategory.id) - }, - ) - }).toPersistentList() - }, - ) + val filterChips = categoriesLoader.load() _state.update { currentState -> currentState.copy(filters = filterChips) } @@ -114,23 +98,16 @@ internal class NewsListModel @Inject constructor( 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 - } - } + NewsListStateManager.calculateState( + articles = articles, + isError = isError, + paginationStatus = paginationStatus, + onRetryClick = { + loadCategories() + batchFlowManager.reload() + }, + onLoadMore = { batchFlowManager.loadMore() }, + ) }.collect { (listState, articles) -> _state.update { currentState -> currentState.copy( @@ -168,6 +145,15 @@ internal class NewsListModel @Inject constructor( return state.value.listOfArticles.map { it.id } } + private fun createNewsListConfig(): NewsListConfig { + return NewsListConfig( + language = currentLanguage, + snapshot = null, + tokenIds = emptyList(), + categoryIds = selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty(), + ) + } + companion object { private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1 } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt new file mode 100644 index 0000000000..b711909253 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/calculator/NewsListStateManager.kt @@ -0,0 +1,41 @@ +package com.tangem.features.feed.model.news.list.calculator + +import com.tangem.common.ui.news.ArticleConfigUM +import com.tangem.features.feed.ui.news.list.state.NewsListState +import com.tangem.pagination.PaginationStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * A pure function for calculating the state of the news list. + * Extracted from NewsListModel to simplify testing and improve readability. + */ +internal object NewsListStateManager { + + /** + * Calculates the state of the news list based on the current data. + * + * @param articles the current list of articles + * @param isError flag indicating an initial loading error + * @param paginationStatus the current pagination status + * @param onRetryClick handler for the retry loading action + * @param onLoadMore handler for loading the next page + * @return a pair: the list state and the list of articles to display + */ + fun calculateState( + articles: ImmutableList, + isError: Boolean, + paginationStatus: PaginationStatus<*>, + onRetryClick: () -> Unit, + onLoadMore: () -> Unit, + ): Pair> { + return when { + isError -> NewsListState.LoadingError(onRetryClick) to persistentListOf() + paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { + NewsListState.Loading to persistentListOf() + } + articles.isEmpty() -> NewsListState.Loading to persistentListOf() + else -> NewsListState.Content(loadMore = onLoadMore) to articles + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt new file mode 100644 index 0000000000..849ae5a073 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/loader/NewsCategoriesLoader.kt @@ -0,0 +1,50 @@ +package com.tangem.features.feed.model.news.list.loader + +import com.tangem.common.ui.R +import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class NewsCategoriesLoader( + private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase, + private val defaultAllNewsCategoryId: Int, + private val onCategoryClick: (Int) -> Unit, +) { + + suspend fun load(): ImmutableList { + val allCategoriesChip = createAllNewsChip() + val categoriesResult = getNewsCategoriesUseCase.invoke() + + return categoriesResult.fold( + ifLeft = { persistentListOf(allCategoriesChip) }, + ifRight = { categories -> + val categoryChips = categories.map { articleCategory -> + createCategoryChip(articleCategory) + } + (listOf(allCategoriesChip) + categoryChips).toPersistentList() + }, + ) + } + + private fun createAllNewsChip(): ChipUM { + return ChipUM( + id = defaultAllNewsCategoryId, + text = TextReference.Res(R.string.news_all_news), + isSelected = true, + onClick = { onCategoryClick(defaultAllNewsCategoryId) }, + ) + } + + private fun createCategoryChip(articleCategory: ArticleCategory): ChipUM { + return ChipUM( + id = articleCategory.id, + text = TextReference.Str(articleCategory.name), + isSelected = false, + onClick = { onCategoryClick(articleCategory.id) }, + ) + } +} \ 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 index 3029f0a192..86687df7a8 100644 --- 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 @@ -14,17 +14,18 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @Suppress("LongParameterList") -internal class NewsListBatchFlowManager( +internal open class NewsListBatchFlowManager( getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, private val currentLanguage: Provider, private val currentCategoryIds: Provider>, - private val modelScope: CoroutineScope, - private val dispatchers: CoroutineDispatcherProvider, + protected val modelScope: CoroutineScope, + protected val dispatchers: CoroutineDispatcherProvider, ) { private val actionsFlow = MutableSharedFlow>() private val converter by lazy { @@ -41,6 +42,19 @@ internal class NewsListBatchFlowManager( private val resultBatches = MutableStateFlow>>>(emptyList()) + val rawArticlesFlow: StateFlow> = batchFlow.state + .map { batchListState -> + batchListState.data + .flatMap { batch -> batch.data } + .toImmutableList() + } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = persistentListOf(), + ) + val uiItems: StateFlow> get() = batchFlow.state .map { batchListState -> @@ -64,15 +78,14 @@ internal class NewsListBatchFlowManager( initialValue = false, ) - val paginationStatus: StateFlow>> - get() = batchFlow.state - .map { it.status } - .distinctUntilChanged() - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = PaginationStatus.InitialLoading, - ) + val paginationStatus: StateFlow>> = batchFlow.state + .map { it.status } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = PaginationStatus.InitialLoading, + ) init { batchFlow.state diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 25a206dcb3..e402b48dff 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -11,10 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -74,7 +71,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { ) { animatedState -> when (animatedState) { is GlobalFeedState.Loading -> { - FeeListLoading( + FeedListLoading( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) @@ -88,7 +85,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { ) } is GlobalFeedState.Content -> { - FeeListContent( + FeedListContent( modifier = Modifier, state = state, ) @@ -125,7 +122,7 @@ private fun FeedSearchBar(feedListSearchBar: FeedListSearchBar, modifier: Modifi } @Composable -private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) { +private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier @@ -289,6 +286,13 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi @Suppress("LongMethod") @Composable private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { + val listState = rememberLazyListState() + val articlesReadStatus = remember(news.content) { + news.content.map { it.isViewed } + } + LaunchedEffect(articlesReadStatus) { + listState.requestScrollToItem(0) + } Column { Header( title = { @@ -347,7 +351,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - state = rememberLazyListState(), + state = listState, ) { items( items = news.content, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt index 2e24ff3ec2..0788fe844c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt @@ -17,7 +17,7 @@ import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.res.TangemThemePreview @Composable -internal fun FeeListLoading(modifier: Modifier = Modifier) { +internal fun FeedListLoading(modifier: Modifier = Modifier) { Column(modifier) { Column( modifier = Modifier diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index d36d027eaa..0d47363ec9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -203,6 +205,13 @@ private fun LazyListScope.loadingInfoBlocks() { private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { item("related-news") { + val listState = rememberLazyListState() + val articlesReadStatus = remember(relatedNews.articles) { + relatedNews.articles.map { it.isViewed } + } + LaunchedEffect(articlesReadStatus) { + listState.requestScrollToItem(0) + } Column( modifier = Modifier .fillMaxWidth() @@ -220,7 +229,7 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - state = rememberLazyListState(), + state = listState, ) { items( items = relatedNews.articles, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt index 86d36b10cb..917bd83e8c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.testTag import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.common.ui.markets.models.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR +import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -176,6 +177,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod onClick = onShowTokensClick, ), ) + SpacerH12() } } 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 b60edbbb14..8010cc035f 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 @@ -1,6 +1,7 @@ package com.tangem.features.feed.ui.news.details import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -20,6 +21,7 @@ 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.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -40,12 +42,45 @@ 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.components.NewsDetailsPlaceholder 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) { val background = LocalMainBottomSheetColor.current.value + AnimatedContent( + targetState = state.articlesStateUM, + modifier = modifier, + ) { animatedState -> + when (animatedState) { + ArticlesStateUM.Content -> { + Content(state = state, background = background) + } + ArticlesStateUM.Loading -> { + NewsDetailsPlaceholder(background = background) + } + is ArticlesStateUM.LoadingError -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData( + onRetryClick = animatedState.onRetryClicked, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 35.dp, horizontal = 10.dp), + ) + } + } + } + } +} + +@Composable +private fun Content(state: NewsDetailsUM, background: Color) { val pagerState = rememberPagerState( initialPage = state.selectedArticleIndex, pageCount = { state.articles.size }, @@ -67,7 +102,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif } Column( - modifier = modifier + modifier = Modifier .fillMaxSize() .background(background), ) { @@ -318,6 +353,7 @@ private fun PreviewNewsDetailsContent() { TangemThemePreview { NewsDetailsContent( state = NewsDetailsUM( + articlesStateUM = ArticlesStateUM.Content, articles = MockArticlesFactory.createMockArticles(), selectedArticleIndex = 0, onShareClick = {}, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt new file mode 100644 index 0000000000..e985004e1b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt @@ -0,0 +1,57 @@ +package com.tangem.features.feed.ui.news.details.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH + +@Composable +fun NewsDetailsPlaceholder(background: Color, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().background(background).padding(16.dp), + ) { + RectangleShimmer(modifier = Modifier.size(width = 112.dp, height = 20.dp)) + SpacerH(8.dp) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(28.dp), + ) + SpacerH(4.dp) + RectangleShimmer(modifier = Modifier.size(height = 28.dp, width = 208.dp)) + SpacerH(20.dp) + RectangleShimmer(modifier = Modifier.size(height = 36.dp, width = 99.dp), radius = 12.dp) + SpacerH(32.dp) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 24.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 70.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 96.dp), + ) + RectangleShimmer( + modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 100.dp), + ) + } + } +} \ 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 0fe906afa9..f8bcdf5f7d 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 @@ -8,6 +8,7 @@ import com.tangem.domain.models.news.RelatedToken import kotlinx.collections.immutable.ImmutableList internal data class NewsDetailsUM( + val articlesStateUM: ArticlesStateUM, val articles: ImmutableList, val selectedArticleIndex: Int, val onShareClick: () -> Unit, @@ -17,6 +18,13 @@ internal data class NewsDetailsUM( val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading, ) +@Immutable +internal sealed interface ArticlesStateUM { + data object Loading : ArticlesStateUM + data object Content : ArticlesStateUM + data class LoadingError(val onRetryClicked: () -> Unit) : ArticlesStateUM +} + internal data class ArticleUM( val id: Int, val title: String,