Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-16 13:11:59 +01:00
parent c2e67ba6bc
commit a02b13ae6a
19 changed files with 544 additions and 273 deletions

View file

@ -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,
),
)
}
}

View file

@ -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<String, List<ShortArticle>>
private typealias TrendingCache = Map<String, TrendingNews>
internal class DefaultTrendingNewsStore(
private val store: RuntimeSharedStore<TrendingCache>,
) : TrendingNewsStore {
override fun get(key: String): Flow<List<ShortArticle>> {
return store.get().map { it[key].orEmpty() }
override fun get(key: String): Flow<TrendingNews> {
return store.get().map { it[key] ?: TrendingNews.Data(emptyList()) }
}
override suspend fun getSyncOrNull(key: String): List<ShortArticle>? {
override suspend fun getSyncOrNull(key: String): TrendingNews? {
return store.getSyncOrNull()?.get(key)
}
override suspend fun store(key: String, value: List<ShortArticle>) {
override suspend fun store(key: String, value: TrendingNews) {
store.update(emptyMap()) { current ->
current + (key to value)
}

View file

@ -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<List<ShortArticle>>
fun get(key: String): Flow<TrendingNews>
suspend fun getSyncOrNull(key: String): List<ShortArticle>?
suspend fun getSyncOrNull(key: String): TrendingNews?
suspend fun store(key: String, value: List<ShortArticle>)
suspend fun store(key: String, value: TrendingNews)
suspend fun clear()
}

View file

@ -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<List<ShortArticle>> {
override fun observeTrendingNews(): Flow<TrendingNews> {
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<Int>, 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<ArticleCategory> {
@ -137,16 +138,46 @@ internal class DefaultNewsRepository(
}
}
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?): List<ShortArticle> {
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<NewsTrendingResponse> -> {
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)
}
}
}
}

View file

@ -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()
}

View file

@ -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<ShortArticle>) : TrendingNews
@Serializable
data class Error(val throwable: NewsError) : TrendingNews
}

View file

@ -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<List<ShortArticle>>
/**
* Refreshes trending news list and updates cache without overriding viewed status.
*/
suspend fun refreshTrendingNews(limit: Int, language: String?)
fun observeTrendingNews(): Flow<TrendingNews>
/**
* Updates viewed flag for provided trending articles.

View file

@ -11,7 +11,7 @@ import java.util.Locale
class FetchTrendingNewsUseCase(private val newsRepository: NewsRepository) {
suspend operator fun invoke(): Either<Throwable, Unit> = Either.catch {
newsRepository.getTrendingNews(
newsRepository.fetchTrendingNews(
limit = LIMIT_FOR_TRENDING_NEWS,
language = Locale.getDefault().language,
)

View file

@ -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<List<ShortArticle>> {
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<TrendingNews> {
return repository
.observeTrendingNews()
.distinctUntilChanged()
}
/**

View file

@ -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<FeedEntryChildFactory.Child, ComposableModularContentComponent>> = 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
}
}
}
internal interface FeedEntryClickIntents : FeedModelClickIntents

View file

@ -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),
)
}
}

View file

@ -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<FeedComponentModel>()
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(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)
}

View file

@ -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<DefaultFeedComponent.FeedParams>()
private var quotesUpdateJob: Job? = null
@ -59,42 +62,50 @@ internal class FeedComponentModel @Inject constructor(
dispatchers = dispatchers,
)
internal val state: StateFlow<FeedListUM>
field = MutableStateFlow<FeedListUM>(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<SortByTypeUM, Boolean>,
errorStatesByOrder: Map<SortByTypeUM, Boolean>,
) {
_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

View file

@ -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()
}

View file

@ -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())
}
}

View file

@ -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()
}
}

View file

@ -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<ArticleConfigUM>) : 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,
)
)
@Immutable
internal sealed interface GlobalFeedState {
data object Loading : GlobalFeedState
data object Content : GlobalFeedState
data class Error(val onRetryClicked: () -> Unit) : GlobalFeedState
}

View file

@ -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<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>,
@ -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<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,

View file

@ -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<ShortArticle>) {
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<ShortArticle>) {
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<ShortArticle>,
): Pair<ShortArticle?, List<ShortArticle>> {
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<LabelUM> {
val categoryLabels = article.categories.map { category ->
LabelUM(text = TextReference.Str(category.name))
}
val tokenLabels = article.relatedTokens.map { token ->
LabelUM(
text = TextReference.Str(token.symbol),
leadingContent = LabelLeadingContentUM.Token(
iconUrl = getTokenIconUrlFromDefaultHost(
tokenId = CryptoCurrency.RawID(token.id),
),
),
)
}
return (categoryLabels + tokenLabels).toPersistentSet()
}
private fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,