Updated on 2026-08-14
This commit is contained in:
parent
028b21b112
commit
348d750054
17 changed files with 698 additions and 300 deletions
|
|
@ -13,20 +13,13 @@ 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.repository.NewsRepository
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
import com.tangem.pagination.BatchListSource
|
||||
import com.tangem.pagination.*
|
||||
import com.tangem.pagination.exception.EndOfPaginationException
|
||||
import com.tangem.pagination.fetcher.BatchFetcher
|
||||
import com.tangem.pagination.toBatchFlow
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
|
@ -42,12 +35,14 @@ internal class DefaultNewsRepository(
|
|||
) : NewsRepository {
|
||||
|
||||
override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow {
|
||||
return BatchListSource(
|
||||
val newsBatchFlow = BatchListSource(
|
||||
fetchDispatcher = dispatchers.io,
|
||||
context = context,
|
||||
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY },
|
||||
batchFetcher = createBatchFetcher(batchSize),
|
||||
).toBatchFlow()
|
||||
|
||||
return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope)
|
||||
}
|
||||
|
||||
override suspend fun getNews(config: NewsListConfig, limit: Int): List<ShortArticle> {
|
||||
|
|
@ -115,11 +110,13 @@ internal class DefaultNewsRepository(
|
|||
}
|
||||
|
||||
override suspend fun getCategories(): List<ArticleCategory> {
|
||||
return newsApi.getCategories().getOrThrow().items.map { dto ->
|
||||
ArticleCategory(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
)
|
||||
return withContext(dispatchers.io) {
|
||||
newsApi.getCategories().getOrThrow().items.map { dto ->
|
||||
ArticleCategory(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +124,38 @@ internal class DefaultNewsRepository(
|
|||
newsViewedStore.updateViewed(articleIds, viewed)
|
||||
}
|
||||
|
||||
private fun updateViewedStatusForNewsBatch(
|
||||
newsBatchFlow: NewsListBatchFlow,
|
||||
scope: CoroutineScope,
|
||||
): NewsListBatchFlow {
|
||||
return object : NewsListBatchFlow {
|
||||
override val state: StateFlow<BatchListState<Int, List<ShortArticle>>> =
|
||||
combine(
|
||||
newsBatchFlow.state,
|
||||
newsViewedStore.getAll(),
|
||||
) { batchListState, viewedFlags ->
|
||||
val updatedBatches = batchListState.data.map { batch ->
|
||||
val updatedArticles = batch.data.map { article ->
|
||||
val isViewed = viewedFlags[article.id] == true
|
||||
article.copy(viewed = isViewed)
|
||||
}
|
||||
Batch(key = batch.key, data = updatedArticles)
|
||||
}
|
||||
BatchListState(
|
||||
data = updatedBatches,
|
||||
status = batchListState.status,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = BatchListState(emptyList(), newsBatchFlow.state.value.status),
|
||||
)
|
||||
|
||||
override val updateResults: SharedFlow<Pair<Nothing, BatchUpdateResult<Int, List<ShortArticle>>>> =
|
||||
newsBatchFlow.updateResults
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchDetailedArticlesInternal(newsIds: Collection<Int>, language: String?) =
|
||||
withContext(dispatchers.io) {
|
||||
if (newsIds.isEmpty()) return@withContext
|
||||
|
|
@ -141,7 +170,7 @@ internal class DefaultNewsRepository(
|
|||
|
||||
if (idsToFetch.isEmpty()) return@withContext
|
||||
|
||||
val fetchedArticles = coroutineScope {
|
||||
val fetchedArticles = supervisorScope {
|
||||
idsToFetch.map { newsId ->
|
||||
async {
|
||||
newsApi.getNewsDetails(newsId = newsId, language = language)
|
||||
|
|
@ -160,13 +189,12 @@ internal class DefaultNewsRepository(
|
|||
|
||||
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) {
|
||||
return withContext(dispatchers.io) {
|
||||
val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)
|
||||
when (val result = apiResponse) {
|
||||
when (val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)) {
|
||||
is ApiResponse.Error -> {
|
||||
Timber.e(
|
||||
result.cause.cause,
|
||||
apiResponse.cause.cause,
|
||||
"Trending news fetch failed cause: ${
|
||||
when (val error = result.cause) {
|
||||
when (val error = apiResponse.cause) {
|
||||
is ApiResponseError.HttpException -> error.code
|
||||
is ApiResponseError.NetworkException -> "NetworkException"
|
||||
is ApiResponseError.TimeoutException -> "TimeoutException"
|
||||
|
|
@ -179,14 +207,14 @@ internal class DefaultNewsRepository(
|
|||
key = TRENDING_NEWS_KEY,
|
||||
value = TrendingNews.Error(
|
||||
NewsError.Unknown(
|
||||
message = result.cause.message,
|
||||
message = apiResponse.cause.message,
|
||||
code = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
is ApiResponse.Success<NewsTrendingResponse> -> {
|
||||
val freshArticles = result.data.items.map { it.toDomainShortArticle() }
|
||||
val freshArticles = apiResponse.data.items.map { it.toDomainShortArticle() }
|
||||
val articles = freshArticles.take(limit)
|
||||
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles))
|
||||
TrendingNews.Data(articles)
|
||||
|
|
@ -271,7 +299,7 @@ internal class DefaultNewsRepository(
|
|||
page = page,
|
||||
limit = limit,
|
||||
language = params.language,
|
||||
snapshot = snapshotOverride,
|
||||
snapshot = snapshotOverride?.takeIf { it.isNotEmpty() },
|
||||
tokenIds = params.tokenIds.takeIf { it.isNotEmpty() },
|
||||
categoryIds = params.categoryIds.takeIf { it.isNotEmpty() },
|
||||
).getOrThrow()
|
||||
|
|
|
|||
|
|
@ -78,7 +78,12 @@ internal class FeedEntryChildFactory @Inject constructor(
|
|||
DefaultNewsListComponent(
|
||||
appComponentContext = appComponentContext,
|
||||
params = DefaultNewsListComponent.Params(
|
||||
onArticleClicked = { feedEntryClickIntents.onArticleClick(articleId = it) },
|
||||
onArticleClicked = { currentArticle, prefetchedArticles ->
|
||||
feedEntryClickIntents.onArticleClick(
|
||||
articleId = currentArticle,
|
||||
preselectedArticlesId = prefetchedArticles,
|
||||
)
|
||||
},
|
||||
onBackClick = onBackClicked,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ internal class DefaultNewsListComponent(
|
|||
|
||||
@Serializable
|
||||
data class Params(
|
||||
val onArticleClicked: (Int) -> Unit,
|
||||
val onArticleClicked: (currentArticle: Int, prefetchedArticles: List<Int>) -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.tangem.features.feed.model.converter
|
||||
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class BatchListStateManager<Key, Domain, UI>(
|
||||
private val converter: BatchItemConverter<Domain, UI>,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
val state = MutableStateFlow(BatchListState<Key, Domain, UI>())
|
||||
|
||||
suspend fun update(newList: List<Batch<Key, List<Domain>>>, forceUpdate: Boolean) =
|
||||
withContext(dispatchers.default) {
|
||||
state.update { currentState ->
|
||||
val uiBatches = currentState.uiBatches
|
||||
val previousList = currentState.processedItems
|
||||
|
||||
if (newList.isEmpty()) {
|
||||
return@update BatchListState(uiBatches = emptyList(), processedItems = emptyList())
|
||||
}
|
||||
|
||||
val isInitialLoading = forceUpdate ||
|
||||
previousList.isNullOrEmpty() ||
|
||||
newList.firstOrNull()?.key != previousList.firstOrNull()?.key
|
||||
|
||||
val outItems = if (isInitialLoading) {
|
||||
newList.map { batch ->
|
||||
Batch(key = batch.key, data = batch.data.map { converter.convert(it) })
|
||||
}
|
||||
} else {
|
||||
if (previousList.size != newList.size) {
|
||||
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
|
||||
val newBatches = newList.filter { keysToAdd.contains(it.key) }
|
||||
|
||||
uiBatches + newBatches.map { batch ->
|
||||
Batch(key = batch.key, data = batch.data.map { converter.convert(it) })
|
||||
}
|
||||
} else {
|
||||
uiBatches.mapIndexed { batchIndex, batch ->
|
||||
val prevBatch = previousList[batchIndex]
|
||||
val newBatch = newList[batchIndex]
|
||||
if (prevBatch == newBatch) return@mapIndexed batch
|
||||
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = batch.data.mapIndexed { index, currentUiItem ->
|
||||
val prevItem = prevBatch.data.getOrNull(index)
|
||||
val newItem = newBatch.data.getOrNull(index)
|
||||
|
||||
if (prevItem != null && newItem != null) {
|
||||
converter.update(prevItem, currentUiItem, newItem)
|
||||
} else if (newItem != null) {
|
||||
converter.convert(newItem)
|
||||
} else {
|
||||
currentUiItem
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coroutineContext.ensureActive()
|
||||
|
||||
BatchListState(
|
||||
uiBatches = outItems,
|
||||
processedItems = newList,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class BatchListState<K, Domain, UI>(
|
||||
val uiBatches: List<Batch<K, List<UI>>> = emptyList(),
|
||||
val processedItems: List<Batch<K, List<Domain>>>? = emptyList(),
|
||||
)
|
||||
|
||||
internal interface BatchItemConverter<Domain, UI> {
|
||||
fun convert(item: Domain): UI
|
||||
|
||||
fun update(prevDomain: Domain, currentUI: UI, newDomain: Domain): UI {
|
||||
return convert(newDomain)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <K, T> Flow<List<Batch<K, List<T>>>>.distinctBatchesContent(): Flow<List<Batch<K, List<T>>>> {
|
||||
return this.distinctUntilChanged { old, new ->
|
||||
old.size == new.size &&
|
||||
old.map { it.key } == new.map { it.key } &&
|
||||
old.flatMap { it.data } == new.flatMap { it.data }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.feed.model.market.details.converter
|
||||
package com.tangem.features.feed.model.converter
|
||||
|
||||
import com.tangem.common.ui.news.ArticleConfigUM
|
||||
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
|
||||
|
|
@ -8,12 +8,16 @@ import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.features.feed.ui.utils.mapFormattedDate
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
|
||||
class RelatedNewsConverter : Converter<List<ShortArticle>, ImmutableList<ArticleConfigUM>> {
|
||||
internal class ShortArticleToArticleConfigUMConverter(
|
||||
private val isTrending: Provider<Boolean>,
|
||||
) : Converter<List<ShortArticle>, ImmutableList<ArticleConfigUM>> {
|
||||
|
||||
override fun convert(value: List<ShortArticle>): ImmutableList<ArticleConfigUM> {
|
||||
return value.map { shortArticle ->
|
||||
|
|
@ -21,7 +25,7 @@ class RelatedNewsConverter : Converter<List<ShortArticle>, ImmutableList<Article
|
|||
id = shortArticle.id,
|
||||
title = shortArticle.title,
|
||||
score = shortArticle.score,
|
||||
isTrending = false,
|
||||
isTrending = isTrending(),
|
||||
tags = buildArticleTags(shortArticle),
|
||||
createdAt = mapFormattedDate(shortArticle.createdAt),
|
||||
isViewed = shortArticle.viewed,
|
||||
|
|
@ -29,7 +33,7 @@ class RelatedNewsConverter : Converter<List<ShortArticle>, ImmutableList<Article
|
|||
}.toPersistentList()
|
||||
}
|
||||
|
||||
private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet<LabelUM> {
|
||||
private fun buildArticleTags(article: ShortArticle): ImmutableSet<LabelUM> {
|
||||
val categoryLabels = article.categories.map { category ->
|
||||
LabelUM(text = TextReference.Str(category.name))
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
|
|||
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
|
||||
import com.tangem.features.feed.components.feed.DefaultFeedComponent
|
||||
import com.tangem.features.feed.impl.R
|
||||
import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager
|
||||
import com.tangem.features.feed.model.feed.state.TrendingNewsStateFactory
|
||||
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
|
||||
import com.tangem.features.feed.ui.feed.state.*
|
||||
import com.tangem.utils.Provider
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
package com.tangem.features.feed.ui.feed.state
|
||||
package com.tangem.features.feed.model.feed.state
|
||||
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.*
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
|
||||
import com.tangem.features.feed.model.converter.*
|
||||
import com.tangem.features.feed.model.market.list.state.MarketsListUM
|
||||
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
|
|
@ -18,8 +17,11 @@ import com.tangem.utils.coroutines.saveIn
|
|||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class FeedMarketsBatchFlowManager(
|
||||
|
|
@ -44,7 +46,7 @@ internal class FeedMarketsBatchFlowManager(
|
|||
}
|
||||
}.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Companion.Eagerly,
|
||||
started = Eagerly,
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
|
|
@ -60,7 +62,7 @@ internal class FeedMarketsBatchFlowManager(
|
|||
}
|
||||
}.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Companion.Eagerly,
|
||||
started = Eagerly,
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
|
|
@ -76,7 +78,7 @@ internal class FeedMarketsBatchFlowManager(
|
|||
}
|
||||
}.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Companion.Eagerly,
|
||||
started = Eagerly,
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
|
|
@ -141,11 +143,29 @@ internal class FeedMarketsBatchFlowManager(
|
|||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
private val updateStateJob = JobHolder()
|
||||
private val resultBatches = MutableStateFlow(ResultBatches())
|
||||
private val uiBatches = resultBatches.map { it.uiBatches }
|
||||
|
||||
private val batchConverter = object : BatchItemConverter<TokenMarket, MarketsListItemUM> {
|
||||
|
||||
private val internalConverter: MarketsTokenItemConverter
|
||||
get() = MarketsTokenItemConverter(
|
||||
currentTrendInterval = MarketsListUM.TrendInterval.H24,
|
||||
appCurrency = currentAppCurrency(),
|
||||
)
|
||||
|
||||
override fun convert(item: TokenMarket) = internalConverter.convert(item)
|
||||
|
||||
override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) =
|
||||
internalConverter.update(prevDomain, currentUI, newDomain)
|
||||
}
|
||||
|
||||
private val stateManager = BatchListStateManager<Int, TokenMarket, MarketsListItemUM>(
|
||||
converter = batchConverter,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>> =
|
||||
uiBatches
|
||||
stateManager.state
|
||||
.map { it.uiBatches }
|
||||
.map { batches ->
|
||||
batches.asSequence()
|
||||
.map { it.data }
|
||||
|
|
@ -155,7 +175,7 @@ internal class FeedMarketsBatchFlowManager(
|
|||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Companion.Eagerly,
|
||||
started = Eagerly,
|
||||
initialValue = persistentListOf(),
|
||||
)
|
||||
|
||||
|
|
@ -170,7 +190,7 @@ internal class FeedMarketsBatchFlowManager(
|
|||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Companion.Eagerly,
|
||||
started = Eagerly,
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
|
|
@ -185,7 +205,7 @@ internal class FeedMarketsBatchFlowManager(
|
|||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Companion.Eagerly,
|
||||
started = Eagerly,
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
|
|
@ -210,15 +230,11 @@ internal class FeedMarketsBatchFlowManager(
|
|||
init {
|
||||
batchFlow.state
|
||||
.map { it.data }
|
||||
.distinctUntilChanged { a, b ->
|
||||
a.size == b.size &&
|
||||
a.map { it.key } == b.map { it.key } &&
|
||||
a.map { it.data }.flatten() == b.map { it.data }.flatten()
|
||||
}
|
||||
.onEach {
|
||||
.distinctBatchesContent()
|
||||
.onEach { newList ->
|
||||
coroutineScope {
|
||||
launch {
|
||||
updateState(it)
|
||||
stateManager.update(newList = newList, forceUpdate = false)
|
||||
}.saveIn(updateStateJob)
|
||||
}
|
||||
}
|
||||
|
|
@ -226,77 +242,9 @@ internal class FeedMarketsBatchFlowManager(
|
|||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
|
||||
withContext(dispatchers.default) {
|
||||
resultBatches.update { resultBatches ->
|
||||
val items = resultBatches.uiBatches
|
||||
val previousList = resultBatches.processedItems
|
||||
|
||||
val converter = MarketsTokenItemConverter(
|
||||
currentTrendInterval = MarketsListUM.TrendInterval.H24,
|
||||
appCurrency = currentAppCurrency(),
|
||||
)
|
||||
|
||||
if (newList.isEmpty()) {
|
||||
return@update ResultBatches(processedItems = emptyList())
|
||||
}
|
||||
|
||||
val isInitialLoading =
|
||||
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
|
||||
|
||||
val outItems = if (isInitialLoading) {
|
||||
newList.map { batch ->
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = converter.convertList(batch.data),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// As nextBatchSize = 0, we only have one batch, but keep the logic for safety
|
||||
if (previousList.size != newList.size) {
|
||||
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
|
||||
val newBatches = newList.filter { keysToAdd.contains(it.key) }
|
||||
|
||||
items + newBatches.map { batch ->
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = converter.convertList(batch.data),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items.mapIndexed { batchIndex, batch ->
|
||||
val prevBatch = previousList[batchIndex]
|
||||
val newBatch = newList[batchIndex]
|
||||
if (prevBatch == newBatch) return@mapIndexed batch
|
||||
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = batch.data.mapIndexed { index, marketsListItemUM ->
|
||||
val prevItem = prevBatch.data.getOrNull(index)
|
||||
val newItem = newBatch.data.getOrNull(index)
|
||||
if (prevItem != null && newItem != null) {
|
||||
converter.update(prevItem, marketsListItemUM, newItem)
|
||||
} else {
|
||||
newItem?.let { converter.convert(it) } ?: marketsListItemUM
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
ResultBatches(
|
||||
uiBatches = outItems,
|
||||
processedItems = newList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun reload(fiatPriceCurrency: String) {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
resultBatches.value = ResultBatches()
|
||||
stateManager.state.value = BatchListState()
|
||||
actionsFlow.emit(
|
||||
BatchAction.Reload(
|
||||
requestParams = TokenMarketListConfig(
|
||||
|
|
@ -366,16 +314,11 @@ internal class FeedMarketsBatchFlowManager(
|
|||
}
|
||||
|
||||
fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? {
|
||||
return resultBatches.value.processedItems
|
||||
return stateManager.state.value.processedItems
|
||||
?.asSequence()
|
||||
?.flatMap { it.data }
|
||||
?.firstOrNull { it.id == tokenId }
|
||||
}
|
||||
|
||||
private data class ResultBatches(
|
||||
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
|
||||
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TokenMarketListConfig.Order.toSortByTypeUM(): SortByTypeUM {
|
||||
|
|
@ -1,19 +1,14 @@
|
|||
package com.tangem.features.feed.ui.feed.state
|
||||
package com.tangem.features.feed.model.feed.state
|
||||
|
||||
import com.tangem.common.ui.news.ArticleConfigUM
|
||||
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
|
||||
import com.tangem.core.ui.components.label.entity.LabelUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.models.news.TrendingNews
|
||||
import com.tangem.features.feed.ui.utils.mapFormattedDate
|
||||
import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter
|
||||
import com.tangem.features.feed.ui.feed.state.FeedListUM
|
||||
import com.tangem.features.feed.ui.feed.state.NewsUM
|
||||
import com.tangem.features.feed.ui.feed.state.NewsUMState
|
||||
import com.tangem.utils.Provider
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
|
||||
internal class TrendingNewsStateFactory(
|
||||
private val currentStateProvider: Provider<FeedListUM>,
|
||||
|
|
@ -31,7 +26,9 @@ internal class TrendingNewsStateFactory(
|
|||
|
||||
private fun handleDataState(currentState: FeedListUM, articles: List<ShortArticle>) {
|
||||
val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles)
|
||||
val commonArticlesUM = commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList()
|
||||
val commonArticlesUM = getShortArticleConfigConverter(isTrending = false)
|
||||
.convert(commonArticles)
|
||||
.toPersistentList()
|
||||
val updatedNews = when (currentState.news.newsUMState) {
|
||||
NewsUMState.CONTENT -> currentState.news.copy(content = commonArticlesUM)
|
||||
NewsUMState.LOADING,
|
||||
|
|
@ -45,7 +42,10 @@ internal class TrendingNewsStateFactory(
|
|||
|
||||
onStateUpdate(
|
||||
currentState.copy(
|
||||
trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) },
|
||||
trendingArticle = trendingArticle?.let { article ->
|
||||
getShortArticleConfigConverter(isTrending = true)
|
||||
.convert(listOf(article))
|
||||
}?.firstOrNull(),
|
||||
news = updatedNews,
|
||||
),
|
||||
)
|
||||
|
|
@ -77,32 +77,7 @@ internal class TrendingNewsStateFactory(
|
|||
}
|
||||
}
|
||||
|
||||
private fun mapToArticleConfigUM(article: ShortArticle, isTrending: Boolean): ArticleConfigUM {
|
||||
return ArticleConfigUM(
|
||||
id = article.id,
|
||||
title = article.title,
|
||||
score = article.score,
|
||||
isTrending = isTrending,
|
||||
tags = buildArticleTags(article),
|
||||
createdAt = mapFormattedDate(article.createdAt),
|
||||
isViewed = article.viewed,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildArticleTags(article: ShortArticle): ImmutableSet<LabelUM> {
|
||||
val categoryLabels = article.categories.map { category ->
|
||||
LabelUM(text = TextReference.Str(category.name))
|
||||
}
|
||||
val tokenLabels = article.relatedTokens.map { token ->
|
||||
LabelUM(
|
||||
text = TextReference.Str(token.symbol),
|
||||
leadingContent = LabelLeadingContentUM.Token(
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(
|
||||
tokenId = CryptoCurrency.RawID(token.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return (categoryLabels + tokenLabels).toPersistentSet()
|
||||
private fun getShortArticleConfigConverter(isTrending: Boolean): ShortArticleToArticleConfigUMConverter {
|
||||
return ShortArticleToArticleConfigUMConverter(isTrending = Provider { isTrending })
|
||||
}
|
||||
}
|
||||
|
|
@ -40,11 +40,11 @@ import com.tangem.features.feed.impl.R
|
|||
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
|
||||
import com.tangem.features.feed.model.market.details.converter.DescriptionConverter
|
||||
import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter
|
||||
import com.tangem.features.feed.model.market.details.converter.RelatedNewsConverter
|
||||
import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoConverter
|
||||
import com.tangem.features.feed.model.market.details.formatter.*
|
||||
import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater
|
||||
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
|
||||
import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter
|
||||
import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent
|
||||
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
|
|
@ -143,8 +143,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
// ==================
|
||||
)
|
||||
|
||||
private val relatedNewsConverter by lazy {
|
||||
RelatedNewsConverter()
|
||||
private val shortArticleToArticleConfigUMConverter by lazy {
|
||||
ShortArticleToArticleConfigUMConverter(isTrending = Provider { false })
|
||||
}
|
||||
|
||||
private val descriptionConverter = DescriptionConverter(
|
||||
|
|
@ -311,7 +311,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
),
|
||||
).onRight { articles ->
|
||||
state.update { marketsTokenDetailsUM ->
|
||||
val relatedNews = relatedNewsConverter.convert(articles)
|
||||
val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles)
|
||||
marketsTokenDetailsUM.copy(
|
||||
relatedNews = marketsTokenDetailsUM.relatedNews.copy(
|
||||
articles = relatedNews,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import com.tangem.common.ui.markets.models.MarketsListItemUM
|
|||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.*
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.feed.model.converter.BatchItemConverter
|
||||
import com.tangem.features.feed.model.converter.BatchListStateManager
|
||||
import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
|
||||
import com.tangem.features.feed.model.converter.distinctBatchesContent
|
||||
import com.tangem.features.feed.model.market.list.state.MarketsListUM
|
||||
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
|
||||
import com.tangem.features.feed.model.market.list.utils.logAction
|
||||
import com.tangem.features.feed.model.market.list.utils.logStatus
|
||||
import com.tangem.features.feed.model.market.list.utils.logUpdateResults
|
||||
import com.tangem.features.feed.model.market.list.state.MarketsListUM
|
||||
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
|
|
@ -48,8 +51,27 @@ internal class MarketsListBatchFlowManager(
|
|||
batchFlowType = batchFlowType,
|
||||
)
|
||||
|
||||
private val batchConverter = object : BatchItemConverter<TokenMarket, MarketsListItemUM> {
|
||||
|
||||
private val internalConverter: MarketsTokenItemConverter
|
||||
get() = MarketsTokenItemConverter(
|
||||
currentTrendInterval = currentTrendInterval(), // Теперь тут всегда актуальное значение
|
||||
appCurrency = currentAppCurrency(),
|
||||
)
|
||||
|
||||
override fun convert(item: TokenMarket) = internalConverter.convert(item)
|
||||
|
||||
override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) =
|
||||
internalConverter.update(prevDomain, currentUI, newDomain)
|
||||
}
|
||||
|
||||
private val stateManager = BatchListStateManager<Int, TokenMarket, MarketsListItemUM>(
|
||||
converter = batchConverter,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
private val resultBatches = MutableStateFlow(ResultBatches())
|
||||
private val uiBatches = resultBatches.map { it.uiBatches }
|
||||
private val uiBatches = stateManager.state.map { it.uiBatches }
|
||||
|
||||
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
|
||||
get() = uiBatches
|
||||
|
|
@ -128,15 +150,11 @@ internal class MarketsListBatchFlowManager(
|
|||
init {
|
||||
batchFlow.state
|
||||
.map { it.data }
|
||||
.distinctUntilChanged { a, b ->
|
||||
a.size == b.size &&
|
||||
a.map { it.key } == b.map { it.key } &&
|
||||
a.map { it.data }.flatten() == b.map { it.data }.flatten()
|
||||
}
|
||||
.onEach {
|
||||
.distinctBatchesContent()
|
||||
.onEach { newList ->
|
||||
coroutineScope {
|
||||
launch {
|
||||
updateState(it)
|
||||
stateManager.update(newList = newList, forceUpdate = false)
|
||||
}.saveIn(updateStateJob)
|
||||
}
|
||||
}
|
||||
|
|
@ -159,70 +177,12 @@ internal class MarketsListBatchFlowManager(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
|
||||
withContext(dispatchers.default) {
|
||||
resultBatches.update { resultBatches ->
|
||||
val items = resultBatches.uiBatches
|
||||
val previousList = resultBatches.processedItems
|
||||
|
||||
val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency())
|
||||
|
||||
if (newList.isEmpty()) {
|
||||
return@update ResultBatches(processedItems = emptyList())
|
||||
}
|
||||
|
||||
val isInitialLoading =
|
||||
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
|
||||
|
||||
val outItems = if (isInitialLoading) {
|
||||
newList.map { batch ->
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = converter.convertList(batch.data),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (previousList.size != newList.size) {
|
||||
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
|
||||
val newBatches = newList.filter { keysToAdd.contains(it.key) }
|
||||
|
||||
items + newBatches.map { batch ->
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = converter.convertList(batch.data),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items.mapIndexed { batchIndex, batch ->
|
||||
val prevBatch = previousList[batchIndex]
|
||||
val newBatch = newList[batchIndex]
|
||||
if (previousList == newBatch) return@mapIndexed batch
|
||||
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = batch.data.mapIndexed { index, marketsListItemUM ->
|
||||
val prevItem = prevBatch.data[index]
|
||||
val newItem = newBatch.data[index]
|
||||
|
||||
converter.update(
|
||||
prevItem,
|
||||
marketsListItemUM,
|
||||
newItem,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
ResultBatches(
|
||||
uiBatches = outItems,
|
||||
processedItems = newList,
|
||||
)
|
||||
}
|
||||
}
|
||||
fun updateUIWithSameState() {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
val current = batchFlow.state.value.data
|
||||
stateManager.update(current, forceUpdate = true)
|
||||
}.saveIn(updateStateJob)
|
||||
}
|
||||
|
||||
fun reload(searchText: String? = null) {
|
||||
modelScope.launch {
|
||||
|
|
@ -250,13 +210,6 @@ internal class MarketsListBatchFlowManager(
|
|||
}
|
||||
}
|
||||
|
||||
fun updateUIWithSameState() {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
val current = batchFlow.state.value.data
|
||||
updateState(current, forceUpdate = true)
|
||||
}.saveIn(updateStateJob)
|
||||
}
|
||||
|
||||
fun loadCharts(batchKeys: Set<Int>, interval: MarketsListUM.TrendInterval) {
|
||||
if (batchKeys.isEmpty()) return
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.features.feed.model.news.details
|
|||
|
||||
import androidx.compose.runtime.Stable
|
||||
import arrow.core.getOrElse
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
|
|
@ -12,16 +13,16 @@ import com.tangem.domain.appcurrency.model.AppCurrency
|
|||
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
|
||||
import com.tangem.domain.markets.GetTokenPriceChartUseCase
|
||||
import com.tangem.domain.markets.PriceChangeInterval
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.news.RelatedToken
|
||||
import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase
|
||||
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase
|
||||
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
|
||||
import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter
|
||||
import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter
|
||||
import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter
|
||||
import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.domain.models.news.RelatedToken
|
||||
import com.tangem.features.feed.ui.news.details.state.ArticleUM
|
||||
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
|
||||
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
|
||||
|
|
@ -49,6 +50,7 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
private val shareManager: ShareManager,
|
||||
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
|
||||
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
|
||||
private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
|
|
@ -101,7 +103,10 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
private fun onArticleIndexChanged(newIndex: Int) {
|
||||
stateFactory.updateSelectedArticleIndex(newIndex)
|
||||
val currentArticle = state.value.articles.getOrNull(newIndex)
|
||||
currentArticle?.let(::loadRelatedTokens)
|
||||
currentArticle?.let { article ->
|
||||
markArticleAsViewed(article.id)
|
||||
loadRelatedTokens(article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePreselectedArticles() {
|
||||
|
|
@ -126,8 +131,6 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
.onEach { articles ->
|
||||
val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0
|
||||
stateFactory.updateArticles(articles, selectedIndex)
|
||||
val currentArticle = articles.getOrNull(selectedIndex)
|
||||
currentArticle?.let(::loadRelatedTokens)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
|
@ -255,6 +258,12 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun markArticleAsViewed(articleId: Int) {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
markArticleAsViewedUseCase.markAsViewed(articleId)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val RELATED_TOKEN_MAX_COUNT = 5
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
package com.tangem.features.feed.model.news.list
|
||||
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.components.chip.entity.ChipUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase
|
||||
import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
|
||||
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
|
||||
import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager
|
||||
import com.tangem.features.feed.ui.news.list.state.NewsListState
|
||||
import com.tangem.features.feed.ui.news.list.state.NewsListUM
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.SupportedLanguages
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
|
@ -22,24 +31,57 @@ import javax.inject.Inject
|
|||
internal class NewsListModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase,
|
||||
private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<DefaultNewsListComponent.Params>()
|
||||
private val selectedCategoryId = MutableStateFlow<Int?>(null)
|
||||
private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode()
|
||||
|
||||
private val batchFlowManager by lazy {
|
||||
NewsListBatchFlowManager(
|
||||
getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase,
|
||||
currentLanguage = Provider { currentLanguage },
|
||||
currentCategoryIds = Provider {
|
||||
selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty()
|
||||
},
|
||||
modelScope = modelScope,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
NewsListUM(
|
||||
selectedCategoryId = 0,
|
||||
selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID,
|
||||
filters = persistentListOf(),
|
||||
articles = persistentListOf(),
|
||||
onArticleClick = params.onArticleClicked,
|
||||
newsListState = NewsListState.Loading,
|
||||
listOfArticles = persistentListOf(),
|
||||
onArticleClick = { articleId ->
|
||||
params.onArticleClicked(
|
||||
/* currentArticle */ articleId,
|
||||
/* prefetchedArticles */ getCurrentFetchedArticlesIds(),
|
||||
)
|
||||
},
|
||||
onBackClick = params.onBackClick,
|
||||
),
|
||||
)
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCategories()
|
||||
observeNewsList()
|
||||
batchFlowManager.reload()
|
||||
}
|
||||
|
||||
private fun loadCategories() {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
val allCategoriesChip = ChipUM(
|
||||
id = DEFAULT_ALL_NEWS_CATEGORIES_ID,
|
||||
text = TextReference.Res(R.string.news_all_news),
|
||||
isSelected = true,
|
||||
onClick = { onCategoryClick(DEFAULT_ALL_NEWS_CATEGORIES_ID) },
|
||||
)
|
||||
val filterChips = getNewsCategoriesUseCase
|
||||
.invoke()
|
||||
.fold(
|
||||
|
|
@ -47,7 +89,7 @@ internal class NewsListModel @Inject constructor(
|
|||
persistentListOf()
|
||||
},
|
||||
ifRight = { categories ->
|
||||
categories.map { articleCategory ->
|
||||
(listOf(allCategoriesChip) + categories.map { articleCategory ->
|
||||
ChipUM(
|
||||
id = articleCategory.id,
|
||||
text = TextReference.Str(articleCategory.name),
|
||||
|
|
@ -56,7 +98,7 @@ internal class NewsListModel @Inject constructor(
|
|||
onCategoryClick(articleCategory.id)
|
||||
},
|
||||
)
|
||||
}.toImmutableList()
|
||||
}).toPersistentList()
|
||||
},
|
||||
)
|
||||
_state.update { currentState ->
|
||||
|
|
@ -65,18 +107,68 @@ internal class NewsListModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun onCategoryClick(categoryId: Int) {
|
||||
_state.update { currentState ->
|
||||
currentState.copy(
|
||||
selectedCategoryId = categoryId,
|
||||
filters = updateFilterChips(categoryId),
|
||||
)
|
||||
private fun observeNewsList() {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
combine(
|
||||
batchFlowManager.uiItems,
|
||||
batchFlowManager.isInInitialLoadingErrorState,
|
||||
batchFlowManager.paginationStatus,
|
||||
) { articles, isError, paginationStatus ->
|
||||
when {
|
||||
isError -> NewsListState.LoadingError(
|
||||
onRetryClicked = {
|
||||
loadCategories()
|
||||
batchFlowManager.reload()
|
||||
},
|
||||
) to persistentListOf()
|
||||
paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> {
|
||||
NewsListState.Loading to persistentListOf()
|
||||
}
|
||||
articles.isEmpty() -> {
|
||||
NewsListState.Loading to persistentListOf()
|
||||
}
|
||||
else -> {
|
||||
NewsListState.Content(loadMore = { batchFlowManager.loadMore() }) to articles
|
||||
}
|
||||
}
|
||||
}.collect { (listState, articles) ->
|
||||
_state.update { currentState ->
|
||||
currentState.copy(
|
||||
listOfArticles = articles,
|
||||
newsListState = listState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFilterChips(categoryId: Int): ImmutableList<ChipUM> {
|
||||
private fun onCategoryClick(categoryId: Int) {
|
||||
val newCategoryId = if (state.value.selectedCategoryId == categoryId) {
|
||||
DEFAULT_ALL_NEWS_CATEGORIES_ID
|
||||
} else {
|
||||
categoryId
|
||||
}
|
||||
selectedCategoryId.value = newCategoryId
|
||||
_state.update { currentState ->
|
||||
currentState.copy(
|
||||
selectedCategoryId = newCategoryId,
|
||||
filters = updateFilterChips(newCategoryId),
|
||||
)
|
||||
}
|
||||
batchFlowManager.reload()
|
||||
}
|
||||
|
||||
private fun updateFilterChips(categoryId: Int?): ImmutableList<ChipUM> {
|
||||
return state.value.filters.map { chip ->
|
||||
chip.copy(isSelected = chip.id == categoryId)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
private fun getCurrentFetchedArticlesIds(): List<Int> {
|
||||
return state.value.listOfArticles.map { it.id }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package com.tangem.features.feed.model.news.list.statemanager
|
||||
|
||||
import com.tangem.common.ui.news.ArticleConfigUM
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.news.model.NewsListBatchingContext
|
||||
import com.tangem.domain.news.model.NewsListConfig
|
||||
import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
|
||||
import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter
|
||||
import com.tangem.features.feed.model.converter.distinctBatchesContent
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class NewsListBatchFlowManager(
|
||||
getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase,
|
||||
private val currentLanguage: Provider<String>,
|
||||
private val currentCategoryIds: Provider<List<Int>>,
|
||||
private val modelScope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
private val actionsFlow = MutableSharedFlow<BatchAction<Int, NewsListConfig, Nothing>>()
|
||||
private val converter by lazy {
|
||||
ShortArticleToArticleConfigUMConverter(isTrending = Provider { false })
|
||||
}
|
||||
|
||||
private val batchFlow = getNewsListBatchFlowUseCase(
|
||||
context = NewsListBatchingContext(
|
||||
actionsFlow = actionsFlow,
|
||||
coroutineScope = modelScope,
|
||||
),
|
||||
batchSize = DEFAULT_BATCH_SIZE,
|
||||
)
|
||||
|
||||
private val resultBatches = MutableStateFlow<List<Batch<Int, List<ArticleConfigUM>>>>(emptyList())
|
||||
|
||||
val uiItems: StateFlow<ImmutableList<ArticleConfigUM>>
|
||||
get() = batchFlow.state
|
||||
.map { batchListState ->
|
||||
batchListState.data
|
||||
.flatMap { batch -> batch.data }
|
||||
.let { articles -> converter.convert(articles) }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = persistentListOf(),
|
||||
)
|
||||
|
||||
val isInInitialLoadingErrorState = batchFlow.state
|
||||
.map { it.status is PaginationStatus.InitialLoadingError }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
val paginationStatus: StateFlow<PaginationStatus<List<ShortArticle>>>
|
||||
get() = batchFlow.state
|
||||
.map { it.status }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = PaginationStatus.InitialLoading,
|
||||
)
|
||||
|
||||
init {
|
||||
batchFlow.state
|
||||
.map { it.data }
|
||||
.distinctBatchesContent()
|
||||
.onEach { batches ->
|
||||
resultBatches.value = batches.map { batch ->
|
||||
Batch(
|
||||
key = batch.key,
|
||||
data = converter.convert(batch.data),
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
resultBatches.value = emptyList()
|
||||
actionsFlow.emit(
|
||||
BatchAction.Reload(
|
||||
requestParams = createNewsListConfig(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
actionsFlow.emit(BatchAction.LoadMore())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNewsListConfig(): NewsListConfig {
|
||||
return NewsListConfig(
|
||||
language = currentLanguage(),
|
||||
snapshot = null,
|
||||
tokenIds = emptyList(),
|
||||
categoryIds = currentCategoryIds(),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val DEFAULT_BATCH_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
|
@ -51,11 +51,13 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
|
|||
pageCount = { state.articles.size },
|
||||
)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
.collect { page ->
|
||||
state.onArticleIndexChanged(page)
|
||||
}
|
||||
if (state.articles.isNotEmpty()) {
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
.collect { page ->
|
||||
state.onArticleIndexChanged(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.selectedArticleIndex) {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
package com.tangem.features.feed.ui.news.list
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.news.ArticleCard
|
||||
import com.tangem.common.ui.news.ArticleConfigUM
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.block.TangemBlockCardColors
|
||||
import com.tangem.core.ui.components.chip.Chip
|
||||
import com.tangem.core.ui.components.chip.entity.ChipUM
|
||||
import com.tangem.core.ui.components.label.entity.LabelUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.features.feed.ui.news.list.components.NewsListLazyColumn
|
||||
import com.tangem.features.feed.ui.news.list.state.NewsListState
|
||||
import com.tangem.features.feed.ui.news.list.state.NewsListUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
|
@ -27,6 +29,8 @@ import kotlinx.collections.immutable.toImmutableSet
|
|||
@Composable
|
||||
internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) {
|
||||
val background = LocalMainBottomSheetColor.current.value
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -43,25 +47,15 @@ internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) {
|
|||
Chip(state = filter)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
) {
|
||||
items(
|
||||
items = state.articles,
|
||||
key = ArticleConfigUM::id,
|
||||
) { article ->
|
||||
ArticleCard(
|
||||
articleConfigUM = article,
|
||||
onArticleClick = { state.onArticleClick(article.id) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(164.dp),
|
||||
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
}
|
||||
}
|
||||
|
||||
SpacerH(16.dp)
|
||||
|
||||
NewsListLazyColumn(
|
||||
newsListState = state.newsListState,
|
||||
listOfArticles = state.listOfArticles,
|
||||
lazyListState = lazyListState,
|
||||
onArticleClick = state.onArticleClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +131,8 @@ private fun NewsListContentPreview() {
|
|||
state = NewsListUM(
|
||||
selectedCategoryId = 0,
|
||||
filters = filters,
|
||||
articles = articles,
|
||||
listOfArticles = articles,
|
||||
newsListState = NewsListState.Content(loadMore = {}),
|
||||
onArticleClick = {},
|
||||
onBackClick = {},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
package com.tangem.features.feed.ui.news.list.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.news.ArticleCard
|
||||
import com.tangem.common.ui.news.ArticleConfigUM
|
||||
import com.tangem.common.ui.news.DefaultLoadingArticle
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.UnableToLoadData
|
||||
import com.tangem.core.ui.components.block.TangemBlockCardColors
|
||||
import com.tangem.core.ui.components.list.InfiniteListHandler
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.feed.ui.news.list.state.NewsListState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
private const val LOAD_NEXT_PAGE_ON_END_INDEX = 40
|
||||
|
||||
@Composable
|
||||
internal fun NewsListLazyColumn(
|
||||
listOfArticles: ImmutableList<ArticleConfigUM>,
|
||||
newsListState: NewsListState,
|
||||
lazyListState: LazyListState,
|
||||
onArticleClick: (Int) -> Unit,
|
||||
) {
|
||||
val screenState by remember(listOfArticles, newsListState) {
|
||||
derivedStateOf {
|
||||
val isListEmpty = listOfArticles.isEmpty()
|
||||
when {
|
||||
isListEmpty && newsListState is NewsListState.Loading -> NewsListScreenState.InitialLoading
|
||||
isListEmpty && newsListState is NewsListState.LoadingError -> NewsListScreenState.InitialError
|
||||
else -> NewsListScreenState.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(targetState = screenState, label = "NewsListTransition") { state ->
|
||||
when (state) {
|
||||
NewsListScreenState.Content -> {
|
||||
Content(
|
||||
listOfArticles = listOfArticles,
|
||||
newsListState = newsListState,
|
||||
lazyListState = lazyListState,
|
||||
onArticleClick = onArticleClick,
|
||||
)
|
||||
}
|
||||
NewsListScreenState.InitialLoading -> {
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
|
||||
userScrollEnabled = false,
|
||||
) {
|
||||
items(
|
||||
count = 10,
|
||||
key = { "initial_loading_$it" },
|
||||
) {
|
||||
DefaultLoadingArticle(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(164.dp),
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
NewsListScreenState.InitialError -> {
|
||||
val errorState = newsListState as? NewsListState.LoadingError
|
||||
LoadingErrorItem(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onTryAgain = errorState?.onRetryClicked ?: {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
listOfArticles: ImmutableList<ArticleConfigUM>,
|
||||
newsListState: NewsListState,
|
||||
lazyListState: LazyListState,
|
||||
onArticleClick: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
|
||||
userScrollEnabled = true,
|
||||
) {
|
||||
items(
|
||||
items = listOfArticles,
|
||||
key = ArticleConfigUM::id,
|
||||
) { article ->
|
||||
ArticleCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(164.dp),
|
||||
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
|
||||
articleConfigUM = article,
|
||||
onArticleClick = {
|
||||
onArticleClick(article.id)
|
||||
},
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
}
|
||||
|
||||
if (newsListState is NewsListState.Loading) {
|
||||
items(
|
||||
count = 10,
|
||||
key = { "loading_footer_$it" },
|
||||
) {
|
||||
DefaultLoadingArticle(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(164.dp),
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newsListState is NewsListState.Content) {
|
||||
InfiniteListHandler(
|
||||
listState = lazyListState,
|
||||
buffer = LOAD_NEXT_PAGE_ON_END_INDEX,
|
||||
triggerLoadMoreCheckOnItemsCountChange = true,
|
||||
onLoadMore = remember(newsListState) {
|
||||
{
|
||||
newsListState.loadMore()
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class NewsListScreenState {
|
||||
InitialLoading,
|
||||
InitialError,
|
||||
Content,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.padding(vertical = 35.dp, horizontal = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UnableToLoadData(onRetryClick = onTryAgain)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,17 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
|
||||
@Immutable
|
||||
data class NewsListUM(
|
||||
val selectedCategoryId: Int?,
|
||||
val selectedCategoryId: Int,
|
||||
val filters: ImmutableList<ChipUM>,
|
||||
val articles: ImmutableList<ArticleConfigUM>,
|
||||
val listOfArticles: ImmutableList<ArticleConfigUM>,
|
||||
val newsListState: NewsListState,
|
||||
val onArticleClick: (Int) -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
)
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed class NewsListState {
|
||||
data class Content(val loadMore: () -> Unit) : NewsListState()
|
||||
data object Loading : NewsListState()
|
||||
data class LoadingError(val onRetryClicked: () -> Unit) : NewsListState()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue