Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-14 13:29:32 +01:00
parent 3ed3dc91b1
commit 6f959bcd3f
21 changed files with 641 additions and 212 deletions

View file

@ -2,6 +2,9 @@ package com.tangem.features.feed.model.feed
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -16,13 +19,16 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager
import com.tangem.features.feed.model.feed.state.TrendingNewsStateFactory
import com.tangem.features.feed.model.feed.state.FeedStateController
import com.tangem.features.feed.model.feed.state.transformers.UpdateGlobalFeedStateTransformer
import com.tangem.features.feed.model.feed.state.transformers.UpdateMarketChartsTransformer
import com.tangem.features.feed.model.feed.state.transformers.UpdateTrendingNewsStateTransformer
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentHashMap
import kotlinx.coroutines.Job
@ -32,18 +38,19 @@ import kotlinx.coroutines.launch
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import javax.inject.Inject
import kotlin.collections.all
import kotlin.collections.map
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class FeedComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase,
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer,
private val stateController: FeedStateController,
) : Model() {
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
@ -66,23 +73,12 @@ internal class FeedComponentModel @Inject constructor(
)
internal val state: StateFlow<FeedListUM>
field = MutableStateFlow<FeedListUM>(initialState())
private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) {
TrendingNewsStateFactory(
currentStateProvider = Provider { state.value },
onStateUpdate = { newState -> state.update { newState } },
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
)
}
get() = stateController.uiState
val isVisibleOnScreen = MutableStateFlow(false)
init {
initializeState()
updateCallbacks()
modelScope.launch(dispatchers.default) {
@ -96,10 +92,50 @@ internal class FeedComponentModel @Inject constructor(
flow3 = marketsBatchFlowManager.errorStatesByOrder,
flow4 = manageTrendingNewsUseCase.observeTrendingNews(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult ->
updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder)
trendingNewsStateFactory.updateTrendingNewsState(result = trendingNewsResult)
updateGlobalState()
val currentSortType = state.value.marketChartConfig.currentSortByType
val globalStateTransformer = UpdateGlobalFeedStateTransformer(
loadingStatesByOrder = loadingStatesByOrder,
errorStatesByOrder = errorStatesByOrder,
trendingNewsResult = trendingNewsResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
stateController.update { currentState ->
currentState.copy(globalState = GlobalFeedState.Loading)
}
fetchTrendingNewsUseCase.invoke()
marketsBatchFlowManager.reloadAll()
}
},
analyticsEventHandler = analyticsEventHandler,
)
val currentState = stateController.value
val newGlobalState = globalStateTransformer.transform(currentState)
val shouldSkipIndividualStates = newGlobalState.globalState !is GlobalFeedState.Content
stateController.update(globalStateTransformer)
if (!shouldSkipIndividualStates) {
stateController.updateAll(
UpdateMarketChartsTransformer(
itemsByOrder = itemsByOrder,
loadingStatesByOrder = loadingStatesByOrder,
errorStatesByOrder = errorStatesByOrder,
onReloadAll = { marketsBatchFlowManager.reloadAll() },
analyticsEventHandler = analyticsEventHandler,
),
UpdateTrendingNewsStateTransformer(
result = trendingNewsResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
analyticsEventHandler = analyticsEventHandler,
),
)
}
val currentSortType = stateController.value.marketChartConfig.currentSortByType
val items = itemsByOrder[currentSortType]
val isLoading = loadingStatesByOrder[currentSortType] == true
if (items != null && items.isNotEmpty() && !isLoading) {
@ -127,6 +163,10 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun initializeState() {
stateController.update { initialState() }
}
private fun initialState(): FeedListUM {
return FeedListUM(
currentDate = getCurrentDate(),
@ -141,6 +181,12 @@ internal class FeedComponentModel @Inject constructor(
onOpenAllNews = {},
onMarketItemClick = {},
onSortTypeClick = {},
onSliderScroll = {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselScrolled())
},
onSliderEndReached = {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselEndReached())
},
),
news = NewsUM(
content = persistentListOf(),
@ -165,101 +211,8 @@ internal class FeedComponentModel @Inject constructor(
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
}
private fun updateMarketCharts(
itemsByOrder: Map<SortByTypeUM, ImmutableList<com.tangem.common.ui.markets.models.MarketsListItemUM>>,
loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
errorStatesByOrder: Map<SortByTypeUM, Boolean>,
) {
state.update { currentState ->
val newMarketCharts = buildMap {
SortByTypeUM.entries.forEach { sortByType ->
val items = itemsByOrder[sortByType] ?: persistentListOf()
val isLoading = loadingStatesByOrder[sortByType] == true
val hasError = errorStatesByOrder[sortByType] == true
when {
hasError -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = {
marketsBatchFlowManager.reloadAll()
},
),
)
}
isLoading -> {
put(sortByType, MarketChartUM.Loading)
}
items.isEmpty() -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = {
marketsBatchFlowManager.reloadAll()
},
),
)
}
else -> {
put(
sortByType,
MarketChartUM.Content(
items = items,
sortChartConfig = SortChartConfigUM(
sortByType = sortByType,
isSelected = sortByType == currentState.marketChartConfig.currentSortByType,
),
),
)
}
}
}
}.toPersistentHashMap()
currentState.copy(
marketChartConfig = currentState.marketChartConfig.copy(
marketCharts = newMarketCharts,
),
)
}
}
private fun updateGlobalState() {
state.update { currentState ->
val newsState = currentState.news
val marketCharts = currentState.marketChartConfig.marketCharts
val isNewsLoading = newsState.newsUMState == NewsUMState.LOADING
val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading }
val isNewsError = newsState.newsUMState == NewsUMState.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 ->
private fun handleSortTypeClicked(sortByType: SortByTypeUM) {
stateController.update { currentState ->
val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) ->
when (chart) {
is MarketChartUM.Content -> {
@ -297,44 +250,85 @@ internal class FeedComponentModel @Inject constructor(
}
private fun updateCallbacks() {
state.update { feedListUM ->
stateController.update { feedListUM ->
feedListUM.copy(
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 ->
val trendingArticleId = state.value.trendingArticle?.id
params.feedClickIntents.onArticleClick(
articleId = articleId,
preselectedArticlesId = listOfNotNull(trendingArticleId) +
when (state.value.news.newsUMState) {
NewsUMState.CONTENT -> state.value.news.content.map { it.id }
NewsUMState.LOADING,
NewsUMState.ERROR,
-> emptyList()
},
paginationConfig = null,
)
},
onOpenAllNews = {
params.feedClickIntents.onOpenAllNews()
},
onSortTypeClick = ::handleSortTypeClicked,
onMarketItemClick = ::handleMarketItemClicked,
onMarketOpenClick = ::handleMarketOpenClicked,
onArticleClick = ::handleArticleClicked,
onOpenAllNews = ::handleOpenAllNews,
),
)
}
}
private fun handleMarketItemClicked(item: MarketsListItemUM) {
val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id)
if (tokenMarket != null) {
params.feedClickIntents.onMarketItemClick(
token = tokenMarket.toSerializableParam(),
appCurrency = currentAppCurrency.value,
)
}
}
private fun handleMarketOpenClicked(sortBy: SortByTypeUM) {
when (sortBy) {
SortByTypeUM.Rating -> {
analyticsEventHandler.send(
FeedAnalyticsEvent.TokenListOpened(
AnalyticsParam.ScreensSources.Markets,
),
)
}
SortByTypeUM.Trending,
SortByTypeUM.ExperiencedBuyers,
SortByTypeUM.TopGainers,
SortByTypeUM.TopLosers,
SortByTypeUM.Staking,
SortByTypeUM.YieldSupply,
-> {
analyticsEventHandler.send(
FeedAnalyticsEvent.TokenListOpened(
AnalyticsParam.ScreensSources.MarketPulse,
),
)
}
}
params.feedClickIntents.onMarketOpenClick(sortBy)
}
private fun handleArticleClicked(articleId: Int) {
val currentState = stateController.value
val trendingArticleId = currentState.trendingArticle?.id
if (articleId == trendingArticleId) {
analyticsEventHandler.send(
event = FeedAnalyticsEvent.NewsCarouselTrendingClicked(newsId = articleId),
)
}
params.feedClickIntents.onArticleClick(
articleId = articleId,
preselectedArticlesId = listOfNotNull(trendingArticleId) +
when (currentState.news.newsUMState) {
NewsUMState.CONTENT -> currentState.news.content.map { it.id }
NewsUMState.LOADING,
NewsUMState.ERROR,
-> emptyList()
},
paginationConfig = null,
)
}
private fun handleOpenAllNews(isFromCarouselButton: Boolean) {
if (isFromCarouselButton) {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselAllNewsButton())
} else {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsListOpened())
}
params.feedClickIntents.onOpenAllNews()
}
private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating

