From dcb8b474cfbac6a8cf06b6e86f9ac6aecba71c80 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 18 Nov 2025 08:47:52 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + .../datasource/api/common/config/ApiConfig.kt | 2 + .../datasource/api/common/config/News.kt | 62 +++++++ .../com/tangem/datasource/api/news/NewsApi.kt | 43 +++++ .../news/models/response/NewsArticleDto.kt | 41 +++++ .../models/response/NewsCategoriesResponse.kt | 9 + .../models/response/NewsDetailsResponse.kt | 20 +++ .../news/models/response/NewsListResponse.kt | 19 ++ .../models/response/NewsTrendingResponse.kt | 15 ++ .../tangem/datasource/di/ApiConfigsModule.kt | 4 + .../com/tangem/datasource/di/NetworkModule.kt | 10 ++ .../api/common/config/ApiConfigTest.kt | 1 + .../managers/ProdApiConfigsManagerTest.kt | 25 +++ .../com/tangem/data/news/di/NewsDataModule.kt | 9 +- .../news/repository/DefaultNewsRepository.kt | 166 +++++++++++++++++- .../tangem/data/news/repository/NewsMapper.kt | 58 ++++++ domain/news/build.gradle.kts | 1 + .../domain/news/model/NewsListConfig.kt | 21 +++ .../domain/news/model/NewsTypealiases.kt | 9 + .../domain/news/repository/NewsRepository.kt | 39 +++- 20 files changed, 550 insertions(+), 8 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/news/NewsApi.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsCategoriesResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsListResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsTrendingResponse.kt create mode 100644 data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt create mode 100644 domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 710a97de4c..cd3fce3406 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -54,5 +54,9 @@ { "name": "ACCOUNTS_FEATURE_ENABLED", "version": "undefined" + }, + { + "name": "FEED_ENABLED", + "version": "undefined" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 24cb41f229..8b69a14c9d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -29,6 +29,7 @@ sealed class ApiConfig { BlockAid, YieldSupply, MoonPay, + News, } private fun initializeId(): ID { @@ -41,6 +42,7 @@ sealed class ApiConfig { is BlockAid -> ID.BlockAid is YieldSupply -> ID.YieldSupply is MoonPay -> ID.MoonPay + is News -> ID.News } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt new file mode 100644 index 0000000000..23ff32ba22 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt @@ -0,0 +1,62 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.AuthProvider +import com.tangem.datasource.utils.RequestHeader +import com.tangem.utils.Provider + +/** + * News [ApiConfig] +[REDACTED_AUTHOR] + */ +internal class News( + private val authProvider: AuthProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + createDevEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = PROD_BASE_URL, + headers = createHeaders(ApiEnvironment.PROD), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = DEV_BASE_URL, + headers = createHeaders(ApiEnvironment.DEV), + ) + + private fun createHeaders(environment: ApiEnvironment) = buildMap { + putAll( + RequestHeader.TangemApiKeyHeader( + authProvider = authProvider, + apiEnvironment = Provider { environment }, + ).values, + ) + } + + private companion object { + + private const val PROD_BASE_URL = "https://api.tangem.org/" + private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/NewsApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/NewsApi.kt new file mode 100644 index 0000000000..24e359b20e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/NewsApi.kt @@ -0,0 +1,43 @@ +package com.tangem.datasource.api.news + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.news.models.response.NewsCategoriesResponse +import com.tangem.datasource.api.news.models.response.NewsDetailsResponse +import com.tangem.datasource.api.news.models.response.NewsListResponse +import com.tangem.datasource.api.news.models.response.NewsTrendingResponse +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface NewsApi { + + @GET(NEWS_PATH) + suspend fun getNews( + @Query("page") page: Int? = null, + @Query("limit") limit: Int? = null, + @Query("lang") language: String? = null, + @Query("asOf") snapshot: String? = null, + @Query("tokenIds") tokenIds: List? = null, + @Query("categoryIds") categoryIds: List? = null, + ): ApiResponse + + @GET("$NEWS_PATH/{newsId}") + suspend fun getNewsDetails( + @Path("newsId") newsId: Int, + @Query("lang") language: String? = null, + ): ApiResponse + + @GET("$NEWS_PATH/trending") + suspend fun getTrendingNews( + @Query("limit") limit: Int? = null, + @Query("lang") language: String? = null, + ): ApiResponse + + @GET("$NEWS_PATH/categories") + suspend fun getCategories(): ApiResponse + + private companion object { + + private const val NEWS_PATH = "api/v1/news" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt new file mode 100644 index 0000000000..292b3d4559 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsArticleDto.kt @@ -0,0 +1,41 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsArticleDto( + @Json(name = "id") val id: Int, + @Json(name = "createdAt") val createdAt: String, + @Json(name = "score") val score: Double, + @Json(name = "language") val language: String, + @Json(name = "isTrending") val isTrending: Boolean, + @Json(name = "categories") val categories: List, + @Json(name = "relatedTokens") val relatedTokens: List, + @Json(name = "title") val title: String, + @Json(name = "newsUrl") val newsUrl: String, +) + +@JsonClass(generateAdapter = true) +data class NewsCategoryDto( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String, +) + +@JsonClass(generateAdapter = true) +data class NewsRelatedTokenDto( + @Json(name = "id") val id: String, + @Json(name = "symbol") val symbol: String, + @Json(name = "name") val name: String, +) + +@JsonClass(generateAdapter = true) +data class NewsOriginalArticleDto( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String, + @Json(name = "sourceName") val sourceName: String, + @Json(name = "language") val language: String, + @Json(name = "publishedAt") val publishedAt: String, + @Json(name = "url") val url: String, + @Json(name = "imageUrl") val imageUrl: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsCategoriesResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsCategoriesResponse.kt new file mode 100644 index 0000000000..bc4816f220 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsCategoriesResponse.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsCategoriesResponse( + @Json(name = "items") val items: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt new file mode 100644 index 0000000000..230c3f42fb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsDetailsResponse.kt @@ -0,0 +1,20 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsDetailsResponse( + @Json(name = "id") val id: Int, + @Json(name = "createdAt") val createdAt: String, + @Json(name = "score") val score: Double, + @Json(name = "language") val language: String, + @Json(name = "isTrending") val isTrending: Boolean, + @Json(name = "categories") val categories: List, + @Json(name = "relatedTokens") val relatedTokens: List, + @Json(name = "title") val title: String, + @Json(name = "newsUrl") val newsUrl: String, + @Json(name = "shortContent") val shortContent: String, + @Json(name = "content") val content: String, + @Json(name = "originalArticles") val originalArticles: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsListResponse.kt new file mode 100644 index 0000000000..089d974534 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsListResponse.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsListResponse( + @Json(name = "meta") val meta: NewsListMetaDto, + @Json(name = "items") val items: List, +) + +@JsonClass(generateAdapter = true) +data class NewsListMetaDto( + @Json(name = "page") val page: Int, + @Json(name = "limit") val limit: Int, + @Json(name = "total") val total: Long, + @Json(name = "hasNext") val hasNext: Boolean, + @Json(name = "asOf") val asOf: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsTrendingResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsTrendingResponse.kt new file mode 100644 index 0000000000..3a21af3d04 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/news/models/response/NewsTrendingResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.news.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NewsTrendingResponse( + @Json(name = "meta") val meta: NewsTrendingMetaDto, + @Json(name = "items") val items: List, +) + +@JsonClass(generateAdapter = true) +data class NewsTrendingMetaDto( + @Json(name = "limit") val limit: Int, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index a8d59b580e..052decff8c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -58,6 +58,10 @@ internal object ApiConfigsModule { appInfoProvider = appInfoProvider, ) + @Provides + @IntoSet + fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider) + @Provides @IntoSet fun provideYieldSupplyConfig( diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 10c72e8fa6..dd2041b5f1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -13,6 +13,7 @@ import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.moonpay.MoonPayApi +import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.pay.TangemPayApi @@ -151,4 +152,13 @@ internal object NetworkModule { applyTimeoutAnnotations = false, ) } + + @Provides + @Singleton + fun provideNewsApi(retrofitApiBuilder: RetrofitApiBuilder): NewsApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.News, + applyTimeoutAnnotations = false, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 11f14cdc69..693c666ff7 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -73,6 +73,7 @@ class ApiConfigTest { ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk()) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) + ApiConfig.ID.News -> News(authProvider = appAuthProvider) } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 9e11effa7d..8e1ae18cf9 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -114,6 +114,7 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) + ApiConfig.ID.News -> News(authProvider = appAuthProvider) } } } @@ -128,6 +129,7 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.BlockAid -> createBlockAidSdkModel() ApiConfig.ID.MoonPay -> createMoonPayModel() ApiConfig.ID.P2PEthPool -> createP2PModel() + ApiConfig.ID.News -> createNewsModel() } } @@ -295,6 +297,29 @@ internal class ProdApiConfigsManagerTest { ) } + private fun createNewsModel(): TestModel { + val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV to "[REDACTED_ENV_URL]" + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD to "https://tangem.com/" + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + return TestModel( + id = ApiConfig.ID.News, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = baseUrl, + headers = mapOf( + "api-key" to ProviderSuspend { TANGEM_API_KEY }, + ), + ), + ) + } + private fun String.checkHeaderValueOrEmpty(): String { for (i in this.indices) { val c = this[i] 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 4d1f74a2d8..8388136dba 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 @@ -1,7 +1,9 @@ package com.tangem.data.news.di import com.tangem.data.news.repository.DefaultNewsRepository +import com.tangem.datasource.api.news.NewsApi import com.tangem.domain.news.repository.NewsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,7 +16,10 @@ internal object NewsDataModule { @Singleton @Provides - fun providesQuotesRepository(): NewsRepository { - return DefaultNewsRepository() + fun provideNewsRepository(newsApi: NewsApi, dispatchers: CoroutineDispatcherProvider): NewsRepository { + return DefaultNewsRepository( + newsApi = newsApi, + dispatchers = dispatchers, + ) } } \ 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 259934d383..600fdaa843 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 @@ -1,10 +1,170 @@ package com.tangem.data.news.repository +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.news.NewsApi +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.news.repository.NewsRepository +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListSource +import com.tangem.pagination.exception.EndOfPaginationException +import com.tangem.pagination.fetcher.BatchFetcher +import com.tangem.pagination.toBatchFlow +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject /** - * Default implementation of [NewsRepository] - * + * Implementation of [NewsRepository]. [REDACTED_AUTHOR] */ -internal class DefaultNewsRepository : NewsRepository \ No newline at end of file +internal class DefaultNewsRepository @Inject constructor( + private val newsApi: NewsApi, + private val dispatchers: CoroutineDispatcherProvider, +) : NewsRepository { + + override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { + return BatchListSource( + fetchDispatcher = dispatchers.io, + context = context, + generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY }, + batchFetcher = createBatchFetcher(batchSize), + ).toBatchFlow() + } + + override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { + val response = newsApi.getNewsDetails(newsId = newsId, language = language).getOrThrow() + return response.toDomainDetailedArticle() + } + + override suspend fun getTrendingNews(limit: Int, language: String?): List { + val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow() + return response.items.map { it.toDomainShortArticle() } + } + + override suspend fun getCategories(): List { + return newsApi.getCategories().getOrThrow().items.map { dto -> + ArticleCategory( + id = dto.id, + name = dto.name, + ) + } + } + + private fun createBatchFetcher(batchSize: Int): BatchFetcher> { + return NewsBatchFetcher( + newsApi = newsApi, + batchSize = batchSize, + ) + } + + private class NewsBatchFetcher( + private val newsApi: NewsApi, + private val batchSize: Int, + ) : BatchFetcher> { + + private var state: NewsPaginationState? = null + + override suspend fun fetchFirst(requestParams: NewsListConfig): BatchFetchResult> { + return runCatching { + loadPage( + page = FIRST_PAGE, + params = requestParams, + limit = batchSize, + snapshotOverride = requestParams.snapshot, + ) + }.fold( + onSuccess = { result -> + state = result.state + result.batchResult + }, + onFailure = { throwable -> BatchFetchResult.Error(throwable) }, + ) + } + + override suspend fun fetchNext( + overrideRequestParams: NewsListConfig?, + lastResult: BatchFetchResult>, + ): BatchFetchResult> { + val currentState = state ?: return BatchFetchResult.Error( + IllegalStateException("fetchFirst must be called"), + ) + + if (lastResult is BatchFetchResult.Success && lastResult.last && overrideRequestParams == null) { + return BatchFetchResult.Error(EndOfPaginationException()) + } + + val params = overrideRequestParams ?: currentState.params + val shouldReset = overrideRequestParams != null && overrideRequestParams != currentState.params + val pageToLoad = if (shouldReset) FIRST_PAGE else currentState.nextPage + val snapshot = if (shouldReset) overrideRequestParams.snapshot else currentState.snapshot + + return runCatching { + loadPage( + page = pageToLoad, + params = params, + limit = batchSize, + snapshotOverride = snapshot, + ) + }.fold( + onSuccess = { result -> + state = result.state + result.batchResult + }, + onFailure = { throwable -> BatchFetchResult.Error(throwable) }, + ) + } + + private suspend fun loadPage( + page: Int, + params: NewsListConfig, + limit: Int, + snapshotOverride: String?, + ): PageLoadResult { + val response = newsApi.getNews( + page = page, + limit = limit, + language = params.language, + snapshot = snapshotOverride, + tokenIds = params.tokenIds.takeIf { it.isNotEmpty() }, + categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, + ).getOrThrow() + + val items = response.items.map { it.toDomainShortArticle() } + + val batchResult = BatchFetchResult.Success( + data = items, + empty = items.isEmpty(), + last = response.meta.hasNext.not(), + ) + + return PageLoadResult( + batchResult = batchResult, + state = NewsPaginationState( + nextPage = response.meta.page + 1, + snapshot = response.meta.asOf, + params = params.copy(snapshot = response.meta.asOf), + ), + ) + } + } + + private data class PageLoadResult( + val batchResult: BatchFetchResult.Success>, + val state: NewsPaginationState, + ) + + private data class NewsPaginationState( + val nextPage: Int, + val snapshot: String?, + val params: NewsListConfig, + ) + + private companion object { + private const val INITIAL_BATCH_KEY = 0 + private const val FIRST_PAGE = 1 + } +} \ 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 new file mode 100644 index 0000000000..3c9d8476fe --- /dev/null +++ b/data/news/src/main/java/com/tangem/data/news/repository/NewsMapper.kt @@ -0,0 +1,58 @@ +package com.tangem.data.news.repository + +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.* + +internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { + return DetailedArticle( + id = id, + createdAt = createdAt, + score = score.toFloat(), + locale = language, + isTrending = isTrending, + categories = categories.map { ArticleCategory(id = it.id, name = it.name) }, + relatedTokens = relatedTokens.map { it.toDomainRelatedToken() }, + title = title, + newsUrl = newsUrl, + shortContent = shortContent, + content = content, + originalArticles = originalArticles.map { it.toDomainOriginalArticle() }, + ) +} + +internal fun NewsArticleDto.toDomainShortArticle(): ShortArticle { + return ShortArticle( + id = id, + createdAt = createdAt, + score = score.toFloat(), + locale = language, + categories = categories.map { ArticleCategory(id = it.id, name = it.name) }, + relatedTokens = relatedTokens.map { it.toDomainRelatedToken() }, + isTrending = isTrending, + title = title, + newsUrl = newsUrl, + ) +} + +internal fun NewsRelatedTokenDto.toDomainRelatedToken(): RelatedToken { + return RelatedToken( + id = id, + symbol = symbol, + name = name, + ) +} + +internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle { + return OriginalArticle( + id = id, + title = title, + sourceName = sourceName, + locale = language, + publishedAt = publishedAt, + url = url, + imageUrl = imageUrl, + ) +} \ No newline at end of file diff --git a/domain/news/build.gradle.kts b/domain/news/build.gradle.kts index 7bc8c1d8dc..4af5590248 100644 --- a/domain/news/build.gradle.kts +++ b/domain/news/build.gradle.kts @@ -7,5 +7,6 @@ plugins { dependencies { api(projects.domain.core) api(projects.domain.models) + api(projects.core.pagination) implementation(deps.kotlin.serialization) } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt new file mode 100644 index 0000000000..f9f69c5b22 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.news.model + +import kotlinx.serialization.Serializable + +/** + * Represents config for request of news list. + * +[REDACTED_AUTHOR] + * + * @param language device locale (ex: en, ru). + * @param snapshot id snapshot (`meta.asOf`) to stabilize responses. + * @param tokenIds filter by tokens. + * @param categoryIds filter by category. + */ +@Serializable +data class NewsListConfig( + val language: String, + val snapshot: String, + val tokenIds: List = emptyList(), + val categoryIds: List = emptyList(), +) \ 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 new file mode 100644 index 0000000000..45a04c9d4b --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsTypealiases.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.news.model + +import com.tangem.domain.news.models.ShortArticle +import com.tangem.pagination.BatchFlow +import com.tangem.pagination.BatchingContext + +typealias NewsListBatchingContext = BatchingContext + +typealias NewsListBatchFlow = BatchFlow, Nothing> \ No newline at end of file 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 07e81c249a..c8ab3ccbe5 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 @@ -1,8 +1,41 @@ 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 + /** - * News repository - * [REDACTED_AUTHOR] */ -interface NewsRepository \ No newline at end of file +interface NewsRepository { + + /** + * Returns flow of batch with pagination handling. + * + * @param context pagination context + * @param batchSize page capacity + */ + fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow + + /** + * Returns detailed article by id with locale configuration. + * @param newsId news identification + * @param language current locale + */ + suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle + + /** + * Returns list of trending news by limit and with correct locale. + * + * @param limit + * @param language current device locale + */ + suspend fun getTrendingNews(limit: Int, language: String?): List + + /** + * Returns available categories. + */ + suspend fun getCategories(): List +} \ No newline at end of file