diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketChartLoadingError.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketChartLoadingError.kt deleted file mode 100644 index 2cbcf9ad7c..0000000000 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketChartLoadingError.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.tangem.common.ui.markets - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.buttons.SecondarySmallButton -import com.tangem.core.ui.components.buttons.SmallButtonConfig -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme - -@Composable -fun MarketChartLoadingError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) { - Column( - modifier = modifier.padding(vertical = 32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResourceSafe(R.string.common_unable_to_load), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.tertiary, - ) - SpacerH(12.dp) - SecondarySmallButton( - config = SmallButtonConfig( - text = TextReference.Res(R.string.try_to_load_data_again_button_title), - onClick = onRetryClick, - ), - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt index 23c1cad1db..157a5430e3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/DefaultTrendingNewsStore.kt @@ -1,25 +1,25 @@ package com.tangem.datasource.local.news.trending import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -private typealias TrendingCache = Map> +private typealias TrendingCache = Map internal class DefaultTrendingNewsStore( private val store: RuntimeSharedStore, ) : TrendingNewsStore { - override fun get(key: String): Flow> { - return store.get().map { it[key].orEmpty() } + override fun get(key: String): Flow { + return store.get().map { it[key] ?: TrendingNews.Data(emptyList()) } } - override suspend fun getSyncOrNull(key: String): List? { + override suspend fun getSyncOrNull(key: String): TrendingNews? { return store.getSyncOrNull()?.get(key) } - override suspend fun store(key: String, value: List) { + override suspend fun store(key: String, value: TrendingNews) { store.update(emptyMap()) { current -> current + (key to value) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt index 24afaabda0..643ad55ee6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/trending/TrendingNewsStore.kt @@ -1,15 +1,15 @@ package com.tangem.datasource.local.news.trending -import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews import kotlinx.coroutines.flow.Flow interface TrendingNewsStore { - fun get(key: String): Flow> + fun get(key: String): Flow - suspend fun getSyncOrNull(key: String): List? + suspend fun getSyncOrNull(key: String): TrendingNews? - suspend fun store(key: String, value: List) + suspend fun store(key: String, value: TrendingNews) suspend fun clear() } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 426bc6f287..09cc6015b6 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -1,15 +1,16 @@ package com.tangem.data.news.repository +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.news.NewsApi +import com.tangem.datasource.api.news.models.response.NewsTrendingResponse import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.domain.models.news.* import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext import com.tangem.domain.news.model.NewsListConfig -import com.tangem.domain.models.news.ArticleCategory -import com.tangem.domain.models.news.DetailedArticle -import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.news.repository.NewsRepository import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.BatchListSource @@ -24,7 +25,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import kotlin.collections.orEmpty +import timber.log.Timber /** * Implementation of [NewsRepository]. @@ -67,26 +68,26 @@ internal class DefaultNewsRepository( fetchDetailedArticlesInternal(newsIds = newsIds, language = language) } - override suspend fun getTrendingNews(limit: Int, language: String?) { + override suspend fun fetchTrendingNews(limit: Int, language: String?) { fetchAndStoreTrendingNews(limit = limit, language = language) } - override fun observeTrendingNews(): Flow> { + override fun observeTrendingNews(): Flow { return trendingNewsStore.get(TRENDING_NEWS_KEY) } - override suspend fun refreshTrendingNews(limit: Int, language: String?) { - fetchAndStoreTrendingNews(limit = limit, language = language) - } - override suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) { if (articleIds.isEmpty()) return - val current = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty() - if (current.isEmpty()) return + val currentResult = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) ?: return + val currentArticles = when (currentResult) { + is TrendingNews.Data -> currentResult.articles + is TrendingNews.Error -> return + } + if (currentArticles.isEmpty()) return val ids = articleIds.toSet() - val updated = current.map { article -> + val updated = currentArticles.map { article -> if (article.id in ids) { article.copy(viewed = viewed) } else { @@ -94,7 +95,7 @@ internal class DefaultNewsRepository( } } - trendingNewsStore.store(TRENDING_NEWS_KEY, updated) + trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated)) } override suspend fun getCategories(): List { @@ -137,16 +138,46 @@ internal class DefaultNewsRepository( } } - private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?): List { + private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) { return withContext(dispatchers.io) { - val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow() - val freshArticles = response.items.map { it.toDomainShortArticle() } - val currentArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty() - val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit) - - trendingNewsStore.store(TRENDING_NEWS_KEY, merged) - - merged + val apiResponse = newsApi.getTrendingNews(limit = limit, language = language) + when (val result = apiResponse) { + is ApiResponse.Error -> { + Timber.e( + result.cause.cause, + "Trending news fetch failed cause: ${ + when (val error = result.cause) { + is ApiResponseError.HttpException -> error.code + is ApiResponseError.NetworkException -> "NetworkException" + is ApiResponseError.TimeoutException -> "TimeoutException" + is ApiResponseError.UnknownException -> "UnknownException" + } + }", + ) + trendingNewsStore.clear() + trendingNewsStore.store( + key = TRENDING_NEWS_KEY, + value = TrendingNews.Error( + NewsError.Unknown( + message = result.cause.message, + code = null, + ), + ), + ) + } + is ApiResponse.Success -> { + val freshArticles = result.data.items.map { it.toDomainShortArticle() } + val cachedArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) + val currentArticles = when (cachedArticles) { + is TrendingNews.Data -> cachedArticles.articles + is TrendingNews.Error -> emptyList() + null -> emptyList() + } + val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit) + trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(merged)) + TrendingNews.Data(merged) + } + } } } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt new file mode 100644 index 0000000000..5055eacbbf --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/NewsError.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.models.news + +import kotlinx.serialization.Serializable + +@Serializable +sealed class NewsError { + abstract val message: String? + abstract val code: Int? + + data class ArticleNotFound( + override val message: String?, + override val code: Int?, + ) : NewsError() + + data class Unknown( + override val message: String?, + override val code: Int?, + ) : NewsError() +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt new file mode 100644 index 0000000000..d3b0bbdf87 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/news/TrendingNews.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.models.news + +import kotlinx.serialization.Serializable + +/** + * Represents the result of fetching news. + * Can contain either data or an error. + */ +@Serializable +sealed interface TrendingNews { + @Serializable + data class Data(val articles: List) : TrendingNews + + @Serializable + data class Error(val throwable: NewsError) : TrendingNews +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 7db6669b0d..3b9e06ce82 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -1,10 +1,10 @@ package com.tangem.domain.news.repository -import com.tangem.domain.news.model.NewsListBatchFlow -import com.tangem.domain.news.model.NewsListBatchingContext import com.tangem.domain.models.news.ArticleCategory import com.tangem.domain.models.news.DetailedArticle -import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews +import com.tangem.domain.news.model.NewsListBatchFlow +import com.tangem.domain.news.model.NewsListBatchingContext import kotlinx.coroutines.flow.Flow /** @@ -43,17 +43,12 @@ interface NewsRepository { * @param limit * @param language current device locale */ - suspend fun getTrendingNews(limit: Int, language: String?) + suspend fun fetchTrendingNews(limit: Int, language: String?) /** * Observes trending news with runtime viewed flag support. */ - fun observeTrendingNews(): Flow> - - /** - * Refreshes trending news list and updates cache without overriding viewed status. - */ - suspend fun refreshTrendingNews(limit: Int, language: String?) + fun observeTrendingNews(): Flow /** * Updates viewed flag for provided trending articles. diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt index 319f111625..fc69c6ba03 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt @@ -11,7 +11,7 @@ import java.util.Locale class FetchTrendingNewsUseCase(private val newsRepository: NewsRepository) { suspend operator fun invoke(): Either = Either.catch { - newsRepository.getTrendingNews( + newsRepository.fetchTrendingNews( limit = LIMIT_FOR_TRENDING_NEWS, language = Locale.getDefault().language, ) diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt index 55d69e8875..6d76aaa9f6 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt @@ -1,8 +1,9 @@ package com.tangem.domain.news.usecase -import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews import com.tangem.domain.news.repository.NewsRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged /** * Exposes trending news as a cached flow and provides helpers to refresh or mark items as viewed. @@ -12,17 +13,12 @@ import kotlinx.coroutines.flow.Flow class ManageTrendingNewsUseCase(private val repository: NewsRepository) { /** - * Observes the current cached list of trending articles (max 10 items). + * Observes the current cached list of trending articles (max 10 items) or error. */ - operator fun invoke(): Flow> { - return repository.observeTrendingNews() - } - - /** - * Forces refresh from backend while preserving local `viewed` flags. - */ - suspend fun refresh(limit: Int, language: String?) { - repository.refreshTrendingNews(limit, language) + fun observeTrendingNews(): Flow { + return repository + .observeTrendingNews() + .distinctUntilChanged() } /** diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index d27de2ff81..dbcfde3de2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -22,7 +22,9 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.entry.components.FeedEntryComponent +import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.ui.EntryBottomSheetContent +import com.tangem.features.feed.ui.market.state.SortByTypeUM import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -40,6 +42,36 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( popCallback = { onChildBack() }, ) + private val clickIntents = object : FeedEntryClickIntents { + override fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) { + innerRouter.push( + route = FeedEntryChildFactory.Child.TokenDetails( + params = DefaultMarketsTokenDetailsComponent.Params( + token = token, + appCurrency = appCurrency, + shouldShowPortfolio = true, + analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams( + blockchain = null, + source = "Market", + ), + ), + ), + ) + } + + override fun onMarketOpenClick(sortBy: SortByTypeUM) { + innerRouter.push(FeedEntryChildFactory.Child.TokenList) + } + + override fun onArticleClick(articleId: Int) { + innerRouter.push(FeedEntryChildFactory.Child.NewsDetails) + } + + override fun onOpenAllNews() { + innerRouter.push(FeedEntryChildFactory.Child.NewsList) + } + } + private val stack: Value> = childStack( key = "main", source = stackNavigation, @@ -53,7 +85,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( componentContext = factoryContext, router = innerRouter, ), - onTokenClick = ::marketsListTokenSelected, + feedEntryClickIntents = clickIntents, ) }, ) @@ -76,22 +108,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ) } - private fun marketsListTokenSelected(token: TokenMarketParams, appCurrency: AppCurrency) { - innerRouter.push( - route = FeedEntryChildFactory.Child.TokenDetails( - params = DefaultMarketsTokenDetailsComponent.Params( - token = token, - appCurrency = appCurrency, - shouldShowPortfolio = true, - analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams( - blockchain = null, - source = "Market", - ), - ), - ), - ) - } - private fun onChildBack() { if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) { stackNavigation.popWhile { it != FeedEntryChildFactory.Child.Feed } @@ -102,4 +118,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( interface Factory : FeedEntryComponent.Factory { override fun create(context: AppComponentContext): DefaultFeedEntryComponent } -} \ No newline at end of file +} + +internal interface FeedEntryClickIntents : FeedModelClickIntents \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 6483e57566..081ec2ab4d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -4,8 +4,6 @@ import androidx.compose.runtime.Immutable import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route import com.tangem.core.ui.decompose.ComposableModularContentComponent -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent @@ -44,7 +42,7 @@ internal class FeedEntryChildFactory @Inject constructor() { fun createChild( child: Child, appComponentContext: AppComponentContext, - onTokenClick: (TokenMarketParams, AppCurrency) -> Unit, + feedEntryClickIntents: FeedEntryClickIntents, ): ComposableModularContentComponent { return when (child) { is Child.TokenDetails -> { @@ -56,7 +54,12 @@ internal class FeedEntryChildFactory @Inject constructor() { is Child.TokenList -> { DefaultMarketsTokenListComponent( appComponentContext = appComponentContext, - onTokenClick = onTokenClick, + onTokenClick = { token, appCurrency -> + feedEntryClickIntents.onMarketItemClick( + token, + appCurrency, + ) + }, ) } Child.NewsDetails -> { @@ -72,6 +75,7 @@ internal class FeedEntryChildFactory @Inject constructor() { Child.Feed -> { DefaultFeedComponent( appComponentContext = appComponentContext, + params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents), ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 910542c05a..0b6ca24074 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -3,19 +3,22 @@ package com.tangem.features.feed.components.feed import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.features.feed.model.feed.FeedComponentModel -import com.tangem.features.feed.ui.feed.FeedListContent +import com.tangem.features.feed.model.feed.FeedModelClickIntents +import com.tangem.features.feed.ui.feed.FeedList import com.tangem.features.feed.ui.feed.FeedListHeader internal class DefaultFeedComponent( appComponentContext: AppComponentContext, + private val params: FeedParams, ) : ComposableModularContentComponent, AppComponentContext by appComponentContext { - private val feedComponentModel = getOrCreateModel() + private val feedComponentModel = getOrCreateModel(params = params) @Composable override fun Title() { @@ -25,8 +28,15 @@ internal class DefaultFeedComponent( @Composable override fun Content(modifier: Modifier) { + LifecycleStartEffect(Unit) { + feedComponentModel.isVisibleOnScreen.value = true + onStopOrDispose { + feedComponentModel.isVisibleOnScreen.value = false + } + } + val state by feedComponentModel.state.collectAsStateWithLifecycle() - FeedListContent( + FeedList( modifier = modifier, state = state, ) @@ -34,4 +44,6 @@ internal class DefaultFeedComponent( @Composable override fun Footer() = Unit + + data class FeedParams(val feedClickIntents: FeedModelClickIntents) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 6467b4cc3c..ee02fa8424 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse 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.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.DateTimeFormatters @@ -11,8 +12,10 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase import com.tangem.domain.markets.TokenMarketListConfig +import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase +import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.feed.state.* import com.tangem.features.feed.ui.market.state.SortByTypeUM @@ -37,10 +40,10 @@ internal class FeedComponentModel @Inject constructor( private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + paramsContainer: ParamsContainer, ) : Model() { - private val _state = MutableStateFlow(initialState()) - val state = _state.asStateFlow() + private val params = paramsContainer.require() private var quotesUpdateJob: Job? = null @@ -59,42 +62,50 @@ internal class FeedComponentModel @Inject constructor( dispatchers = dispatchers, ) + internal val state: StateFlow + field = MutableStateFlow(initialState()) + private val searchBarStateFactory by lazy(LazyThreadSafetyMode.NONE) { SearchBarStateFactory( - currentStateProvider = Provider { _state.value }, - onStateUpdate = { newState -> _state.update { newState } }, + currentStateProvider = Provider { state.value }, + onStateUpdate = { newState -> state.update { newState } }, ) } private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) { TrendingNewsStateFactory( - currentStateProvider = Provider { _state.value }, - onStateUpdate = { newState -> _state.update { newState } }, + currentStateProvider = Provider { state.value }, + onStateUpdate = { newState -> state.update { newState } }, ) } + val isVisibleOnScreen = MutableStateFlow(false) + init { + updateCallbacks() + modelScope.launch(dispatchers.default) { fetchTrendingNewsUseCase() - subscribeOnTrendingNews() - } - _state.update { feedListUM -> - feedListUM.copy( - searchBar = _state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange), - feedListCallbacks = feedListUM.feedListCallbacks.copy( - onSortTypeClick = ::onSortTypeClick, - ), - ) } modelScope.launch(dispatchers.default) { combine( - marketsBatchFlowManager.itemsByOrder, - marketsBatchFlowManager.loadingStatesByOrder, - marketsBatchFlowManager.errorStatesByOrder, - ) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder -> + flow = marketsBatchFlowManager.itemsByOrder, + flow2 = marketsBatchFlowManager.loadingStatesByOrder, + flow3 = marketsBatchFlowManager.errorStatesByOrder, + flow4 = manageTrendingNewsUseCase.observeTrendingNews(), + ) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult -> updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder) - val currentSortType = _state.value.marketChartConfig.currentSortByType + trendingNewsStateFactory.updateTrendingNewsState( + result = trendingNewsResult, + onRetryClicked = { + modelScope.launch(dispatchers.default) { + fetchTrendingNewsUseCase.invoke() + } + }, + ) + updateGlobalState() + val currentSortType = state.value.marketChartConfig.currentSortByType val items = itemsByOrder[currentSortType] val isLoading = loadingStatesByOrder[currentSortType] == true if (items != null && items.isNotEmpty() && !isLoading) { @@ -122,12 +133,6 @@ internal class FeedComponentModel @Inject constructor( } } - private suspend fun subscribeOnTrendingNews() { - manageTrendingNewsUseCase().collect { articles -> - trendingNewsStateFactory.updateTrendingNewsState(articles) - } - } - private fun initialState(): FeedListUM { return FeedListUM( currentDate = getCurrentDate(), @@ -151,11 +156,12 @@ internal class FeedComponentModel @Inject constructor( marketChartConfig = MarketChartConfig( marketCharts = buildMap { SortByTypeUM.entries.forEach { - put(it, MarketChartUM.LoadingError(onRetryClicked = marketsBatchFlowManager::reloadAll)) + put(it, MarketChartUM.Loading) } }.toPersistentHashMap(), currentSortByType = SortByTypeUM.Trending, ), + globalState = GlobalFeedState.Loading, ) } @@ -169,7 +175,7 @@ internal class FeedComponentModel @Inject constructor( loadingStatesByOrder: Map, errorStatesByOrder: Map, ) { - _state.update { currentState -> + state.update { currentState -> val newMarketCharts = buildMap { SortByTypeUM.entries.forEach { sortByType -> val items = itemsByOrder[sortByType] ?: persistentListOf() @@ -224,8 +230,41 @@ internal class FeedComponentModel @Inject constructor( } } + private fun updateGlobalState() { + state.update { currentState -> + val newsState = currentState.news + val marketCharts = currentState.marketChartConfig.marketCharts + + val isNewsLoading = newsState is NewsUM.Loading + val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading } + + val isNewsError = newsState is NewsUM.Error + val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError } + + val newGlobalState = when { + isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading + isNewsError && areAllChartsError -> GlobalFeedState.Error( + onRetryClicked = { + modelScope.launch(dispatchers.default) { + fetchTrendingNewsUseCase.invoke() + marketsBatchFlowManager.reloadAll() + } + }, + ) + else -> GlobalFeedState.Content + } + + val currentGlobalState = currentState.globalState + if (currentGlobalState::class != newGlobalState::class) { + currentState.copy(globalState = newGlobalState) + } else { + currentState + } + } + } + private fun onSortTypeClick(sortByType: SortByTypeUM) { - _state.update { currentState -> + state.update { currentState -> val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) -> when (chart) { is MarketChartUM.Content -> { @@ -256,11 +295,41 @@ internal class FeedComponentModel @Inject constructor( quotesUpdateJob = modelScope.launch { while (true) { delay(DELAY_TO_FETCH_QUOTES) + isVisibleOnScreen.first { it } marketsBatchFlowManager.updateQuotes() } } } + private fun updateCallbacks() { + state.update { feedListUM -> + feedListUM.copy( + searchBar = state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange), + feedListCallbacks = feedListUM.feedListCallbacks.copy( + onSortTypeClick = ::onSortTypeClick, + onMarketItemClick = { item -> + val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id) + if (tokenMarket != null) { + params.feedClickIntents.onMarketItemClick( + token = tokenMarket.toSerializableParam(), + appCurrency = currentAppCurrency.value, + ) + } + }, + onMarketOpenClick = { sortBy -> + params.feedClickIntents.onMarketOpenClick(sortBy) + }, + onArticleClick = { articleId -> + params.feedClickIntents.onArticleClick(articleId) + }, + onOpenAllNews = { + params.feedClickIntents.onOpenAllNews() + }, + ), + ) + } + } + private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order { return when (this) { SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt new file mode 100644 index 0000000000..7e518f9c26 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -0,0 +1,15 @@ +package com.tangem.features.feed.model.feed + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.ui.market.state.SortByTypeUM + +/** + * Callback interface for feed model navigation actions. + */ +internal interface FeedModelClickIntents { + fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) + fun onMarketOpenClick(sortBy: SortByTypeUM) + fun onArticleClick(articleId: Int) + fun onOpenAllNews() +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 6a6b6b2b3f..c35da92100 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -1,9 +1,6 @@ package com.tangem.features.feed.ui.feed -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith +import androidx.compose.animation.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -31,7 +28,6 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach -import com.tangem.common.ui.markets.MarketChartLoadingError import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.common.ui.markets.models.MarketsListItemUM @@ -40,6 +36,7 @@ import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.buttons.SecondarySmallButton @@ -74,77 +71,123 @@ internal fun FeedListHeader(searchBarUM: SearchBarUM, modifier: Modifier = Modif } @Composable -internal fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) { +internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value + AnimatedContent( + modifier = modifier, + targetState = state.globalState, + ) { animatedState -> + when (animatedState) { + is GlobalFeedState.Loading -> { + FeeListLoading( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .drawBehind { drawRect(background) }, + ) + } + is GlobalFeedState.Error -> { + FeedListGlobalError( + onRetryClick = animatedState.onRetryClicked, + modifier = Modifier.drawBehind { drawRect(background) }, + ) + } + is GlobalFeedState.Content -> { + FeeListContent( + modifier = Modifier, + state = state, + ) + } + } + } +} + +@Composable +private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) { + val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .drawBehind { drawRect(background) }, ) { - SpacerH(20.dp) - - Text( + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - text = stringResourceSafe(R.string.feed_market_and_news), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - text = state.currentDate, - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.tertiary, - ) + .fillMaxSize() + .padding(WindowInsets.navigationBars.asPaddingValues()), + ) { + SpacerH(20.dp) - SpacerH(32.dp) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = stringResourceSafe(R.string.feed_market_and_news), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = state.currentDate, + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.tertiary, + ) - state.marketChartConfig.marketCharts[SortByTypeUM.Rating]?.let { marketChartUM -> - MarketBlock( - marketChart = marketChartUM, + SpacerH(32.dp) + + AnimatedVisibility(state.marketChartConfig.marketCharts[SortByTypeUM.Rating] != null) { + val marketChart = remember(state.marketChartConfig.marketCharts[SortByTypeUM.Rating]) { + state.marketChartConfig.marketCharts[SortByTypeUM.Rating] + } + if (marketChart != null) { + MarketBlock( + marketChart = marketChart, + feedListCallbacks = state.feedListCallbacks, + ) + } + } + + NewsBlock( + news = state.news, + feedListCallbacks = state.feedListCallbacks, + trendingArticle = state.trendingArticle, + ) + + MarketPulseBlock( + marketChartConfig = state.marketChartConfig, feedListCallbacks = state.feedListCallbacks, ) } - - NewsBlock( - news = state.news, - feedListCallbacks = state.feedListCallbacks, - trendingArticle = state.trendingArticle, - ) - - MarketPulseBlock( - marketChartConfig = state.marketChartConfig, - feedListCallbacks = state.feedListCallbacks, - ) } } @Composable private fun MarketBlock(marketChart: MarketChartUM, feedListCallbacks: FeedListCallbacks) { - Header( - title = { - Text( - text = stringResourceSafe(R.string.markets_common_title), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) - }, - onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) }, - ) + Column(modifier = Modifier.fillMaxWidth()) { + Header( + title = { + Text( + text = stringResourceSafe(R.string.markets_common_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + }, + onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) }, + ) - SpacerH(12.dp) + SpacerH(12.dp) - Charts( - onItemClick = feedListCallbacks.onMarketItemClick, - modifier = Modifier.padding(horizontal = 16.dp), - marketChart = marketChart, - ) - SpacerH(32.dp) + Charts( + onItemClick = feedListCallbacks.onMarketItemClick, + modifier = Modifier.padding(horizontal = 16.dp), + marketChart = marketChart, + ) + + SpacerH(32.dp) + } } @Composable @@ -219,6 +262,9 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi NewsUM.Loading -> { NewsLoadingBlock() } + is NewsUM.Error -> { + NewsErrorBlock(onRetryClick = newsUM.onRetryClicked) + } } } } @@ -342,9 +388,9 @@ private fun Charts( } } is MarketChartUM.LoadingError -> { - MarketChartLoadingError( - modifier = Modifier.fillMaxWidth(), + UnableToLoadData( onRetryClick = marketChart.onRetryClicked, + modifier = Modifier.fillMaxWidth(), ) } is MarketChartUM.Content -> { @@ -392,6 +438,43 @@ private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick: } } +@Composable +private fun FeedListGlobalError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) { + val background = LocalMainBottomSheetColor.current.value + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { drawRect(background) } + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onRetryClick) + } +} + +@Composable +private fun NewsErrorBlock(onRetryClick: () -> Unit) { + Column { + Header( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResourceSafe(R.string.common_news), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + } + }, + onSeeAllClick = {}, + ) + SpacerH(12.dp) + UnableToLoadData( + onRetryClick = onRetryClick, + modifier = Modifier.fillMaxWidth(), + ) + } +} + private const val DEFAULT_CHART_SIZE_IN_MARKET = 5 private const val GRADIENT_START = 0f private const val GRADIENT_END = 0.5f @@ -402,6 +485,6 @@ private val LinearGradientSecondPart = Color(0xFFE05AED) @Composable private fun FeedListPreview() { TangemThemePreview { - FeedListContent(state = createFeedPreviewState()) + FeedList(state = createFeedPreviewState()) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt index 0d936738f5..2e24ff3ec2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt @@ -16,6 +16,21 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.res.TangemThemePreview +@Composable +internal fun FeeListLoading(modifier: Modifier = Modifier) { + Column(modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.navigationBars.asPaddingValues()), + ) { + MarketLoadingBlock() + NewsLoadingBlock() + MarketPulseLoadingBlock() + } + } +} + @Composable internal fun MarketLoadingBlock() { RectangleShimmer( @@ -23,15 +38,19 @@ internal fun MarketLoadingBlock() { .padding(start = 16.dp) .size(width = 104.dp, height = 18.dp), ) - SpacerH(12.dp) + SpacerH(15.dp) ChartsLoading(modifier = Modifier.padding(horizontal = 16.dp)) - SpacerH(32.dp) + SpacerH(35.dp) } @Composable internal fun MarketPulseLoadingBlock() { - RectangleShimmer() - SpacerH(8.dp) + RectangleShimmer( + modifier = Modifier + .padding(start = 16.dp) + .size(width = 104.dp, height = 18.dp), + ) + SpacerH(15.dp) LazyRow( modifier = Modifier.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -43,7 +62,7 @@ internal fun MarketPulseLoadingBlock() { RectangleShimmer(modifier = Modifier.size(width = 124.dp, height = 36.dp)) } } - SpacerH(12.dp) + SpacerH(16.dp) ChartsLoading(modifier = Modifier.padding(horizontal = 16.dp)) SpacerH(32.dp) } @@ -51,8 +70,12 @@ internal fun MarketPulseLoadingBlock() { @Composable internal fun NewsLoadingBlock() { Column { - RectangleShimmer() - SpacerH(12.dp) + RectangleShimmer( + modifier = Modifier + .padding(start = 16.dp) + .size(width = 104.dp, height = 18.dp), + ) + SpacerH(15.dp) TrendingLoadingArticle(modifier = Modifier.padding(horizontal = 16.dp)) SpacerH(12.dp) LazyRow( @@ -65,6 +88,7 @@ internal fun NewsLoadingBlock() { DefaultLoadingArticle() } } + SpacerH(35.dp) } } @@ -90,10 +114,8 @@ private const val DEFAULT_CHART_SIZE_IN_MARKET = 5 private fun FeedListLoadingPreview() { TangemThemePreview { Column { - NewsLoadingBlock() - SpacerH(10.dp) MarketLoadingBlock() - SpacerH(10.dp) + NewsLoadingBlock() MarketPulseLoadingBlock() } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 519472ae49..3b613ccc86 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -16,6 +16,7 @@ internal data class FeedListUM( val news: NewsUM, val trendingArticle: ArticleConfigUM?, val marketChartConfig: MarketChartConfig, + val globalState: GlobalFeedState = GlobalFeedState.Content, ) internal data class FeedListCallbacks( @@ -31,6 +32,7 @@ internal data class FeedListCallbacks( internal sealed interface NewsUM { data object Loading : NewsUM data class Content(val content: ImmutableList) : NewsUM + data class Error(val onRetryClicked: () -> Unit) : NewsUM } internal data class MarketChartConfig( @@ -56,4 +58,11 @@ internal sealed interface MarketChartUM { internal data class SortChartConfigUM( val sortByType: SortByTypeUM, val isSelected: Boolean, -) \ No newline at end of file +) + +@Immutable +internal sealed interface GlobalFeedState { + data object Loading : GlobalFeedState + data object Content : GlobalFeedState + data class Error(val onRetryClicked: () -> Unit) : GlobalFeedState +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt index bdda39948d..7fa17ca9f5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt @@ -3,6 +3,7 @@ package com.tangem.features.feed.ui.feed.state import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.model.converter.MarketsTokenItemConverter import com.tangem.features.feed.ui.market.state.MarketsListUM import com.tangem.features.feed.ui.market.state.SortByTypeUM @@ -126,6 +127,11 @@ internal class FeedMarketsBatchFlowManager( return managersByOrder[order]?.onLastBatchLoadedSuccess } + fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? { + return managersByOrder.values + .firstNotNullOfOrNull { manager -> manager.getTokenMarketById(tokenId) } + } + private class SingleOrderManager( val order: TokenMarketListConfig.Order, private val actionsFlow: MutableSharedFlow>, @@ -359,6 +365,13 @@ internal class FeedMarketsBatchFlowManager( } } + fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? { + return resultBatches.value.processedItems + ?.asSequence() + ?.flatMap { it.data } + ?.firstOrNull { it.id == tokenId } + } + private data class ResultBatches( val uiBatches: List>> = emptyList(), val processedItems: List>>? = null, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt index f096546b37..15aee2ff6a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt @@ -11,6 +11,7 @@ import com.tangem.core.ui.utils.getFormattedDate import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.models.news.TrendingNews import com.tangem.features.feed.impl.R import com.tangem.utils.Provider import com.tangem.utils.StringsSigns @@ -23,71 +24,78 @@ internal class TrendingNewsStateFactory( private val onStateUpdate: (FeedListUM) -> Unit, ) { - fun updateTrendingNewsState(news: List) { - val trendingArticleIndex = news.indexOfFirst { it.isTrending } - val trendingArticle = if (trendingArticleIndex != -1) news[trendingArticleIndex] else null - val commonArticles = if (trendingArticleIndex != -1) { - news.toMutableList().apply { removeAt(trendingArticleIndex) } - } else { - news - } + fun updateTrendingNewsState(result: TrendingNews, onRetryClicked: () -> Unit) { val currentState = currentStateProvider() + when (result) { + is TrendingNews.Data -> handleDataState(currentState, result.articles) + is TrendingNews.Error -> handleErrorState(currentState, onRetryClicked) + } + } + + private fun handleDataState(currentState: FeedListUM, articles: List) { + val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) + onStateUpdate( currentState.copy( - trendingArticle = trendingArticle?.let { article -> - ArticleConfigUM( - id = article.id, - title = article.title, - score = article.score, - isTrending = true, - tags = article.categories.map { category -> - LabelUM(text = TextReference.Str(category.name)) - }.plus( - article.relatedTokens.map { token -> - LabelUM( - text = TextReference.Str(token.symbol), - leadingContent = LabelLeadingContentUM.Token( - iconUrl = getTokenIconUrlFromDefaultHost( - tokenId = CryptoCurrency.RawID(token.id), - ), - ), - ) - }, - ).toPersistentSet(), - createdAt = mapFormattedDate(article.createdAt), - isViewed = article.viewed, - ) - }, + trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) }, news = NewsUM.Content( - commonArticles.map { article -> - ArticleConfigUM( - id = article.id, - title = article.title, - score = article.score, - isTrending = false, - tags = article.categories.map { category -> - LabelUM(text = TextReference.Str(category.name)) - }.plus( - article.relatedTokens.map { token -> - LabelUM( - text = TextReference.Str(token.symbol), - leadingContent = LabelLeadingContentUM.Token( - iconUrl = getTokenIconUrlFromDefaultHost( - tokenId = CryptoCurrency.RawID(token.id), - ), - ), - ) - }, - ).toPersistentSet(), - createdAt = mapFormattedDate(article.createdAt), - isViewed = article.viewed, - ) - }.toPersistentList(), + commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList(), ), ), ) } + private fun handleErrorState(currentState: FeedListUM, onRetryClicked: () -> Unit) { + onStateUpdate( + currentState.copy( + trendingArticle = null, + news = NewsUM.Error(onRetryClicked = onRetryClicked), + ), + ) + } + + private fun separateTrendingAndCommonArticles( + articles: List, + ): Pair> { + val trendingArticleIndex = articles.indexOfFirst { it.isTrending } + return if (trendingArticleIndex != -1) { + val trendingArticle = articles[trendingArticleIndex] + val commonArticles = articles.toMutableList().apply { removeAt(trendingArticleIndex) } + trendingArticle to commonArticles + } else { + null to articles + } + } + + private fun mapToArticleConfigUM(article: ShortArticle, isTrending: Boolean): ArticleConfigUM { + return ArticleConfigUM( + id = article.id, + title = article.title, + score = article.score, + isTrending = isTrending, + tags = buildArticleTags(article), + createdAt = mapFormattedDate(article.createdAt), + isViewed = article.viewed, + ) + } + + private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet { + val categoryLabels = article.categories.map { category -> + LabelUM(text = TextReference.Str(category.name)) + } + val tokenLabels = article.relatedTokens.map { token -> + LabelUM( + text = TextReference.Str(token.symbol), + leadingContent = LabelLeadingContentUM.Token( + iconUrl = getTokenIconUrlFromDefaultHost( + tokenId = CryptoCurrency.RawID(token.id), + ), + ), + ) + } + return (categoryLabels + tokenLabels).toPersistentSet() + } + private fun mapFormattedDate(createdAt: String): TextReference { val formattedDate = getFormattedDate( createdAt = createdAt,