View file

@ -0,0 +1,94 @@
package com.tangem.features.feed.model.feed.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.OneTimePerSessionEvent
internal sealed class FeedAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets", event = event, params = params) {
data class TokenListOpened(
private val screensSources: AnalyticsParam.ScreensSources,
) : FeedAnalyticsEvent(
event = "Token List Opened",
params = mapOf(
SOURCE to screensSources.value,
),
)
class NewsListOpened : FeedAnalyticsEvent(
event = "News List Opened",
params = mapOf(
SOURCE to AnalyticsParam.ScreensSources.Markets.value,
),
)
data class NewsCarouselTrendingClicked(private val newsId: Int) : FeedAnalyticsEvent(
event = "News Carousel Trending Clicked",
params = mapOf(
"News id" to newsId.toString(),
),
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event
}
class NewsCarouselAllNewsButton : FeedAnalyticsEvent(
event = "News Carousel All News button",
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event
}
class NewsCarouselEndReached : FeedAnalyticsEvent(
event = "News Carousel End Reached",
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event
}
class NewsCarouselScrolled : FeedAnalyticsEvent(
event = "News Carousel Scrolled",
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event
}
data class MarketsLoadError(
private val code: Int?,
private val message: String,
) : FeedAnalyticsEvent(
event = "Markets Load Error",
params = mapOf(
ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(),
ERROR_MESSAGE to message,
),
)
data class NewsLoadError(
private val code: Int?,
private val message: String,
) : FeedAnalyticsEvent(
event = "News Load Error",
params = mapOf(
ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(),
ERROR_MESSAGE to message,
),
)
data class AllWidgetsLoadError(
private val code: Int?,
private val message: String,
) : FeedAnalyticsEvent(
event = "All Widgets Load Error",
params = mapOf(
ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(),
ERROR_MESSAGE to message,
),
)
private companion object {
const val IS_NOT_HTTP_ERROR = "Is not http error"
}
}

