Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-19 12:46:39 +01:00
parent 96154e6983
commit 18201957b1
22 changed files with 460 additions and 18 deletions

View file

@ -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,
)
}
}

View file

@ -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<Map<Int, DetailedArticle>> {
return newsDetailsStore.getAll().map { articles ->
articles.associateBy(DetailedArticle::id)
}
}
override suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?) {
fetchDetailedArticlesInternal(newsIds = newsIds, language = language)
}
override suspend fun getTrendingNews(limit: Int, language: String?): List<ShortArticle> {
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<List<ShortArticle>> {
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<Int>, 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<ArticleCategory> {
@ -55,6 +107,61 @@ internal class DefaultNewsRepository @Inject constructor(
}
}
private suspend fun fetchDetailedArticlesInternal(newsIds: Collection<Int>, 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<ShortArticle> {
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<ShortArticle>, fresh: List<ShortArticle>): List<ShortArticle> {
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<NewsListConfig, List<ShortArticle>> {
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"
}
}

View file

@ -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,
)
}