From 18201957b12fb290046a7f4c875ee199f9fcdabc Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 19 Nov 2025 12:46:39 +0100 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 1 + .../tangem/tap/di/domain/NewsDomainModule.kt | 41 ++++++ .../tangem/datasource/di/NewsStoreModule.kt | 29 +++++ .../datastore/core/StringKeyDataStore.kt | 4 + .../news/details/DefaultNewsDetailsStore.kt | 37 ++++++ .../local/news/details/NewsDetailsStore.kt | 17 +++ .../news/trending/DefaultTrendingNewsStore.kt | 31 +++++ .../local/news/trending/TrendingNewsStore.kt | 15 +++ .../com/tangem/data/news/di/NewsDataModule.kt | 11 +- .../news/repository/DefaultNewsRepository.kt | 122 +++++++++++++++++- .../tangem/data/news/repository/NewsMapper.kt | 7 +- .../domain/models/news}/ArticleCategory.kt | 2 +- .../domain/models/news}/DetailedArticle.kt | 2 +- .../domain/models/news}/OriginalArticle.kt | 2 +- .../domain/models/news}/RelatedToken.kt | 2 +- .../domain/models/news}/ShortArticle.kt | 4 +- .../domain/news/model/NewsTypealiases.kt | 2 +- .../domain/news/repository/NewsRepository.kt | 32 ++++- .../news/usecase/GetNewsCategoriesUseCase.kt | 21 +++ .../usecase/GetNewsListBatchFlowUseCase.kt | 26 ++++ .../news/usecase/ManageTrendingNewsUseCase.kt | 41 ++++++ .../news/usecase/ObserveNewsDetailsUseCase.kt | 29 +++++ 22 files changed, 460 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/details/DefaultNewsDetailsStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/details/NewsDetailsStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt rename domain/{news/src/main/java/com/tangem/domain/news/models => models/src/main/kotlin/com/tangem/domain/models/news}/ArticleCategory.kt (87%) rename domain/{news/src/main/java/com/tangem/domain/news/models => models/src/main/kotlin/com/tangem/domain/models/news}/DetailedArticle.kt (96%) rename domain/{news/src/main/java/com/tangem/domain/news/models => models/src/main/kotlin/com/tangem/domain/models/news}/OriginalArticle.kt (94%) rename domain/{news/src/main/java/com/tangem/domain/news/models => models/src/main/kotlin/com/tangem/domain/models/news}/RelatedToken.kt (90%) rename domain/{news/src/main/java/com/tangem/domain/news/models => models/src/main/kotlin/com/tangem/domain/models/news}/ShortArticle.kt (86%) create mode 100644 domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsListBatchFlowUseCase.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf4d86ade8..b5b03c3345 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -163,6 +163,7 @@ dependencies { implementation(projects.domain.yieldSupply) implementation(projects.domain.blockaid) implementation(projects.domain.hotWallet) + implementation(projects.domain.news) implementation(projects.common) implementation(projects.common.routing) diff --git a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt new file mode 100644 index 0000000000..dda3134a35 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt @@ -0,0 +1,41 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.news.repository.NewsRepository +import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase +import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase +import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase +import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object NewsDomainModule { + + @Provides + @Singleton + fun provideGetNewsCategoriesUseCase(repository: NewsRepository): GetNewsCategoriesUseCase { + return GetNewsCategoriesUseCase(repository) + } + + @Provides + @Singleton + fun provideObserveNewsDetailsUseCase(repository: NewsRepository): ObserveNewsDetailsUseCase { + return ObserveNewsDetailsUseCase(repository) + } + + @Provides + @Singleton + fun provideObserveTrendingNewsUseCase(repository: NewsRepository): ManageTrendingNewsUseCase { + return ManageTrendingNewsUseCase(repository) + } + + @Provides + @Singleton + fun provideGetNewsListBatchFlowUseCase(repository: NewsRepository): GetNewsListBatchFlowUseCase { + return GetNewsListBatchFlowUseCase(repository) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt new file mode 100644 index 0000000000..fa1dbd3792 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt @@ -0,0 +1,29 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore +import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore +import com.tangem.datasource.local.news.trending.TrendingNewsStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object NewsStoreModule { + + @Provides + @Singleton + fun provideNewsDetailsStore(): NewsDetailsStore { + return DefaultNewsDetailsStore(store = RuntimeSharedStore()) + } + + @Provides + @Singleton + fun provideTrendingNewsStore(): TrendingNewsStore { + return DefaultTrendingNewsStore(store = RuntimeSharedStore()) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStore.kt index 4c04a89dc5..d6a3fb0fbb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStore.kt @@ -1,3 +1,7 @@ package com.tangem.datasource.local.datastore.core +@Deprecated( + message = "Use RuntimeSharedStore instead", + replaceWith = ReplaceWith("RuntimeSharedStore"), +) internal interface StringKeyDataStore : DataStore \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/details/DefaultNewsDetailsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/DefaultNewsDetailsStore.kt new file mode 100644 index 0000000000..06eac03937 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/DefaultNewsDetailsStore.kt @@ -0,0 +1,37 @@ +package com.tangem.datasource.local.news.details + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.news.DetailedArticle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class DefaultNewsDetailsStore( + private val store: RuntimeSharedStore>, +) : NewsDetailsStore { + + override fun getAll(): Flow> { + return store.get().map { it.values.toList() } + } + + override suspend fun getSyncOrNull(id: Int): DetailedArticle? { + return store.getSyncOrNull()?.get(id) + } + + override suspend fun store(id: Int, article: DetailedArticle) { + store.update(emptyMap()) { current -> + current + (id to article) + } + } + + override suspend fun store(articles: Map) { + if (articles.isEmpty()) return + + store.update(emptyMap()) { current -> + current + articles + } + } + + override suspend fun clear() { + store.store(emptyMap()) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/details/NewsDetailsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/NewsDetailsStore.kt new file mode 100644 index 0000000000..f2af3e04ab --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/details/NewsDetailsStore.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.local.news.details + +import com.tangem.domain.models.news.DetailedArticle +import kotlinx.coroutines.flow.Flow + +interface NewsDetailsStore { + + fun getAll(): Flow> + + suspend fun getSyncOrNull(id: Int): DetailedArticle? + + suspend fun store(id: Int, article: DetailedArticle) + + suspend fun store(articles: Map) + + suspend fun clear() +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt new file mode 100644 index 0000000000..23c1cad1db --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt @@ -0,0 +1,31 @@ +package com.tangem.datasource.local.news.trending + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.news.ShortArticle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private typealias TrendingCache = Map> + +internal class DefaultTrendingNewsStore( + private val store: RuntimeSharedStore, +) : TrendingNewsStore { + + override fun get(key: String): Flow> { + return store.get().map { it[key].orEmpty() } + } + + override suspend fun getSyncOrNull(key: String): List? { + return store.getSyncOrNull()?.get(key) + } + + override suspend fun store(key: String, value: List) { + store.update(emptyMap()) { current -> + current + (key to value) + } + } + + override suspend fun clear() { + store.store(emptyMap()) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt new file mode 100644 index 0000000000..24afaabda0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.local.news.trending + +import com.tangem.domain.models.news.ShortArticle +import kotlinx.coroutines.flow.Flow + +interface TrendingNewsStore { + + fun get(key: String): Flow> + + suspend fun getSyncOrNull(key: String): List? + + suspend fun store(key: String, value: List) + + suspend fun clear() +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt index 8388136dba..9a5aa274f1 100644 --- a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt +++ b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt @@ -2,6 +2,8 @@ package com.tangem.data.news.di import com.tangem.data.news.repository.DefaultNewsRepository import com.tangem.datasource.api.news.NewsApi +import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.domain.news.repository.NewsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -16,10 +18,17 @@ internal object NewsDataModule { @Singleton @Provides - fun provideNewsRepository(newsApi: NewsApi, dispatchers: CoroutineDispatcherProvider): NewsRepository { + fun provideNewsRepository( + newsApi: NewsApi, + dispatchers: CoroutineDispatcherProvider, + newsDetailsStore: NewsDetailsStore, + trendingNewsStore: TrendingNewsStore, + ): NewsRepository { return DefaultNewsRepository( newsApi = newsApi, dispatchers = dispatchers, + newsDetailsStore = newsDetailsStore, + trendingNewsStore = trendingNewsStore, ) } } \ No newline at end of file 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 6e10db56d6..e42068534e 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 @@ -2,12 +2,14 @@ package com.tangem.data.news.repository import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.news.NewsApi +import com.tangem.datasource.local.news.details.NewsDetailsStore +import com.tangem.datasource.local.news.trending.TrendingNewsStore 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.models.ArticleCategory -import com.tangem.domain.news.models.DetailedArticle -import com.tangem.domain.news.models.ShortArticle +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.news.repository.NewsRepository import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.BatchListSource @@ -17,6 +19,13 @@ import com.tangem.pagination.toBatchFlow import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.collections.orEmpty /** * Implementation of [NewsRepository]. @@ -25,6 +34,8 @@ import javax.inject.Inject internal class DefaultNewsRepository @Inject constructor( private val newsApi: NewsApi, private val dispatchers: CoroutineDispatcherProvider, + private val newsDetailsStore: NewsDetailsStore, + private val trendingNewsStore: TrendingNewsStore, ) : NewsRepository { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { @@ -37,13 +48,54 @@ internal class DefaultNewsRepository @Inject constructor( } override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { - val response = newsApi.getNewsDetails(newsId = newsId, language = language).getOrThrow() - return response.toDomainDetailedArticle() + 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> { + return newsDetailsStore.getAll().map { articles -> + articles.associateBy(DetailedArticle::id) + } + } + + override suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) { + fetchDetailedArticlesInternal(newsIds = newsIds, language = language) } override suspend fun getTrendingNews(limit: Int, language: String?): List { - val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow() - return response.items.map { it.toDomainShortArticle() } + return fetchAndStoreTrendingNews(limit = limit, language = language) + } + + override fun observeTrendingNews(): Flow> { + return trendingNewsStore.get(TRENDING_NEWS_KEY) + } + + override suspend fun refreshTrendingNews(limit: Int, language: String?) { + fetchAndStoreTrendingNews(limit = limit, language = language) + } + + override suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) { + if (articleIds.isEmpty()) return + + val current = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty() + if (current.isEmpty()) return + + val ids = articleIds.toSet() + val updated = current.map { article -> + if (article.id in ids) { + article.copy(viewed = viewed) + } else { + article + } + } + + trendingNewsStore.store(TRENDING_NEWS_KEY, updated) } override suspend fun getCategories(): List { @@ -55,6 +107,61 @@ internal class DefaultNewsRepository @Inject constructor( } } + private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = + withContext(dispatchers.io) { + if (newsIds.isEmpty()) return@withContext + + val uniqueIds = newsIds.distinct() + val idsToFetch = buildList { + for (id in uniqueIds) { + val cached = newsDetailsStore.getSyncOrNull(id) + if (cached == null) add(id) + } + } + + if (idsToFetch.isEmpty()) return@withContext + + val fetchedArticles = coroutineScope { + idsToFetch.map { newsId -> + async { + newsApi.getNewsDetails(newsId = newsId, language = language) + .getOrThrow() + .toDomainDetailedArticle() + } + }.awaitAll() + } + + if (fetchedArticles.isNotEmpty()) { + newsDetailsStore.store( + articles = fetchedArticles.associateBy(DetailedArticle::id), + ) + } + } + + private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?): List { + return withContext(dispatchers.io) { + val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow() + val freshArticles = response.items.map { it.toDomainShortArticle() } + val currentArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty() + val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit) + + trendingNewsStore.store(TRENDING_NEWS_KEY, merged) + + merged + } + } + + private fun mergeTrendingArticles(current: List, fresh: List): List { + if (current.isEmpty()) return fresh + + val currentById = current.associateBy(ShortArticle::id) + + return fresh.map { article -> + val stored = currentById[article.id] ?: return@map article + article.copy(viewed = stored.viewed) + } + } + private fun createBatchFetcher(batchSize: Int): BatchFetcher> { return NewsBatchFetcher( newsApi = newsApi, @@ -167,5 +274,6 @@ internal class DefaultNewsRepository @Inject constructor( private companion object { private const val INITIAL_BATCH_KEY = 0 private const val FIRST_PAGE = 1 + private const val TRENDING_NEWS_KEY = "trending_news" } } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt index 3c9d8476fe..6f34dd3ed4 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt @@ -4,7 +4,11 @@ import com.tangem.datasource.api.news.models.response.NewsArticleDto import com.tangem.datasource.api.news.models.response.NewsDetailsResponse import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto -import com.tangem.domain.news.models.* +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.OriginalArticle +import com.tangem.domain.models.news.RelatedToken +import com.tangem.domain.models.news.ShortArticle internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { return DetailedArticle( @@ -34,6 +38,7 @@ internal fun NewsArticleDto.toDomainShortArticle(): ShortArticle { isTrending = isTrending, title = title, newsUrl = newsUrl, + viewed = false, ) } diff --git a/domain/news/src/main/java/com/tangem/domain/news/models/ArticleCategory.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ArticleCategory.kt similarity index 87% rename from domain/news/src/main/java/com/tangem/domain/news/models/ArticleCategory.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/news/ArticleCategory.kt index c5876b9e37..b2f818535d 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/models/ArticleCategory.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ArticleCategory.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.news.models +package com.tangem.domain.models.news import kotlinx.serialization.Serializable diff --git a/domain/news/src/main/java/com/tangem/domain/news/models/DetailedArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt similarity index 96% rename from domain/news/src/main/java/com/tangem/domain/news/models/DetailedArticle.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt index 2086d850df..ea41bfe17f 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/models/DetailedArticle.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/DetailedArticle.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.news.models +package com.tangem.domain.models.news import kotlinx.serialization.Serializable diff --git a/domain/news/src/main/java/com/tangem/domain/news/models/OriginalArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt similarity index 94% rename from domain/news/src/main/java/com/tangem/domain/news/models/OriginalArticle.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt index 700dfe5978..d087148b79 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/models/OriginalArticle.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/OriginalArticle.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.news.models +package com.tangem.domain.models.news import kotlinx.serialization.Serializable diff --git a/domain/news/src/main/java/com/tangem/domain/news/models/RelatedToken.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/RelatedToken.kt similarity index 90% rename from domain/news/src/main/java/com/tangem/domain/news/models/RelatedToken.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/news/RelatedToken.kt index 4e796c9840..8dc34ff208 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/models/RelatedToken.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/RelatedToken.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.news.models +package com.tangem.domain.models.news import kotlinx.serialization.Serializable diff --git a/domain/news/src/main/java/com/tangem/domain/news/models/ShortArticle.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ShortArticle.kt similarity index 86% rename from domain/news/src/main/java/com/tangem/domain/news/models/ShortArticle.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/news/ShortArticle.kt index 8ec22d6474..56cb8adb43 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/models/ShortArticle.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/ShortArticle.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.news.models +package com.tangem.domain.models.news import kotlinx.serialization.Serializable @@ -15,6 +15,7 @@ import kotlinx.serialization.Serializable * @param relatedTokens - tokens, which were mentioned in article * @param title - article title * @param newsUrl - link for article to share + * @param viewed - is article is viewed (store only runtime) */ @Serializable data class ShortArticle( @@ -27,4 +28,5 @@ data class ShortArticle( val isTrending: Boolean, val title: String, val newsUrl: String, + val viewed: Boolean, ) \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt index 45a04c9d4b..ea3de971c7 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt @@ -1,6 +1,6 @@ package com.tangem.domain.news.model -import com.tangem.domain.news.models.ShortArticle +import com.tangem.domain.models.news.ShortArticle import com.tangem.pagination.BatchFlow import com.tangem.pagination.BatchingContext 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 c8ab3ccbe5..c8ab6c6cd6 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 @@ -2,9 +2,10 @@ package com.tangem.domain.news.repository import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext -import com.tangem.domain.news.models.ArticleCategory -import com.tangem.domain.news.models.DetailedArticle -import com.tangem.domain.news.models.ShortArticle +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle +import kotlinx.coroutines.flow.Flow /** [REDACTED_AUTHOR] @@ -26,6 +27,16 @@ interface NewsRepository { */ suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle + /** + * Observes cached detailed articles. + */ + fun observeDetailedArticles(): Flow> + + /** + * Fetches and caches detailed articles for provided ids in parallel. + */ + suspend fun fetchDetailedArticles(newsIds: Collection, language: String?) + /** * Returns list of trending news by limit and with correct locale. * @@ -34,6 +45,21 @@ interface NewsRepository { */ suspend fun getTrendingNews(limit: Int, language: String?): List + /** + * Observes trending news with runtime viewed flag support. + */ + fun observeTrendingNews(): Flow> + + /** + * Refreshes trending news list and updates cache without overriding viewed status. + */ + suspend fun refreshTrendingNews(limit: Int, language: String?) + + /** + * Updates viewed flag for provided trending articles. + */ + suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) + /** * Returns available categories. */ diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt new file mode 100644 index 0000000000..35e1f8f0bb --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsCategoriesUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.models.news.ArticleCategory +import com.tangem.domain.news.repository.NewsRepository + +/** + * Returns the list of available news categories. + * + * Use this from presentation layer to refresh filters or render category chips. + */ +class GetNewsCategoriesUseCase( + private val repository: NewsRepository, +) { + + /** + * Fetches categories from the repository. + */ + suspend operator fun invoke(): List { + return repository.getCategories() + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsListBatchFlowUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsListBatchFlowUseCase.kt new file mode 100644 index 0000000000..f219d3cd1c --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsListBatchFlowUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.news.model.NewsListBatchFlow +import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.repository.NewsRepository + +/** + * Provides a pagination-aware flow of short articles. + * + * Mirrors behaviour of other BatchFlow use cases (e.g., manage tokens) and can be used with pagers. + */ +class GetNewsListBatchFlowUseCase( + private val repository: NewsRepository, +) { + + /** + * Creates a [NewsListBatchFlow] for the given batching context and page size. + */ + operator fun invoke(context: NewsListBatchingContext, batchSize: Int = DEFAULT_BATCH_SIZE): NewsListBatchFlow { + return repository.getNewsListBatchFlow(context, batchSize) + } + + private companion object { + private const val DEFAULT_BATCH_SIZE = 20 + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt new file mode 100644 index 0000000000..55d69e8875 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.repository.NewsRepository +import kotlinx.coroutines.flow.Flow + +/** + * Exposes trending news as a cached flow and provides helpers to refresh or mark items as viewed. + * + * Keep a single instance per process (provided via DI) and collect the flow to render trending rows. + */ +class ManageTrendingNewsUseCase(private val repository: NewsRepository) { + + /** + * Observes the current cached list of trending articles (max 10 items). + */ + operator fun invoke(): Flow> { + return repository.observeTrendingNews() + } + + /** + * Forces refresh from backend while preserving local `viewed` flags. + */ + suspend fun refresh(limit: Int, language: String?) { + repository.refreshTrendingNews(limit, language) + } + + /** + * Marks a single article as viewed/unviewed. + */ + suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { + repository.updateTrendingNewsViewed(listOf(articleId), viewed) + } + + /** + * Marks multiple articles at once (useful for bulk updates). + */ + suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { + repository.updateTrendingNewsViewed(articleIds, viewed) + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt new file mode 100644 index 0000000000..ea0dc371d9 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.news.repository.NewsRepository +import kotlinx.coroutines.flow.Flow + +/** + * Provides a hot stream with cached detailed articles and supports proactive prefetching. + * + * Subscribe once per screen to receive updates for any article that was fetched through [prefetch]. + */ +class ObserveNewsDetailsUseCase( + private val repository: NewsRepository, +) { + + /** + * Observes all cached detailed articles mapped by id. + */ + operator fun invoke(): Flow> { + return repository.observeDetailedArticles() + } + + /** + * Prefetches the given article ids (can be called with current + next ids for pager preloading). + */ + suspend fun prefetch(newsIds: Collection, language: String?) { + repository.fetchDetailedArticles(newsIds, language) + } +} \ No newline at end of file