View file

@ -66,15 +66,15 @@ internal class FeedMarketsBatchFlowManager(
initialValue = emptyMap(),
)
val errorStatesByOrder: StateFlow<Map<SortByTypeUM, Boolean>> =
val errorStatesByOrder: StateFlow<Map<SortByTypeUM, Throwable?>> =
combine(
TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.hasError?.map { hasError -> order to hasError }
managersByOrder[order]?.error?.map { error -> order to error }
},
) { errorStatesList ->
errorStatesList.associate { (order, hasError) ->
errorStatesList.associate { (order, error) ->
val sortByType = order.toSortByTypeUM()
sortByType to hasError
sortByType to error
}
}.stateIn(
scope = modelScope,
@ -194,19 +194,19 @@ internal class FeedMarketsBatchFlowManager(
initialValue = false,
)
val hasError = batchFlow.state
val error = batchFlow.state
.map { state ->
when (val status = state.status) {
is PaginationStatus.InitialLoadingError -> true
is PaginationStatus.Paginating -> status.lastResult is BatchFetchResult.Error
else -> false
is PaginationStatus.InitialLoadingError -> status.throwable
is PaginationStatus.Paginating -> (status.lastResult as? BatchFetchResult.Error)?.throwable
else -> null
}
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = Eagerly,
initialValue = false,
initialValue = null,
)
val onLastBatchLoadedSuccess = batchFlow.state

View file

@ -0,0 +1,70 @@
package com.tangem.features.feed.model.feed.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.state.*
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class FeedStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<FeedListUM> = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<FeedListUM> get() = mutableUiState.asStateFlow()
val value: FeedListUM get() = uiState.value
fun update(function: (FeedListUM) -> FeedListUM) {
mutableUiState.update(function = function)
}
fun update(transformer: FeedListUMTransformer) {
mutableUiState.update(function = transformer::transform)
}
fun updateAll(vararg transformers: FeedListUMTransformer) {
transformers.forEach { mutableUiState.update(function = it::transform) }
}
fun clear() {
mutableUiState.update { getInitialState() }
}
private fun getInitialState(): FeedListUM {
return FeedListUM(
currentDate = "",
feedListSearchBar = FeedListSearchBar(
placeholderText = com.tangem.core.ui.extensions.TextReference.EMPTY,
onBarClick = {},
),
feedListCallbacks = FeedListCallbacks(
onSearchClick = {},
onMarketOpenClick = {},
onArticleClick = {},
onOpenAllNews = {},
onMarketItemClick = {},
onSortTypeClick = {},
onSliderScroll = {},
onSliderEndReached = {},
),
news = NewsUM(
content = persistentListOf(),
onRetryClicked = {},
newsUMState = NewsUMState.LOADING,
),
trendingArticle = null,
marketChartConfig = MarketChartConfig(
marketCharts = persistentHashMapOf(),
currentSortByType = SortByTypeUM.Trending,
),
globalState = GlobalFeedState.Loading,
)
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.features.feed.ui.feed.state.FeedListUM
internal interface FeedListUMTransformer {
fun transform(prevState: FeedListUM): FeedListUM
}

View file

@ -0,0 +1,76 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.GlobalFeedState
import com.tangem.features.feed.ui.feed.state.NewsUMState
internal class UpdateGlobalFeedStateTransformer(
private val loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
private val errorStatesByOrder: Map<SortByTypeUM, Throwable?>,
private val trendingNewsResult: TrendingNews,
private val onRetryClicked: () -> Unit,
private val analyticsEventHandler: AnalyticsEventHandler,
) : FeedListUMTransformer {
@Suppress("CyclomaticComplexMethod")
override fun transform(prevState: FeedListUM): FeedListUM {
val previousGlobalState = prevState.globalState
val isNewsLoading = prevState.news.newsUMState == NewsUMState.LOADING
val isNewsError = trendingNewsResult is TrendingNews.Error ||
prevState.news.newsUMState == NewsUMState.ERROR
val hasLoadingInCharts = loadingStatesByOrder.values.any { it }
val hasErrorInCharts = errorStatesByOrder.values.any { it != null }
val hasAllErrorInCharts = errorStatesByOrder.values.isNotEmpty() && errorStatesByOrder.values.all { it != null }
val areAllChartsLoading = loadingStatesByOrder.values.isNotEmpty() && loadingStatesByOrder.values.all { it }
val areAllChartsError = hasAllErrorInCharts || hasErrorInCharts && hasLoadingInCharts
val newGlobalState = when {
isNewsLoading && areAllChartsError -> GlobalFeedState.Loading
isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading
isNewsError && areAllChartsLoading -> GlobalFeedState.Loading
isNewsError && areAllChartsError -> {
if (previousGlobalState !is GlobalFeedState.Error) {
sendErrorAnalytics(errorStatesByOrder)
}
GlobalFeedState.Error(
onRetryClicked = onRetryClicked,
)
}
else -> GlobalFeedState.Content
}
return prevState.copy(
globalState = newGlobalState,
news = prevState.news.copy(
newsUMState = when {
isNewsError -> NewsUMState.ERROR
else -> NewsUMState.CONTENT
},
),
)
}
private fun sendErrorAnalytics(errorStatesByOrder: Map<SortByTypeUM, Throwable?>) {
val firstError: Throwable? = errorStatesByOrder.values.firstNotNullOfOrNull { it }
firstError?.let { error ->
val (code, message) = when (error) {
is ApiResponseError.HttpException -> error.code.numericCode to error.message
else -> null to ""
}
analyticsEventHandler.send(
FeedAnalyticsEvent.AllWidgetsLoadError(
code = code,
message = message.orEmpty(),
),
)
}
}
}

View file

@ -0,0 +1,102 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.MarketChartUM
import com.tangem.features.feed.ui.feed.state.SortChartConfigUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentHashMap
internal class UpdateMarketChartsTransformer(
private val itemsByOrder: Map<SortByTypeUM, ImmutableList<MarketsListItemUM>>,
private val loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
private val errorStatesByOrder: Map<SortByTypeUM, Throwable?>,
private val onReloadAll: () -> Unit,
private val analyticsEventHandler: AnalyticsEventHandler,
) : FeedListUMTransformer {
override fun transform(prevState: FeedListUM): FeedListUM {
val newMarketCharts = buildMap {
SortByTypeUM.entries.forEach { sortByType ->
val items = itemsByOrder[sortByType] ?: persistentListOf()
val isLoading = loadingStatesByOrder[sortByType] == true
val hasError = errorStatesByOrder[sortByType] != null
when {
hasError -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = onReloadAll,
),
)
}
isLoading -> {
put(sortByType, MarketChartUM.Loading)
}
items.isEmpty() -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = onReloadAll,
),
)
}
else -> {
put(
sortByType,
MarketChartUM.Content(
items = items,
sortChartConfig = SortChartConfigUM(
sortByType = sortByType,
isSelected = sortByType == prevState.marketChartConfig.currentSortByType,
),
),
)
}
}
}
}.toPersistentHashMap()
val wasAnyErrorBefore = prevState.marketChartConfig.marketCharts.values.any {
it is MarketChartUM.LoadingError
}
val isAnyErrorNow = newMarketCharts.values.any { it is MarketChartUM.LoadingError }
if (!wasAnyErrorBefore && isAnyErrorNow) {
sendErrorAnalytics(errorStatesByOrder, itemsByOrder)
}
return prevState.copy(
marketChartConfig = prevState.marketChartConfig.copy(
marketCharts = newMarketCharts,
),
)
}
private fun sendErrorAnalytics(
errorStatesByOrder: Map<SortByTypeUM, Throwable?>,
itemsByOrder: Map<SortByTypeUM, ImmutableList<MarketsListItemUM>>,
) {
val firstError = errorStatesByOrder.values.firstNotNullOfOrNull { it }
if (firstError == null && !itemsByOrder.values.any { it.isEmpty() }) {
return
}
val (code, message) = if (firstError is ApiResponseError.HttpException) {
firstError.code.numericCode to firstError.message.orEmpty()
} else {
null to ""
}
analyticsEventHandler.send(
FeedAnalyticsEvent.MarketsLoadError(
code = code,
message = message,
),
)
}
}

