Updated on 2026-08-14
This commit is contained in:
parent
9105ffbd75
commit
dcb8b474cf
20 changed files with 550 additions and 8 deletions
|
|
@ -54,5 +54,9 @@
|
||||||
{
|
{
|
||||||
"name": "ACCOUNTS_FEATURE_ENABLED",
|
"name": "ACCOUNTS_FEATURE_ENABLED",
|
||||||
"version": "undefined"
|
"version": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FEED_ENABLED",
|
||||||
|
"version": "undefined"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ sealed class ApiConfig {
|
||||||
BlockAid,
|
BlockAid,
|
||||||
YieldSupply,
|
YieldSupply,
|
||||||
MoonPay,
|
MoonPay,
|
||||||
|
News,
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeId(): ID {
|
private fun initializeId(): ID {
|
||||||
|
|
@ -41,6 +42,7 @@ sealed class ApiConfig {
|
||||||
is BlockAid -> ID.BlockAid
|
is BlockAid -> ID.BlockAid
|
||||||
is YieldSupply -> ID.YieldSupply
|
is YieldSupply -> ID.YieldSupply
|
||||||
is MoonPay -> ID.MoonPay
|
is MoonPay -> ID.MoonPay
|
||||||
|
is News -> ID.News
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ApiEnvironmentConfig> = 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]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String>? = null,
|
||||||
|
@Query("categoryIds") categoryIds: List<Int>? = null,
|
||||||
|
): ApiResponse<NewsListResponse>
|
||||||
|
|
||||||
|
@GET("$NEWS_PATH/{newsId}")
|
||||||
|
suspend fun getNewsDetails(
|
||||||
|
@Path("newsId") newsId: Int,
|
||||||
|
@Query("lang") language: String? = null,
|
||||||
|
): ApiResponse<NewsDetailsResponse>
|
||||||
|
|
||||||
|
@GET("$NEWS_PATH/trending")
|
||||||
|
suspend fun getTrendingNews(
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("lang") language: String? = null,
|
||||||
|
): ApiResponse<NewsTrendingResponse>
|
||||||
|
|
||||||
|
@GET("$NEWS_PATH/categories")
|
||||||
|
suspend fun getCategories(): ApiResponse<NewsCategoriesResponse>
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
private const val NEWS_PATH = "api/v1/news"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NewsCategoryDto>,
|
||||||
|
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
@ -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<NewsCategoryDto>,
|
||||||
|
)
|
||||||
|
|
@ -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<NewsCategoryDto>,
|
||||||
|
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
|
||||||
|
@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<NewsOriginalArticleDto>,
|
||||||
|
)
|
||||||
|
|
@ -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<NewsArticleDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
@ -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<NewsArticleDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class NewsTrendingMetaDto(
|
||||||
|
@Json(name = "limit") val limit: Int,
|
||||||
|
)
|
||||||
|
|
@ -58,6 +58,10 @@ internal object ApiConfigsModule {
|
||||||
appInfoProvider = appInfoProvider,
|
appInfoProvider = appInfoProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@IntoSet
|
@IntoSet
|
||||||
fun provideYieldSupplyConfig(
|
fun provideYieldSupplyConfig(
|
||||||
|
|
|
||||||
|
|
@ -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.express.TangemExpressApi
|
||||||
import com.tangem.datasource.api.markets.TangemTechMarketsApi
|
import com.tangem.datasource.api.markets.TangemTechMarketsApi
|
||||||
import com.tangem.datasource.api.moonpay.MoonPayApi
|
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.onramp.OnrampApi
|
||||||
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||||
import com.tangem.datasource.api.pay.TangemPayApi
|
import com.tangem.datasource.api.pay.TangemPayApi
|
||||||
|
|
@ -151,4 +152,13 @@ internal object NetworkModule {
|
||||||
applyTimeoutAnnotations = false,
|
applyTimeoutAnnotations = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideNewsApi(retrofitApiBuilder: RetrofitApiBuilder): NewsApi {
|
||||||
|
return retrofitApiBuilder.build(
|
||||||
|
apiConfigId = ApiConfig.ID.News,
|
||||||
|
applyTimeoutAnnotations = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +73,7 @@ class ApiConfigTest {
|
||||||
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
|
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
|
||||||
ApiConfig.ID.MoonPay -> MoonPay()
|
ApiConfig.ID.MoonPay -> MoonPay()
|
||||||
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
|
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
|
||||||
|
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ internal class ProdApiConfigsManagerTest {
|
||||||
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
|
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
|
||||||
ApiConfig.ID.MoonPay -> MoonPay()
|
ApiConfig.ID.MoonPay -> MoonPay()
|
||||||
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider)
|
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.BlockAid -> createBlockAidSdkModel()
|
||||||
ApiConfig.ID.MoonPay -> createMoonPayModel()
|
ApiConfig.ID.MoonPay -> createMoonPayModel()
|
||||||
ApiConfig.ID.P2PEthPool -> createP2PModel()
|
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 {
|
private fun String.checkHeaderValueOrEmpty(): String {
|
||||||
for (i in this.indices) {
|
for (i in this.indices) {
|
||||||
val c = this[i]
|
val c = this[i]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package com.tangem.data.news.di
|
package com.tangem.data.news.di
|
||||||
|
|
||||||
import com.tangem.data.news.repository.DefaultNewsRepository
|
import com.tangem.data.news.repository.DefaultNewsRepository
|
||||||
|
import com.tangem.datasource.api.news.NewsApi
|
||||||
import com.tangem.domain.news.repository.NewsRepository
|
import com.tangem.domain.news.repository.NewsRepository
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -14,7 +16,10 @@ internal object NewsDataModule {
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun providesQuotesRepository(): NewsRepository {
|
fun provideNewsRepository(newsApi: NewsApi, dispatchers: CoroutineDispatcherProvider): NewsRepository {
|
||||||
return DefaultNewsRepository()
|
return DefaultNewsRepository(
|
||||||
|
newsApi = newsApi,
|
||||||
|
dispatchers = dispatchers,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,170 @@
|
||||||
package com.tangem.data.news.repository
|
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.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]
|
[REDACTED_AUTHOR]
|
||||||
*/
|
*/
|
||||||
internal class DefaultNewsRepository : NewsRepository
|
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<ShortArticle> {
|
||||||
|
val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow()
|
||||||
|
return response.items.map { it.toDomainShortArticle() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getCategories(): List<ArticleCategory> {
|
||||||
|
return newsApi.getCategories().getOrThrow().items.map { dto ->
|
||||||
|
ArticleCategory(
|
||||||
|
id = dto.id,
|
||||||
|
name = dto.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBatchFetcher(batchSize: Int): BatchFetcher<NewsListConfig, List<ShortArticle>> {
|
||||||
|
return NewsBatchFetcher(
|
||||||
|
newsApi = newsApi,
|
||||||
|
batchSize = batchSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NewsBatchFetcher(
|
||||||
|
private val newsApi: NewsApi,
|
||||||
|
private val batchSize: Int,
|
||||||
|
) : BatchFetcher<NewsListConfig, List<ShortArticle>> {
|
||||||
|
|
||||||
|
private var state: NewsPaginationState? = null
|
||||||
|
|
||||||
|
override suspend fun fetchFirst(requestParams: NewsListConfig): BatchFetchResult<List<ShortArticle>> {
|
||||||
|
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<List<ShortArticle>>,
|
||||||
|
): BatchFetchResult<List<ShortArticle>> {
|
||||||
|
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<List<ShortArticle>>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,5 +7,6 @@ plugins {
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.domain.core)
|
api(projects.domain.core)
|
||||||
api(projects.domain.models)
|
api(projects.domain.models)
|
||||||
|
api(projects.core.pagination)
|
||||||
implementation(deps.kotlin.serialization)
|
implementation(deps.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
@ -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<String> = emptyList(),
|
||||||
|
val categoryIds: List<Int> = emptyList(),
|
||||||
|
)
|
||||||
|
|
@ -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<Int, NewsListConfig, Nothing>
|
||||||
|
|
||||||
|
typealias NewsListBatchFlow = BatchFlow<Int, List<ShortArticle>, Nothing>
|
||||||
|
|
@ -1,8 +1,41 @@
|
||||||
package com.tangem.domain.news.repository
|
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]
|
[REDACTED_AUTHOR]
|
||||||
*/
|
*/
|
||||||
interface NewsRepository
|
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<ShortArticle>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available categories.
|
||||||
|
*/
|
||||||
|
suspend fun getCategories(): List<ArticleCategory>
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue