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

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

View file

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

View file

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

View file

@ -1,3 +1,7 @@
package com.tangem.datasource.local.datastore.core
@Deprecated(
message = "Use RuntimeSharedStore instead",
replaceWith = ReplaceWith("RuntimeSharedStore"),
)
internal interface StringKeyDataStore<Value : Any> : DataStore<String, Value>

View file

@ -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<Map<Int, DetailedArticle>>,
) : NewsDetailsStore {
override fun getAll(): Flow<List<DetailedArticle>> {
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<Int, DetailedArticle>) {
if (articles.isEmpty()) return
store.update(emptyMap()) { current ->
current + articles
}
}
override suspend fun clear() {
store.store(emptyMap())
}
}

View file

@ -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<List<DetailedArticle>>
suspend fun getSyncOrNull(id: Int): DetailedArticle?
suspend fun store(id: Int, article: DetailedArticle)
suspend fun store(articles: Map<Int, DetailedArticle>)
suspend fun clear()
}

View file

@ -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<String, List<ShortArticle>>
internal class DefaultTrendingNewsStore(
private val store: RuntimeSharedStore<TrendingCache>,
) : TrendingNewsStore {
override fun get(key: String): Flow<List<ShortArticle>> {
return store.get().map { it[key].orEmpty() }
}
override suspend fun getSyncOrNull(key: String): List<ShortArticle>? {
return store.getSyncOrNull()?.get(key)
}
override suspend fun store(key: String, value: List<ShortArticle>) {
store.update(emptyMap()) { current ->
current + (key to value)
}
}
override suspend fun clear() {
store.store(emptyMap())
}
}

View file

@ -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<List<ShortArticle>>
suspend fun getSyncOrNull(key: String): List<ShortArticle>?
suspend fun store(key: String, value: List<ShortArticle>)
suspend fun clear()
}

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

View file

@ -1,4 +1,4 @@
package com.tangem.domain.news.models
package com.tangem.domain.models.news
import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package com.tangem.domain.news.models
package com.tangem.domain.models.news
import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package com.tangem.domain.news.models
package com.tangem.domain.models.news
import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package com.tangem.domain.news.models
package com.tangem.domain.models.news
import kotlinx.serialization.Serializable

View file

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

View file

@ -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

View file

@ -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<Map<Int, DetailedArticle>>
/**
* Fetches and caches detailed articles for provided ids in parallel.
*/
suspend fun fetchDetailedArticles(newsIds: Collection<Int>, 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<ShortArticle>
/**
* Observes trending news with runtime viewed flag support.
*/
fun observeTrendingNews(): Flow<List<ShortArticle>>
/**
* 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<Int>, viewed: Boolean)
/**
* Returns available categories.
*/

View file

@ -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<ArticleCategory> {
return repository.getCategories()
}
}

View file

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

View file

@ -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<List<ShortArticle>> {
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<Int>, viewed: Boolean = true) {
repository.updateTrendingNewsViewed(articleIds, viewed)
}
}

View file

@ -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<Map<Int, DetailedArticle>> {
return repository.observeDetailedArticles()
}
/**
* Prefetches the given article ids (can be called with current + next ids for pager preloading).
*/
suspend fun prefetch(newsIds: Collection<Int>, language: String?) {
repository.fetchDetailedArticles(newsIds, language)
}
}