View file

@ -1,8 +1,11 @@
package com.tangem.features.feed.model.feed.state
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.models.news.NewsError
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.NewsUM
import com.tangem.features.feed.ui.feed.state.NewsUMState
@ -10,21 +13,32 @@ import com.tangem.utils.Provider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class TrendingNewsStateFactory(
private val currentStateProvider: Provider<FeedListUM>,
private val onStateUpdate: (FeedListUM) -> Unit,
internal class UpdateTrendingNewsStateTransformer(
private val result: TrendingNews,
private val onRetryClicked: () -> Unit,
) {
private val analyticsEventHandler: AnalyticsEventHandler,
) : FeedListUMTransformer {
fun updateTrendingNewsState(result: TrendingNews) {
val currentState = currentStateProvider()
when (result) {
is TrendingNews.Data -> handleDataState(currentState, result.articles)
is TrendingNews.Error -> handleErrorState(currentState)
override fun transform(prevState: FeedListUM): FeedListUM {
return when (result) {
is TrendingNews.Data -> handleDataState(prevState, result.articles)
is TrendingNews.Error -> {
val (code, message) = when (val error = result.error) {
is NewsError.HttpError -> error.code to error.message
NewsError.NotHttpError -> null to ""
}
analyticsEventHandler.send(
FeedAnalyticsEvent.NewsLoadError(
code = code,
message = message,
),
)
handleErrorState(prevState)
}
}
}
private fun handleDataState(currentState: FeedListUM, articles: List<ShortArticle>) {
private fun handleDataState(currentState: FeedListUM, articles: List<ShortArticle>): FeedListUM {
val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles)
val commonArticlesUM = getShortArticleConfigConverter(isTrending = false)
.convert(commonArticles)
@ -40,26 +54,22 @@ internal class TrendingNewsStateFactory(
)
}
onStateUpdate(
currentState.copy(
trendingArticle = trendingArticle?.let { article ->
getShortArticleConfigConverter(isTrending = true)
.convert(listOf(article))
}?.firstOrNull(),
news = updatedNews,
),
return currentState.copy(
trendingArticle = trendingArticle?.let { article ->
getShortArticleConfigConverter(isTrending = true)
.convert(listOf(article))
}?.firstOrNull(),
news = updatedNews,
)
}
private fun handleErrorState(currentState: FeedListUM) {
onStateUpdate(
currentState.copy(
trendingArticle = null,
news = NewsUM(
content = persistentListOf(),
onRetryClicked = onRetryClicked,
newsUMState = NewsUMState.ERROR,
),
private fun handleErrorState(currentState: FeedListUM): FeedListUM {
return currentState.copy(
trendingArticle = null,
news = NewsUM(
content = persistentListOf(),
onRetryClicked = onRetryClicked,
newsUMState = NewsUMState.ERROR,
),
)
}

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -329,7 +330,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
)
}
},
onSeeAllClick = feedListCallbacks.onOpenAllNews,
onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) },
)
SpacerH(12.dp)
@ -356,23 +357,36 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
items(
items = news.content,
key = ArticleConfigUM::id,
contentType = { "article" },
) { article ->
val articleModifier = if (news.content.indexOf(article) == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = Modifier
modifier = articleModifier
.heightIn(min = 164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
item {
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(216.dp)
.heightIn(min = 164.dp),
onClick = feedListCallbacks.onOpenAllNews,
.heightIn(min = 164.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderEndReached,
),
onClick = { feedListCallbacks.onOpenAllNews(true) },
)
}
}
@ -520,6 +534,7 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) {
}
private const val DEFAULT_CHART_SIZE_IN_MARKET = 5
private const val FOURTH_ITEM_INDEX = 3
private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f
private val LinearGradientFirstPart = Color(0xFF635EEC)

View file

@ -32,6 +32,8 @@ internal object FeedListPreviewDataProvider {
onOpenAllNews = {},
onMarketItemClick = {},
onSortTypeClick = {},
onSliderScroll = {},
onSliderEndReached = {},
),
news = NewsUM(
content = articles.filter { it.isTrending.not() }.toImmutableList(),

View file

@ -23,9 +23,11 @@ internal data class FeedListCallbacks(
val onSearchClick: () -> Unit,
val onMarketOpenClick: (sortBy: SortByTypeUM) -> Unit,
val onArticleClick: (id: Int) -> Unit,
val onOpenAllNews: () -> Unit,
val onOpenAllNews: (isFromCarouselButton: Boolean) -> Unit,
val onMarketItemClick: (MarketsListItemUM) -> Unit,
val onSortTypeClick: (SortByTypeUM) -> Unit,
val onSortTypeClick: (sortBy: SortByTypeUM) -> Unit,
val onSliderScroll: () -> Unit,
val onSliderEndReached: () -> Unit,
)
internal data class FeedListSearchBar(