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

View file

@ -23,18 +23,11 @@ interface NewsRepository {
fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow 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 * @param config config for getting news list
*/ */
suspend fun getNews(config: NewsListConfig, limit: Int): List<ShortArticle> fun getNews(config: NewsListConfig, limit: Int): Flow<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
/** /**
* Observes cached detailed articles. * 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.models.news.ShortArticle
import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.repository.NewsRepository import com.tangem.domain.news.repository.NewsRepository
import kotlinx.coroutines.flow.Flow
class GetNewsUseCase(private val repository: NewsRepository) { 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 { Either.catch {
repository.getNews( repository.getNews(
config = newsListConfig, 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.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams 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.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent 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( innerRouter.push(
FeedEntryChildFactory.Child.NewsDetails( FeedEntryChildFactory.Child.NewsDetails(
params = DefaultNewsDetailsComponent.Params( params = DefaultNewsDetailsComponent.Params(
articleId = articleId, articleId = articleId,
onBackClicked = { onChildBack() }, onBackClicked = { onChildBack() },
preselectedArticlesId = preselectedArticlesId, preselectedArticlesId = preselectedArticlesId,
paginationConfig = paginationConfig,
onTokenClick = { token, currency -> onMarketItemClick(token, currency) }, onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
), ),
), ),
@ -179,7 +185,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}, },
onBackClicked = { router.pop() }, onBackClicked = { router.pop() },
onArticleClick = { articleId, preselectedArticlesId -> 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( DefaultNewsListComponent(
appComponentContext = appComponentContext, appComponentContext = appComponentContext,
params = DefaultNewsListComponent.Params( params = DefaultNewsListComponent.Params(
onArticleClicked = { currentArticle, prefetchedArticles -> onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
feedEntryClickIntents.onArticleClick( feedEntryClickIntents.onArticleClick(
articleId = currentArticle, articleId = currentArticle,
preselectedArticlesId = prefetchedArticles, preselectedArticlesId = prefetchedArticles,
paginationConfig = paginationConfig,
) )
}, },
onBackClick = onBackClicked, 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.core.ui.res.LocalMainBottomSheetColor
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams 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.model.news.details.NewsDetailsModel
import com.tangem.features.feed.ui.news.details.NewsDetailsContent import com.tangem.features.feed.ui.news.details.NewsDetailsContent
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
internal class DefaultNewsDetailsComponent( internal class DefaultNewsDetailsComponent(
@ -41,7 +43,8 @@ internal class DefaultNewsDetailsComponent(
endButton = TopAppBarButtonUM.Icon( endButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_share_24, iconRes = R.drawable.ic_share_24,
onClicked = state.onShareClick, 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 onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val preselectedArticlesId: List<Int> = emptyList(), 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.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor 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.model.news.list.NewsListModel
import com.tangem.features.feed.ui.news.list.NewsListContent import com.tangem.features.feed.ui.news.list.NewsListContent
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -51,7 +52,11 @@ internal class DefaultNewsListComponent(
@Serializable @Serializable
data class Params( data class Params(
val onArticleClicked: (currentArticle: Int, prefetchedArticles: List<Int>) -> Unit, val onArticleClicked: (
currentArticle: Int,
prefetchedArticles: List<Int>,
paginationConfig: NewsListConfig?,
) -> Unit,
val onBackClick: () -> Unit, val onBackClick: () -> Unit,
) )
} }

View file

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

View file

@ -324,6 +324,7 @@ internal class FeedComponentModel @Inject constructor(
NewsUMState.ERROR, NewsUMState.ERROR,
-> emptyList() -> emptyList()
}, },
paginationConfig = null,
) )
}, },
onOpenAllNews = { 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.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.model.market.list.state.SortByTypeUM 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 { internal interface FeedModelClickIntents {
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency)
fun onMarketOpenClick(sortBy: SortByTypeUM?) 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() fun onOpenAllNews()
} }

View file

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

View file

@ -309,20 +309,22 @@ internal class MarketsTokenDetailsModel @Inject constructor(
snapshot = null, snapshot = null,
tokenIds = listOf(params.token.id.value), tokenIds = listOf(params.token.id.value),
), ),
).onRight { articles -> ).onRight { listOfArticles ->
state.update { marketsTokenDetailsUM -> listOfArticles.collect { articles ->
val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles) state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy( val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles)
relatedNews = marketsTokenDetailsUM.relatedNews.copy( marketsTokenDetailsUM.copy(
articles = relatedNews, relatedNews = marketsTokenDetailsUM.relatedNews.copy(
onArticledClicked = { articledId -> articles = relatedNews,
params.onArticleClick( onArticledClicked = { articledId ->
/* articledId */ articledId, params.onArticleClick(
/* preselectedIds */ relatedNews.map { it.id }, /* 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 androidx.compose.runtime.Stable
import arrow.core.getOrElse import arrow.core.getOrElse
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer 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.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
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.MarkArticleAsViewedUseCase
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent 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.NewsDetailsConverter
import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter import com.tangem.features.feed.model.news.details.loader.NewsRelatedTokensLoader
import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter
import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory 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.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.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
import com.tangem.utils.Provider import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
@ -46,6 +39,7 @@ internal class NewsDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase,
private val getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
private val shareManager: ShareManager, private val shareManager: ShareManager,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
@ -56,10 +50,29 @@ internal class NewsDetailsModel @Inject constructor(
private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>() private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>()
private val currentLanguage = Locale.getDefault().language private val currentLanguage = Locale.getDefault().language
private val relatedTokensCache = mutableMapOf<Int, RelatedTokensUM>()
private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl) 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 -> private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default } maybeAppCurrency.getOrElse { AppCurrency.Default }
@ -73,8 +86,9 @@ internal class NewsDetailsModel @Inject constructor(
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
NewsDetailsUM( NewsDetailsUM(
articlesStateUM = ArticlesStateUM.Loading,
articles = persistentListOf(), articles = persistentListOf(),
selectedArticleIndex = 0, selectedArticleIndex = -1,
onShareClick = {}, onShareClick = {},
onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, onLikeClick = { /* [REDACTED_TODO_COMMENT] */ },
onBackClick = params.onBackClicked, onBackClick = params.onBackClicked,
@ -87,174 +101,100 @@ internal class NewsDetailsModel @Inject constructor(
currentStateProvider = Provider { _state.value }, currentStateProvider = Provider { _state.value },
shareManager = shareManager, shareManager = shareManager,
onStateUpdate = { newState -> _state.update { newState } }, onStateUpdate = { newState -> _state.update { newState } },
onRetryClick = ::onRetryClicked,
) )
} }
val state: StateFlow<NewsDetailsUM> = _state.asStateFlow() val state: StateFlow<NewsDetailsUM> = _state.asStateFlow()
init { init {
if (params.preselectedArticlesId.isNotEmpty()) { loadNews()
handlePreselectedArticles()
} else {
// TODO handle pagination [REDACTED_TASK_KEY]
}
} }
private fun onArticleIndexChanged(newIndex: Int) { private fun onArticleIndexChanged(newIndex: Int) {
stateFactory.updateSelectedArticleIndex(newIndex) 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 -> currentArticle?.let { article ->
markArticleAsViewed(article.id) markArticleAsViewed(article.id)
loadRelatedTokens(article) loadRelatedTokens(article)
} }
} }
private fun handlePreselectedArticles() { private fun loadNews() {
modelScope.launch { modelScope.launch {
observeNewsDetailsUseCase.prefetch( initialPrefetch()
newsIds = params.preselectedArticlesId, paginationManager?.start()
language = currentLanguage, val articleIdsFlow = paginationManager?.cachedPrefetchedIds ?: flowOf(params.preselectedArticlesId)
) combine(
articleIdsFlow,
observeNewsDetailsUseCase observeNewsDetailsUseCase.invoke(),
.invoke() ) { articleIds, articlesMap ->
.map { articlesMap -> articleIds.mapNotNull { articleId ->
params.preselectedArticlesId.mapNotNull { articleId -> articlesMap[articleId]?.let { detailedArticle ->
articlesMap[articleId]?.let { detailedArticle -> newsDetailsConverter.convert(detailedArticle)
newsDetailsConverter.convert(detailedArticle)
}
} }
} }
}
.map { articles -> .map { articles ->
articles.toImmutableList() articles.toImmutableList()
} }
.onEach { articles -> .onEach { newArticles ->
val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0 val currentState = _state.value
stateFactory.updateArticles(articles, selectedIndex) val newIndex = NewsDetailsIndexManager.calculateNewIndex(
currentState = NewsDetailsIndexManager.NewsDetailsState(
articles = currentState.articles,
selectedArticleIndex = currentState.selectedArticleIndex,
),
newArticles = newArticles,
defaultArticleId = params.articleId,
)
stateFactory.updateArticles(newArticles, newIndex)
} }
.launchIn(modelScope) .launchIn(modelScope)
} }
} }
private fun loadRelatedTokens(article: ArticleUM) { private suspend fun initialPrefetch() {
modelScope.launch(dispatchers.default) { observeNewsDetailsUseCase.prefetch(
val cachedTokens = getCachedRelatedTokens(article.id) newsIds = params.preselectedArticlesId,
if (cachedTokens != null) { language = currentLanguage,
stateFactory.updateRelatedTokens(cachedTokens) ).mapLeft {
return@launch stateFactory.createErrorState()
}
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 fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? { private fun onRetryClicked() {
return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading } stateFactory.createLoadingState()
modelScope.launch(dispatchers.default) {
initialPrefetch()
paginationManager?.reload()
}
} }
private fun handleEmptyRelatedTokens(articleId: Int) { private fun loadRelatedTokens(article: ArticleUM) {
val errorState = RelatedTokensUM.LoadingError modelScope.launch(dispatchers.default) {
relatedTokensCache[articleId] = errorState stateFactory.updateRelatedTokens(RelatedTokensUM.Loading)
stateFactory.updateRelatedTokens(errorState) val appCurrency = currentAppCurrency.value
} val resultState = relatedTokensLoader.load(
articleId = article.id,
private suspend fun CoroutineScope.loadTokensData( relatedTokens = article.relatedTokens,
relatedTokens: List<RelatedToken>, appCurrency = appCurrency,
appCurrency: AppCurrency, onTokenClick = { tokenParams, currency ->
): List<Pair<MarketsListItemUM, TokenMarketParams>> { params.onTokenClick(tokenParams, currency)
val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency) },
)
return relatedTokens.map { token -> stateFactory.updateRelatedTokens(resultState)
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)
}
} }
} }

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 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.core.navigation.share.ShareManager
import com.tangem.features.feed.ui.news.details.state.ArticleUM 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.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
import com.tangem.utils.Provider import com.tangem.utils.Provider
@ -12,6 +12,7 @@ internal class NewsDetailsStateFactory(
private val currentStateProvider: Provider<NewsDetailsUM>, private val currentStateProvider: Provider<NewsDetailsUM>,
private val shareManager: ShareManager, private val shareManager: ShareManager,
private val onStateUpdate: (NewsDetailsUM) -> Unit, private val onStateUpdate: (NewsDetailsUM) -> Unit,
private val onRetryClick: () -> Unit,
) { ) {
fun updateArticles(articles: List<ArticleUM>, selectedIndex: Int) { fun updateArticles(articles: List<ArticleUM>, selectedIndex: Int) {
@ -20,6 +21,7 @@ internal class NewsDetailsStateFactory(
onStateUpdate( onStateUpdate(
currentState.copy( currentState.copy(
articles = articles.toImmutableList(), articles = articles.toImmutableList(),
articlesStateUM = ArticlesStateUM.Content,
selectedArticleIndex = selectedIndex, selectedArticleIndex = selectedIndex,
onShareClick = { onShareClick = {
currentArticle?.let { currentArticle?.let {
@ -50,13 +52,13 @@ internal class NewsDetailsStateFactory(
onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens)) onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens))
} }
fun createRelatedTokensContent( fun createErrorState() {
items: List<MarketsListItemUM>, val currentState = currentStateProvider()
onTokenClick: (MarketsListItemUM) -> Unit, onStateUpdate(currentState.copy(articlesStateUM = ArticlesStateUM.LoadingError(onRetryClick)))
): RelatedTokensUM.Content { }
return RelatedTokensUM.Content(
items = items.toImmutableList(), fun createLoadingState() {
onTokenClick = onTokenClick, 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 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.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.chip.entity.ChipUM 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.GetNewsCategoriesUseCase
import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent 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.model.news.list.statemanager.NewsListBatchFlowManager
import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListState
import com.tangem.features.feed.ui.news.list.state.NewsListUM import com.tangem.features.feed.ui.news.list.state.NewsListUM
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider import com.tangem.utils.Provider
import com.tangem.utils.SupportedLanguages import com.tangem.utils.SupportedLanguages
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -39,6 +38,14 @@ internal class NewsListModel @Inject constructor(
private val selectedCategoryId = MutableStateFlow<Int?>(null) private val selectedCategoryId = MutableStateFlow<Int?>(null)
private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() 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 { private val batchFlowManager by lazy {
NewsListBatchFlowManager( NewsListBatchFlowManager(
getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase,
@ -61,6 +68,7 @@ internal class NewsListModel @Inject constructor(
params.onArticleClicked( params.onArticleClicked(
/* currentArticle */ articleId, /* currentArticle */ articleId,
/* prefetchedArticles */ getCurrentFetchedArticlesIds(), /* prefetchedArticles */ getCurrentFetchedArticlesIds(),
/* paginationConfig */ createNewsListConfig(),
) )
}, },
onBackClick = params.onBackClick, onBackClick = params.onBackClick,
@ -76,31 +84,7 @@ internal class NewsListModel @Inject constructor(
private fun loadCategories() { private fun loadCategories() {
modelScope.launch(dispatchers.default) { modelScope.launch(dispatchers.default) {
val allCategoriesChip = ChipUM( val filterChips = categoriesLoader.load()
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()
},
)
_state.update { currentState -> _state.update { currentState ->
currentState.copy(filters = filterChips) currentState.copy(filters = filterChips)
} }
@ -114,23 +98,16 @@ internal class NewsListModel @Inject constructor(
batchFlowManager.isInInitialLoadingErrorState, batchFlowManager.isInInitialLoadingErrorState,
batchFlowManager.paginationStatus, batchFlowManager.paginationStatus,
) { articles, isError, paginationStatus -> ) { articles, isError, paginationStatus ->
when { NewsListStateManager.calculateState(
isError -> NewsListState.LoadingError( articles = articles,
onRetryClicked = { isError = isError,
loadCategories() paginationStatus = paginationStatus,
batchFlowManager.reload() onRetryClick = {
}, loadCategories()
) to persistentListOf() batchFlowManager.reload()
paginationStatus is PaginationStatus.InitialLoading && articles.isEmpty() -> { },
NewsListState.Loading to persistentListOf() onLoadMore = { batchFlowManager.loadMore() },
} )
articles.isEmpty() -> {
NewsListState.Loading to persistentListOf()
}
else -> {
NewsListState.Content(loadMore = { batchFlowManager.loadMore() }) to articles
}
}
}.collect { (listState, articles) -> }.collect { (listState, articles) ->
_state.update { currentState -> _state.update { currentState ->
currentState.copy( currentState.copy(
@ -168,6 +145,15 @@ internal class NewsListModel @Inject constructor(
return state.value.listOfArticles.map { it.id } 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 { companion object {
private const val DEFAULT_ALL_NEWS_CATEGORIES_ID = -1 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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Suppress("LongParameterList") @Suppress("LongParameterList")
internal class NewsListBatchFlowManager( internal open class NewsListBatchFlowManager(
getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase,
private val currentLanguage: Provider<String>, private val currentLanguage: Provider<String>,
private val currentCategoryIds: Provider<List<Int>>, private val currentCategoryIds: Provider<List<Int>>,
private val modelScope: CoroutineScope, protected val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider, protected val dispatchers: CoroutineDispatcherProvider,
) { ) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, NewsListConfig, Nothing>>() private val actionsFlow = MutableSharedFlow<BatchAction<Int, NewsListConfig, Nothing>>()
private val converter by lazy { private val converter by lazy {
@ -41,6 +42,19 @@ internal class NewsListBatchFlowManager(
private val resultBatches = MutableStateFlow<List<Batch<Int, List<ArticleConfigUM>>>>(emptyList()) 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>> val uiItems: StateFlow<ImmutableList<ArticleConfigUM>>
get() = batchFlow.state get() = batchFlow.state
.map { batchListState -> .map { batchListState ->
@ -64,15 +78,14 @@ internal class NewsListBatchFlowManager(
initialValue = false, initialValue = false,
) )
val paginationStatus: StateFlow<PaginationStatus<List<ShortArticle>>> val paginationStatus: StateFlow<PaginationStatus<List<ShortArticle>>> = batchFlow.state
get() = batchFlow.state .map { it.status }
.map { it.status } .distinctUntilChanged()
.distinctUntilChanged() .stateIn(
.stateIn( scope = modelScope,
scope = modelScope, started = SharingStarted.Eagerly,
started = SharingStarted.Eagerly, initialValue = PaginationStatus.InitialLoading,
initialValue = PaginationStatus.InitialLoading, )
)
init { init {
batchFlow.state batchFlow.state

View file

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

View file

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

View file

@ -7,6 +7,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -203,6 +205,13 @@ private fun LazyListScope.loadingInfoBlocks() {
private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
item("related-news") { item("related-news") {
val listState = rememberLazyListState()
val articlesReadStatus = remember(relatedNews.articles) {
relatedNews.articles.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -220,7 +229,7 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(), state = listState,
) { ) {
items( items(
items = relatedNews.articles, 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.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR 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.UnableToLoadData
import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.buttons.SmallButtonConfig
@ -176,6 +177,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod
onClick = onShowTokensClick, onClick = onShowTokensClick,
), ),
) )
SpacerH12()
} }
} }

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.news.details package com.tangem.features.feed.ui.news.details
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -20,6 +21,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview 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.components.RelatedTokensBlock
import com.tangem.features.feed.ui.news.details.state.* import com.tangem.features.feed.ui.news.details.state.*
@Composable @Composable
internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) { internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value 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( val pagerState = rememberPagerState(
initialPage = state.selectedArticleIndex, initialPage = state.selectedArticleIndex,
pageCount = { state.articles.size }, pageCount = { state.articles.size },
@ -67,7 +102,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
} }
Column( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(background), .background(background),
) { ) {
@ -318,6 +353,7 @@ private fun PreviewNewsDetailsContent() {
TangemThemePreview { TangemThemePreview {
NewsDetailsContent( NewsDetailsContent(
state = NewsDetailsUM( state = NewsDetailsUM(
articlesStateUM = ArticlesStateUM.Content,
articles = MockArticlesFactory.createMockArticles(), articles = MockArticlesFactory.createMockArticles(),
selectedArticleIndex = 0, selectedArticleIndex = 0,
onShareClick = {}, 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 import kotlinx.collections.immutable.ImmutableList
internal data class NewsDetailsUM( internal data class NewsDetailsUM(
val articlesStateUM: ArticlesStateUM,
val articles: ImmutableList<ArticleUM>, val articles: ImmutableList<ArticleUM>,
val selectedArticleIndex: Int, val selectedArticleIndex: Int,
val onShareClick: () -> Unit, val onShareClick: () -> Unit,
@ -17,6 +18,13 @@ internal data class NewsDetailsUM(
val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading, 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( internal data class ArticleUM(
val id: Int, val id: Int,
val title: String, val title: String,