Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-30 19:27:21 +01:00
parent 75e81283f8
commit faaa2aad8f
28 changed files with 795 additions and 316 deletions

View file

@ -45,8 +45,8 @@ internal class DefaultNewsRepository(
return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope)
}
override suspend fun getNews(config: NewsListConfig, limit: Int): List<ShortArticle> {
return withContext(dispatchers.io) {
override fun getNews(config: NewsListConfig, limit: Int): Flow<List<ShortArticle>> {
return flow {
val response = newsApi.getNews(
page = FIRST_PAGE,
limit = limit,
@ -56,25 +56,16 @@ internal class DefaultNewsRepository(
categoryIds = config.categoryIds.takeIf { it.isNotEmpty() },
).getOrThrow()
val articles = response.items.map { it.toDomainShortArticle() }
val viewedFlags = newsViewedStore.getSync()
articles.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
val shortArticles = response.items.map { it.toDomainShortArticle() }
emit(shortArticles)
}
.flowOn(dispatchers.io)
.combine(newsViewedStore.getAll()) { articlesToUpdate, viewedFlags ->
articlesToUpdate.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
}.sortedBy { it.viewed }
}
}
}
override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle {
val cached = newsDetailsStore.getSyncOrNull(newsId)
if (cached != null) return cached
fetchDetailedArticlesInternal(newsIds = listOf(newsId), language = language)
return requireNotNull(newsDetailsStore.getSyncOrNull(newsId)) {
"Unable to load detailed article with id=$newsId"
}
}
override fun observeDetailedArticles(): Flow<Map<Int, DetailedArticle>> {
@ -98,10 +89,11 @@ internal class DefaultNewsRepository(
) { trendingNews, viewedFlags ->
when (trendingNews) {
is TrendingNews.Data -> {
val articlesWithViewedFlags = trendingNews.articles.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
}
val articlesWithViewedFlags = trendingNews.articles
.map { article ->
article.copy(viewed = viewedFlags[article.id] == true)
}
.sortedBy { it.viewed }
TrendingNews.Data(articlesWithViewedFlags)
}
is TrendingNews.Error -> trendingNews
@ -128,32 +120,11 @@ internal class DefaultNewsRepository(
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
}
return NewsBatchFlowWithViewedStatus(
upstream = newsBatchFlow,
newsViewedStore = newsViewedStore,
scope = scope,
)
}
private suspend fun fetchDetailedArticlesInternal(newsIds: Collection<Int>, language: String?) =
@ -340,6 +311,37 @@ internal class DefaultNewsRepository(
val params: NewsListConfig,
)
private class NewsBatchFlowWithViewedStatus(
private val upstream: NewsListBatchFlow,
newsViewedStore: NewsViewedStore,
scope: CoroutineScope,
) : NewsListBatchFlow {
override val state: StateFlow<BatchListState<Int, List<ShortArticle>>> = combine(
upstream.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(), upstream.state.value.status),
)
override val updateResults: SharedFlow<Pair<Nothing, BatchUpdateResult<Int, List<ShortArticle>>>>
get() = upstream.updateResults
}
private companion object {
private const val INITIAL_BATCH_KEY = 0
private const val FIRST_PAGE = 1

View file

@ -23,18 +23,11 @@ interface NewsRepository {
fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow
/**
* Returns list of short article by config.
* Returns flow of list of short article by config.
*
* @param config config for getting news list
*/
suspend fun getNews(config: NewsListConfig, limit: Int): List<ShortArticle>
/**
* Returns detailed article by id with locale configuration.
* @param newsId news identification
* @param language current locale
*/
suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle
fun getNews(config: NewsListConfig, limit: Int): Flow<List<ShortArticle>>
/**
* Observes cached detailed articles.

View file

@ -4,10 +4,11 @@ import arrow.core.Either
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.repository.NewsRepository
import kotlinx.coroutines.flow.Flow
class GetNewsUseCase(private val repository: NewsRepository) {
suspend fun getNews(limit: Int, newsListConfig: NewsListConfig): Either<Throwable, List<ShortArticle>> =
fun getNews(limit: Int, newsListConfig: NewsListConfig): Either<Throwable, Flow<List<ShortArticle>>> =
Either.catch {
repository.getNews(
config = newsListConfig,

View file

@ -19,6 +19,7 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
@ -79,13 +80,18 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
)
}
override fun onArticleClick(articleId: Int, preselectedArticlesId: List<Int>) {
override fun onArticleClick(
articleId: Int,
preselectedArticlesId: List<Int>,
paginationConfig: NewsListConfig?,
) {
innerRouter.push(
FeedEntryChildFactory.Child.NewsDetails(
params = DefaultNewsDetailsComponent.Params(
articleId = articleId,
onBackClicked = { onChildBack() },
preselectedArticlesId = preselectedArticlesId,
paginationConfig = paginationConfig,
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
),
),
@ -179,7 +185,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
},
onBackClicked = { router.pop() },
onArticleClick = { articleId, preselectedArticlesId ->
clickIntents.onArticleClick(articleId, preselectedArticlesId)
clickIntents.onArticleClick(
articleId = articleId,
preselectedArticlesId = preselectedArticlesId,
paginationConfig = null,
)
},
),
)

View file

@ -78,10 +78,11 @@ internal class FeedEntryChildFactory @Inject constructor(
DefaultNewsListComponent(
appComponentContext = appComponentContext,
params = DefaultNewsListComponent.Params(
onArticleClicked = { currentArticle, prefetchedArticles ->
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
feedEntryClickIntents.onArticleClick(
articleId = currentArticle,
preselectedArticlesId = prefetchedArticles,
paginationConfig = paginationConfig,
)
},
onBackClick = onBackClicked,

View file

@ -15,8 +15,10 @@ import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.ui.news.details.NewsDetailsContent
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import kotlinx.serialization.Serializable
internal class DefaultNewsDetailsComponent(
@ -41,7 +43,8 @@ internal class DefaultNewsDetailsComponent(
endButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_share_24,
onClicked = state.onShareClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED &&
state.articlesStateUM is ArticlesStateUM.Content,
),
)
}
@ -61,5 +64,6 @@ internal class DefaultNewsDetailsComponent(
val onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val preselectedArticlesId: List<Int> = emptyList(),
val paginationConfig: NewsListConfig? = null,
)
}

View file

@ -14,6 +14,7 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.model.news.list.NewsListModel
import com.tangem.features.feed.ui.news.list.NewsListContent
import kotlinx.serialization.Serializable
@ -51,7 +52,11 @@ internal class DefaultNewsListComponent(
@Serializable
data class Params(
val onArticleClicked: (currentArticle: Int, prefetchedArticles: List<Int>) -> Unit,
val onArticleClicked: (
currentArticle: Int,
prefetchedArticles: List<Int>,
paginationConfig: NewsListConfig?,
) -> Unit,
val onBackClick: () -> Unit,
)
}

View file

@ -13,7 +13,12 @@ internal class BatchListStateManager<Key, Domain, UI>(
private val converter: BatchItemConverter<Domain, UI>,
private val dispatchers: CoroutineDispatcherProvider,
) {
val state = MutableStateFlow(BatchListState<Key, Domain, UI>())
val state = MutableStateFlow(
BatchListState<Key, Domain, UI>(
uiBatches = emptyList(),
processedItems = emptyList(),
),
)
suspend fun update(newList: List<Batch<Key, List<Domain>>>, forceUpdate: Boolean) =
withContext(dispatchers.default) {
@ -26,7 +31,7 @@ internal class BatchListStateManager<Key, Domain, UI>(
}
val isInitialLoading = forceUpdate ||
previousList.isNullOrEmpty() ||
previousList.isEmpty() ||
newList.firstOrNull()?.key != previousList.firstOrNull()?.key
val outItems = if (isInitialLoading) {
@ -77,8 +82,8 @@ internal class BatchListStateManager<Key, Domain, UI>(
}
internal data class BatchListState<K, Domain, UI>(
val uiBatches: List<Batch<K, List<UI>>> = emptyList(),
val processedItems: List<Batch<K, List<Domain>>>? = emptyList(),
val uiBatches: List<Batch<K, List<UI>>>,
val processedItems: List<Batch<K, List<Domain>>>,
)
internal interface BatchItemConverter<Domain, UI> {

View file

@ -324,6 +324,7 @@ internal class FeedComponentModel @Inject constructor(
NewsUMState.ERROR,
-> emptyList()
},
paginationConfig = null,
)
},
onOpenAllNews = {

View file

@ -2,6 +2,7 @@ package com.tangem.features.feed.model.feed
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
/**
@ -10,6 +11,17 @@ import com.tangem.features.feed.model.market.list.state.SortByTypeUM
internal interface FeedModelClickIntents {
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency)
fun onMarketOpenClick(sortBy: SortByTypeUM?)
fun onArticleClick(articleId: Int, preselectedArticlesId: List<Int> = emptyList())
/**
* Method to open article of news from different places.
* @param articleId - id of article, which should be opened.
* @param preselectedArticlesId - ids of articles, which were prefetched before opening news details.
* @param paginationConfig - config of current pagination from news list. Must be null, when open from other places.
*/
fun onArticleClick(
articleId: Int,
preselectedArticlesId: List<Int> = emptyList(),
paginationConfig: NewsListConfig? = null,
)
fun onOpenAllNews()
}

View file

@ -244,7 +244,10 @@ internal class FeedMarketsBatchFlowManager(
fun reload(fiatPriceCurrency: String) {
modelScope.launch(dispatchers.default) {
stateManager.state.value = BatchListState()
stateManager.state.value = BatchListState(
processedItems = emptyList(),
uiBatches = emptyList(),
)
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
@ -315,9 +318,9 @@ internal class FeedMarketsBatchFlowManager(
fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? {
return stateManager.state.value.processedItems
?.asSequence()
?.flatMap { it.data }
?.firstOrNull { it.id == tokenId }
.asSequence()
.flatMap { it.data }
.firstOrNull { it.id == tokenId }
}
}

View file

@ -309,20 +309,22 @@ internal class MarketsTokenDetailsModel @Inject constructor(
snapshot = null,
tokenIds = listOf(params.token.id.value),
),
).onRight { articles ->
state.update { marketsTokenDetailsUM ->
val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles)
marketsTokenDetailsUM.copy(
relatedNews = marketsTokenDetailsUM.relatedNews.copy(
articles = relatedNews,
onArticledClicked = { articledId ->
params.onArticleClick(
/* articledId */ articledId,
/* preselectedIds */ relatedNews.map { it.id },
)
},
),
)
).onRight { listOfArticles ->
listOfArticles.collect { articles ->
state.update { marketsTokenDetailsUM ->
val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles)
marketsTokenDetailsUM.copy(
relatedNews = marketsTokenDetailsUM.relatedNews.copy(
articles = relatedNews,
onArticledClicked = { articledId ->
params.onArticleClick(
/* articledId */ articledId,
/* preselectedIds */ relatedNews.map { it.id },
)
},
),
)
}
}
}
}

View file

@ -2,7 +2,6 @@ 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,28 +11,22 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
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.GetNewsListBatchFlowUseCase
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.factory.NewsDetailsIndexManager
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.loader.NewsRelatedTokensLoader
import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Locale
@ -46,6 +39,7 @@ internal class NewsDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase,
private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
@ -56,10 +50,29 @@ internal class NewsDetailsModel @Inject constructor(
private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>()
private val currentLanguage = Locale.getDefault().language
private val relatedTokensCache = mutableMapOf<Int, RelatedTokensUM>()
private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl)
private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter()
private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config ->
NewsDetailsPaginationManager(
getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase,
currentLanguage = Provider { config.language },
currentCategoryIds = Provider { config.categoryIds },
modelScope = modelScope,
dispatchers = dispatchers,
observeNewsDetailsUseCase = observeNewsDetailsUseCase,
prefetchedIds = params.preselectedArticlesId.toSet(),
)
}
private val relatedTokensLoader by lazy {
NewsRelatedTokensLoader(
getTokenMarketInfoUseCase = getTokenMarketInfoUseCase,
getTokenPriceChartUseCase = getTokenPriceChartUseCase,
dispatchers = dispatchers,
maxCount = RELATED_TOKEN_MAX_COUNT,
)
}
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
@ -73,8 +86,9 @@ internal class NewsDetailsModel @Inject constructor(
private val _state = MutableStateFlow(
NewsDetailsUM(
articlesStateUM = ArticlesStateUM.Loading,
articles = persistentListOf(),
selectedArticleIndex = 0,
selectedArticleIndex = -1,
onShareClick = {},
onLikeClick = { /* [REDACTED_TODO_COMMENT] */ },
onBackClick = params.onBackClicked,
@ -87,174 +101,100 @@ internal class NewsDetailsModel @Inject constructor(
currentStateProvider = Provider { _state.value },
shareManager = shareManager,
onStateUpdate = { newState -> _state.update { newState } },
onRetryClick = ::onRetryClicked,
)
}
val state: StateFlow<NewsDetailsUM> = _state.asStateFlow()
init {
if (params.preselectedArticlesId.isNotEmpty()) {
handlePreselectedArticles()
} else {
// TODO handle pagination [REDACTED_TASK_KEY]
}
loadNews()
}
private fun onArticleIndexChanged(newIndex: Int) {
stateFactory.updateSelectedArticleIndex(newIndex)
val currentArticle = state.value.articles.getOrNull(newIndex)
val currentArticle = when (state.value.articlesStateUM) {
is ArticlesStateUM.Content -> {
paginationManager?.checkAndLoadMoreIfNeeded(
currentIndex = newIndex,
totalArticlesCount = state.value.articles.size,
)
state.value.articles.getOrNull(newIndex)
}
ArticlesStateUM.Loading,
is ArticlesStateUM.LoadingError,
-> null
}
currentArticle?.let { article ->
markArticleAsViewed(article.id)
loadRelatedTokens(article)
}
}
private fun handlePreselectedArticles() {
private fun loadNews() {
modelScope.launch {
observeNewsDetailsUseCase.prefetch(
newsIds = params.preselectedArticlesId,
language = currentLanguage,
)
observeNewsDetailsUseCase
.invoke()
.map { articlesMap ->
params.preselectedArticlesId.mapNotNull { articleId ->
articlesMap[articleId]?.let { detailedArticle ->
newsDetailsConverter.convert(detailedArticle)
}
initialPrefetch()
paginationManager?.start()
val articleIdsFlow = paginationManager?.cachedPrefetchedIds ?: flowOf(params.preselectedArticlesId)
combine(
articleIdsFlow,
observeNewsDetailsUseCase.invoke(),
) { articleIds, articlesMap ->
articleIds.mapNotNull { articleId ->
articlesMap[articleId]?.let { detailedArticle ->
newsDetailsConverter.convert(detailedArticle)
}
}
}
.map { articles ->
articles.toImmutableList()
}
.onEach { articles ->
val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0
stateFactory.updateArticles(articles, selectedIndex)
.onEach { newArticles ->
val currentState = _state.value
val newIndex = NewsDetailsIndexManager.calculateNewIndex(
currentState = NewsDetailsIndexManager.NewsDetailsState(
articles = currentState.articles,
selectedArticleIndex = currentState.selectedArticleIndex,
),
newArticles = newArticles,
defaultArticleId = params.articleId,
)
stateFactory.updateArticles(newArticles, newIndex)
}
.launchIn(modelScope)
}
}
private fun loadRelatedTokens(article: ArticleUM) {
modelScope.launch(dispatchers.default) {
val cachedTokens = getCachedRelatedTokens(article.id)
if (cachedTokens != null) {
stateFactory.updateRelatedTokens(cachedTokens)
return@launch
}
stateFactory.updateRelatedTokens(RelatedTokensUM.Loading)
val relatedTokens = article.relatedTokens.take(RELATED_TOKEN_MAX_COUNT)
if (relatedTokens.isEmpty()) {
handleEmptyRelatedTokens(article.id)
return@launch
}
val appCurrency = currentAppCurrency.value
val tokenDataList = loadTokensData(relatedTokens, appCurrency)
if (tokenDataList.isEmpty()) {
handleEmptyTokenData(article.id)
return@launch
}
val resultState = createRelatedTokensState(tokenDataList, appCurrency)
relatedTokensCache[article.id] = resultState
stateFactory.updateRelatedTokens(resultState)
private suspend fun initialPrefetch() {
observeNewsDetailsUseCase.prefetch(
newsIds = params.preselectedArticlesId,
language = currentLanguage,
).mapLeft {
stateFactory.createErrorState()
}
}
private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? {
return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading }
private fun onRetryClicked() {
stateFactory.createLoadingState()
modelScope.launch(dispatchers.default) {
initialPrefetch()
paginationManager?.reload()
}
}
private fun handleEmptyRelatedTokens(articleId: Int) {
val errorState = RelatedTokensUM.LoadingError
relatedTokensCache[articleId] = errorState
stateFactory.updateRelatedTokens(errorState)
}
private suspend fun CoroutineScope.loadTokensData(
relatedTokens: List<RelatedToken>,
appCurrency: AppCurrency,
): List<Pair<MarketsListItemUM, TokenMarketParams>> {
val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency)
return relatedTokens.map { token ->
async(dispatchers.default) {
loadSingleTokenData(token, appCurrency, relatedTokenConverter)
}
}.awaitAll().filterNotNull()
}
private suspend fun loadSingleTokenData(
token: RelatedToken,
appCurrency: AppCurrency,
relatedTokenConverter: RelatedTokenConverter,
): Pair<MarketsListItemUM, TokenMarketParams>? {
val tokenId = CryptoCurrency.RawID(token.id)
val tokenInfoResult = getTokenMarketInfoUseCase(
appCurrency = appCurrency,
tokenId = tokenId,
tokenSymbol = token.symbol,
)
return tokenInfoResult.fold(
ifLeft = { null },
ifRight = { tokenInfo ->
val chart = loadTokenChart(tokenId, token.symbol, appCurrency)
val tokenItem = relatedTokenConverter.convert(tokenInfo to chart)
val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo)
tokenItem to tokenParams
},
)
}
private suspend fun loadTokenChart(
tokenId: CryptoCurrency.RawID,
tokenSymbol: String,
appCurrency: AppCurrency,
): com.tangem.domain.markets.TokenChart? {
val chartResult = getTokenPriceChartUseCase(
appCurrency = appCurrency,
interval = PriceChangeInterval.H24,
tokenId = tokenId,
tokenSymbol = tokenSymbol,
preview = true,
)
return chartResult.getOrElse { null }
}
private fun handleEmptyTokenData(articleId: Int) {
val errorState = RelatedTokensUM.LoadingError
relatedTokensCache[articleId] = errorState
stateFactory.updateRelatedTokens(errorState)
}
private fun createRelatedTokensState(
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
appCurrency: AppCurrency,
): RelatedTokensUM.Content {
val tokenItems = tokenDataList.map { it.first }
val onTokenClick = createTokenClickHandler(tokenDataList, appCurrency)
return stateFactory.createRelatedTokensContent(
items = tokenItems.toImmutableList(),
onTokenClick = onTokenClick,
)
}
private fun createTokenClickHandler(
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
appCurrency: AppCurrency,
): (MarketsListItemUM) -> Unit {
return { item ->
val tokenData = tokenDataList.find { it.first.id == item.id }
tokenData?.second?.let { tokenParams ->
params.onTokenClick(tokenParams, appCurrency)
}
private fun loadRelatedTokens(article: ArticleUM) {
modelScope.launch(dispatchers.default) {
stateFactory.updateRelatedTokens(RelatedTokensUM.Loading)
val appCurrency = currentAppCurrency.value
val resultState = relatedTokensLoader.load(
articleId = article.id,
relatedTokens = article.relatedTokens,
appCurrency = appCurrency,
onTokenClick = { tokenParams, currency ->
params.onTokenClick(tokenParams, currency)
},
)
stateFactory.updateRelatedTokens(resultState)
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.feed.model.news.details
import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase
import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class NewsDetailsPaginationManager(
private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase,
private val currentLanguage: Provider<String>,
getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase,
dispatchers: CoroutineDispatcherProvider,
currentCategoryIds: Provider<List<Int>>,
modelScope: CoroutineScope,
prefetchedIds: Set<Int>,
) : NewsListBatchFlowManager(
getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase,
currentLanguage = currentLanguage,
currentCategoryIds = currentCategoryIds,
modelScope = modelScope,
dispatchers = dispatchers,
) {
private val _cachedPrefetchedIds = MutableStateFlow(prefetchedIds)
val cachedPrefetchedIds = _cachedPrefetchedIds.asStateFlow()
init {
reload()
}
fun start() {
modelScope.launch(dispatchers.default) {
combine(
rawArticlesFlow,
observeNewsDetailsUseCase(),
_cachedPrefetchedIds,
) { articles, cachedArticles, prefetched ->
calculateIdsToFetch(
visibleIds = articles.map { it.id }.toSet(),
cachedIds = cachedArticles.keys,
alreadyPrefetched = prefetched,
)
}
.distinctUntilChanged()
.collect { newIds ->
if (newIds.isNotEmpty()) {
observeNewsDetailsUseCase.prefetch(
newsIds = newIds,
language = currentLanguage(),
)
_cachedPrefetchedIds.update { it + newIds }
}
}
}
}
private fun calculateIdsToFetch(visibleIds: Set<Int>, cachedIds: Set<Int>, alreadyPrefetched: Set<Int>): Set<Int> {
return visibleIds - cachedIds - alreadyPrefetched
}
fun checkAndLoadMoreIfNeeded(
currentIndex: Int,
totalArticlesCount: Int,
preloadThreshold: Int = PRELOAD_THRESHOLD,
) {
if (currentIndex >= totalArticlesCount - preloadThreshold) {
loadMoreIfPossible()
}
}
private fun loadMoreIfPossible() {
modelScope.launch(dispatchers.default) {
when (val status = paginationStatus.value) {
is PaginationStatus.InitialLoading,
is PaginationStatus.NextBatchLoading,
is PaginationStatus.EndOfPagination,
-> {
return@launch
}
is PaginationStatus.InitialLoadingError -> {
reload()
return@launch
}
is PaginationStatus.Paginating -> {
val lastResult = status.lastResult
val canLoadMore = lastResult is BatchFetchResult.Success && !lastResult.last
if (canLoadMore) {
loadMore()
}
}
is PaginationStatus.None -> {
reload()
}
}
}
}
companion object {
private const val PRELOAD_THRESHOLD = 5
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.feed.model.news.details.factory
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.collections.immutable.ImmutableList
internal object NewsDetailsIndexManager {
/**
* Calculate new index of article, trying to save current selected article while is loading
*/
fun calculateNewIndex(
currentState: NewsDetailsState,
newArticles: ImmutableList<ArticleUM>,
defaultArticleId: Int,
): Int {
val currentArticleId = if (currentState.articles.isEmpty()) {
defaultArticleId
} else {
currentState.articles.getOrNull(currentState.selectedArticleIndex)?.id
?: defaultArticleId
}
return newArticles.indexOfFirstOrNull { it.id == currentArticleId } ?: 0
}
data class NewsDetailsState(
val articles: ImmutableList<ArticleUM>,
val selectedArticleIndex: Int,
)
}

View file

@ -1,8 +1,8 @@
package com.tangem.features.feed.model.news.details.factory
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.navigation.share.ShareManager
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
import com.tangem.utils.Provider
@ -12,6 +12,7 @@ internal class NewsDetailsStateFactory(
private val currentStateProvider: Provider<NewsDetailsUM>,
private val shareManager: ShareManager,
private val onStateUpdate: (NewsDetailsUM) -> Unit,
private val onRetryClick: () -> Unit,
) {
fun updateArticles(articles: List<ArticleUM>, selectedIndex: Int) {
@ -20,6 +21,7 @@ internal class NewsDetailsStateFactory(
onStateUpdate(
currentState.copy(
articles = articles.toImmutableList(),
articlesStateUM = ArticlesStateUM.Content,
selectedArticleIndex = selectedIndex,
onShareClick = {
currentArticle?.let {
@ -50,13 +52,13 @@ internal class NewsDetailsStateFactory(
onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens))
}
fun createRelatedTokensContent(
items: List<MarketsListItemUM>,
onTokenClick: (MarketsListItemUM) -> Unit,
): RelatedTokensUM.Content {
return RelatedTokensUM.Content(
items = items.toImmutableList(),
onTokenClick = onTokenClick,
)
fun createErrorState() {
val currentState = currentStateProvider()
onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.LoadingError(onRetryClick)))
}
fun createLoadingState() {
val currentState = currentStateProvider()
onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.Loading))
}
}

View file

@ -0,0 +1,150 @@
package com.tangem.features.feed.model.news.details.loader
import arrow.core.getOrElse
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.domain.models.news.RelatedToken
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.ui.news.details.state.RelatedTokensUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
internal class NewsRelatedTokensLoader(
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val dispatchers: CoroutineDispatcherProvider,
private val maxCount: Int,
) {
private val relatedTokensCache = mutableMapOf<Int, RelatedTokensUM>()
private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter()
/**
* Loads data about related tokens.
* Returns the cached result if available, otherwise loads the data.
*
* @param articleId article ID for caching
* @param relatedTokens list of related tokens
* @param appCurrency application currency
* @param onTokenClick token click handler
* @return state of related tokens
*/
suspend fun load(
articleId: Int,
relatedTokens: List<RelatedToken>,
appCurrency: AppCurrency,
onTokenClick: (TokenMarketParams, AppCurrency) -> Unit,
): RelatedTokensUM = withContext(dispatchers.default) {
val cached = getCachedRelatedTokens(articleId)
if (cached != null) {
return@withContext cached
}
val tokensToLoad = relatedTokens.take(maxCount)
if (tokensToLoad.isEmpty()) {
val errorState = RelatedTokensUM.LoadingError
relatedTokensCache[articleId] = errorState
return@withContext errorState
}
val tokenDataList = loadTokensData(tokensToLoad, appCurrency)
if (tokenDataList.isEmpty()) {
val errorState = RelatedTokensUM.LoadingError
relatedTokensCache[articleId] = errorState
return@withContext errorState
}
val resultState = createRelatedTokensState(tokenDataList, appCurrency, onTokenClick)
relatedTokensCache[articleId] = resultState
resultState
}
private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? {
return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading }
}
private suspend fun CoroutineScope.loadTokensData(
relatedTokens: List<RelatedToken>,
appCurrency: AppCurrency,
): List<Pair<MarketsListItemUM, TokenMarketParams>> {
val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency)
return relatedTokens.map { token ->
async(dispatchers.default) {
loadSingleTokenData(token, appCurrency, relatedTokenConverter)
}
}.awaitAll().filterNotNull()
}
private suspend fun loadSingleTokenData(
token: RelatedToken,
appCurrency: AppCurrency,
relatedTokenConverter: RelatedTokenConverter,
): Pair<MarketsListItemUM, TokenMarketParams>? {
val tokenId = CryptoCurrency.RawID(token.id)
val tokenInfoResult = getTokenMarketInfoUseCase(
appCurrency = appCurrency,
tokenId = tokenId,
tokenSymbol = token.symbol,
)
return tokenInfoResult.fold(
ifLeft = { null },
ifRight = { tokenInfo ->
val chart = loadTokenChart(tokenId, token.symbol, appCurrency)
val tokenItem = relatedTokenConverter.convert(tokenInfo to chart)
val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo)
tokenItem to tokenParams
},
)
}
private suspend fun loadTokenChart(
tokenId: CryptoCurrency.RawID,
tokenSymbol: String,
appCurrency: AppCurrency,
): TokenChart? {
val chartResult = getTokenPriceChartUseCase(
appCurrency = appCurrency,
interval = PriceChangeInterval.H24,
tokenId = tokenId,
tokenSymbol = tokenSymbol,
preview = true,
)
return chartResult.getOrElse { null }
}
private fun createRelatedTokensState(
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
appCurrency: AppCurrency,
onTokenClick: (TokenMarketParams, AppCurrency) -> Unit,
): RelatedTokensUM.Content {
val tokenItems = tokenDataList.map { it.first }
val onTokenClickHandler = createTokenClickHandler(tokenDataList, appCurrency, onTokenClick)
return RelatedTokensUM.Content(
items = tokenItems.toImmutableList(),
onTokenClick = onTokenClickHandler,
)
}
private fun createTokenClickHandler(
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
appCurrency: AppCurrency,
onTokenClick: (TokenMarketParams, AppCurrency) -> Unit,
): (MarketsListItemUM) -> Unit {
return { item ->
val tokenData = tokenDataList.find { it.first.id == item.id }
tokenData?.second?.let { tokenParams ->
onTokenClick(tokenParams, appCurrency)
}
}
}
}

View file

@ -1,25 +1,24 @@
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.model.NewsListConfig
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.calculator.NewsListStateManager
import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader
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
@ -39,6 +38,14 @@ internal class NewsListModel @Inject constructor(
private val selectedCategoryId = MutableStateFlow<Int?>(null)
private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode()
private val categoriesLoader by lazy {
NewsCategoriesLoader(
getNewsCategoriesUseCase = getNewsCategoriesUseCase,
defaultAllNewsCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID,
onCategoryClick = ::onCategoryClick,
)
}
private val batchFlowManager by lazy {
NewsListBatchFlowManager(
getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase,
@ -61,6 +68,7 @@ internal class NewsListModel @Inject constructor(
params.onArticleClicked(
/* currentArticle */ articleId,
/* prefetchedArticles */ getCurrentFetchedArticlesIds(),
/* paginationConfig */ createNewsListConfig(),
)
},
onBackClick = params.onBackClick,
@ -76,31 +84,7 @@ internal class NewsListModel @Inject constructor(
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(
ifLeft = {
persistentListOf()
},
ifRight = { categories ->
(listOf(allCategoriesChip) + categories.map { articleCategory ->
ChipUM(
id = articleCategory.id,
text = TextReference.Str(articleCategory.name),
isSelected = false,
onClick = {
onCategoryClick(articleCategory.id)
},
)
}).toPersistentList()
},
)
val filterChips = categoriesLoader.load()
_state.update { currentState ->
currentState.copy(filters = filterChips)
}
@ -114,23 +98,16 @@ internal class NewsListModel @Inject constructor(
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
}
}
NewsListStateManager.calculateState(
articles = articles,
isError = isError,
paginationStatus = paginationStatus,
onRetryClick = {
loadCategories()
batchFlowManager.reload()
},
onLoadMore = { batchFlowManager.loadMore() },
)
}.collect { (listState, articles) ->
_state.update { currentState ->
currentState.copy(
@ -168,6 +145,15 @@ internal class NewsListModel @Inject constructor(
return state.value.listOfArticles.map { it.id }
}
private fun createNewsListConfig(): NewsListConfig {
return NewsListConfig(
language = currentLanguage,
snapshot = null,
tokenIds = emptyList(),
categoryIds = selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty(),
)
}
companion object {
private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.feed.model.news.list.calculator
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.news.list.state.NewsListState
import com.tangem.pagination.PaginationStatus
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* A pure function for calculating the state of the news list.
* Extracted from NewsListModel to simplify testing and improve readability.
*/
internal object NewsListStateManager {
/**
* Calculates the state of the news list based on the current data.
*
* @param articles the current list of articles
* @param isError flag indicating an initial loading error
* @param paginationStatus the current pagination status
* @param onRetryClick handler for the retry loading action
* @param onLoadMore handler for loading the next page
* @return a pair: the list state and the list of articles to display
*/
fun calculateState(
articles: ImmutableList<ArticleConfigUM>,
isError: Boolean,
paginationStatus: PaginationStatus<*>,
onRetryClick: () -> Unit,
onLoadMore: () -> Unit,
): Pair<NewsListState, ImmutableList<ArticleConfigUM>> {
return when {
isError -> NewsListState.LoadingError(onRetryClick) to persistentListOf()
paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> {
NewsListState.Loading to persistentListOf()
}
articles.isEmpty() -> NewsListState.Loading to persistentListOf()
else -> NewsListState.Content(loadMore = onLoadMore) to articles
}
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.feed.model.news.list.loader
import com.tangem.common.ui.R
import com.tangem.core.ui.components.chip.entity.ChipUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.news.ArticleCategory
import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class NewsCategoriesLoader(
private val getNewsCategoriesUseCase: GetNewsCategoriesUseCase,
private val defaultAllNewsCategoryId: Int,
private val onCategoryClick: (Int) -> Unit,
) {
suspend fun load(): ImmutableList<ChipUM> {
val allCategoriesChip = createAllNewsChip()
val categoriesResult = getNewsCategoriesUseCase.invoke()
return categoriesResult.fold(
ifLeft = { persistentListOf(allCategoriesChip) },
ifRight = { categories ->
val categoryChips = categories.map { articleCategory ->
createCategoryChip(articleCategory)
}
(listOf(allCategoriesChip) + categoryChips).toPersistentList()
},
)
}
private fun createAllNewsChip(): ChipUM {
return ChipUM(
id = defaultAllNewsCategoryId,
text = TextReference.Res(R.string.news_all_news),
isSelected = true,
onClick = { onCategoryClick(defaultAllNewsCategoryId) },
)
}
private fun createCategoryChip(articleCategory: ArticleCategory): ChipUM {
return ChipUM(
id = articleCategory.id,
text = TextReference.Str(articleCategory.name),
isSelected = false,
onClick = { onCategoryClick(articleCategory.id) },
)
}
}

View file

@ -14,17 +14,18 @@ import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class NewsListBatchFlowManager(
internal open class NewsListBatchFlowManager(
getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase,
private val currentLanguage: Provider<String>,
private val currentCategoryIds: Provider<List<Int>>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
protected val modelScope: CoroutineScope,
protected val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, NewsListConfig, Nothing>>()
private val converter by lazy {
@ -41,6 +42,19 @@ internal class NewsListBatchFlowManager(
private val resultBatches = MutableStateFlow<List<Batch<Int, List<ArticleConfigUM>>>>(emptyList())
val rawArticlesFlow: StateFlow<ImmutableList<ShortArticle>> = batchFlow.state
.map { batchListState ->
batchListState.data
.flatMap { batch -> batch.data }
.toImmutableList()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = persistentListOf(),
)
val uiItems: StateFlow<ImmutableList<ArticleConfigUM>>
get() = batchFlow.state
.map { batchListState ->
@ -64,15 +78,14 @@ internal class NewsListBatchFlowManager(
initialValue = false,
)
val paginationStatus: StateFlow<PaginationStatus<List<ShortArticle>>>
get() = batchFlow.state
.map { it.status }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = PaginationStatus.InitialLoading,
)
val paginationStatus: StateFlow<PaginationStatus<List<ShortArticle>>> = batchFlow.state
.map { it.status }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = PaginationStatus.InitialLoading,
)
init {
batchFlow.state

View file

@ -11,10 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -74,7 +71,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) {
) { animatedState ->
when (animatedState) {
is GlobalFeedState.Loading -> {
FeeListLoading(
FeedListLoading(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
@ -88,7 +85,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) {
)
}
is GlobalFeedState.Content -> {
FeeListContent(
FeedListContent(
modifier = Modifier,
state = state,
)
@ -125,7 +122,7 @@ private fun FeedSearchBar(feedListSearchBar: FeedListSearchBar, modifier: Modifi
}
@Composable
private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) {
private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
@ -289,6 +286,13 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi
@Suppress("LongMethod")
@Composable
private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
val listState = rememberLazyListState()
val articlesReadStatus = remember(news.content) {
news.content.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column {
Header(
title = {
@ -347,7 +351,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
state = listState,
) {
items(
items = news.content,

View file

@ -17,7 +17,7 @@ import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.res.TangemThemePreview
@Composable
internal fun FeeListLoading(modifier: Modifier = Modifier) {
internal fun FeedListLoading(modifier: Modifier = Modifier) {
Column(modifier) {
Column(
modifier = Modifier

View file

@ -7,6 +7,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -203,6 +205,13 @@ private fun LazyListScope.loadingInfoBlocks() {
private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
item("related-news") {
val listState = rememberLazyListState()
val articlesReadStatus = remember(relatedNews.articles) {
relatedNews.articles.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column(
modifier = Modifier
.fillMaxWidth()
@ -220,7 +229,7 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
state = listState,
) {
items(
items = relatedNews.articles,

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.platform.testTag
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
@ -176,6 +177,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod
onClick = onShowTokensClick,
),
)
SpacerH12()
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.news.details
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -20,6 +21,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -40,12 +42,45 @@ import com.tangem.core.ui.extensions.stringResourceSafe
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.details.components.NewsDetailsPlaceholder
import com.tangem.features.feed.ui.news.details.components.RelatedTokensBlock
import com.tangem.features.feed.ui.news.details.state.*
@Composable
internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
AnimatedContent(
targetState = state.articlesStateUM,
modifier = modifier,
) { animatedState ->
when (animatedState) {
ArticlesStateUM.Content -> {
Content(state = state, background = background)
}
ArticlesStateUM.Loading -> {
NewsDetailsPlaceholder(background = background)
}
is ArticlesStateUM.LoadingError -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(background),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(
onRetryClick = animatedState.onRetryClicked,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
)
}
}
}
}
}
@Composable
private fun Content(state: NewsDetailsUM, background: Color) {
val pagerState = rememberPagerState(
initialPage = state.selectedArticleIndex,
pageCount = { state.articles.size },
@ -67,7 +102,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
}
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.background(background),
) {
@ -318,6 +353,7 @@ private fun PreviewNewsDetailsContent() {
TangemThemePreview {
NewsDetailsContent(
state = NewsDetailsUM(
articlesStateUM = ArticlesStateUM.Content,
articles = MockArticlesFactory.createMockArticles(),
selectedArticleIndex = 0,
onShareClick = {},

View file

@ -0,0 +1,57 @@
package com.tangem.features.feed.ui.news.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
@Composable
fun NewsDetailsPlaceholder(background: Color, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().background(background).padding(16.dp),
) {
RectangleShimmer(modifier = Modifier.size(width = 112.dp, height = 20.dp))
SpacerH(8.dp)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(28.dp),
)
SpacerH(4.dp)
RectangleShimmer(modifier = Modifier.size(height = 28.dp, width = 208.dp))
SpacerH(20.dp)
RectangleShimmer(modifier = Modifier.size(height = 36.dp, width = 99.dp), radius = 12.dp)
SpacerH(32.dp)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 24.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 70.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 96.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 100.dp),
)
}
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.news.RelatedToken
import kotlinx.collections.immutable.ImmutableList
internal data class NewsDetailsUM(
val articlesStateUM: ArticlesStateUM,
val articles: ImmutableList<ArticleUM>,
val selectedArticleIndex: Int,
val onShareClick: () -> Unit,
@ -17,6 +18,13 @@ internal data class NewsDetailsUM(
val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading,
)
@Immutable
internal sealed interface ArticlesStateUM {
data object Loading : ArticlesStateUM
data object Content : ArticlesStateUM
data class LoadingError(val onRetryClicked: () -> Unit) : ArticlesStateUM
}
internal data class ArticleUM(
val id: Int,
val title: String,