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

@ -78,6 +78,7 @@ sealed class AnalyticsParam {
data object Onboarding : ScreensSources("Onboarding")
data object LongTap : ScreensSources("Long Tap")
data object Markets : ScreensSources("Markets")
data object MarketPulse : ScreensSources("Market Pulse")
data object TangemPay : ScreensSources("Tangem Pay")
data object WalletSettings : ScreensSources("Wallet Settings")
data object Upgrade : ScreensSources("Upgrade")

View file

@ -0,0 +1,22 @@
package com.tangem.core.analytics.models
/**
* Marker interface for analytics events that should be sent only once per session.
*
* Events implementing this interface will be tracked by their [oneTimeEventId] to ensure
* they are not sent multiple times during the same application session. Once an event
* with a specific [oneTimeEventId] has been sent, subsequent attempts to send an event
* with the same ID will be ignored.
*
* @see Analytics.send
*/
interface OneTimePerSessionEvent {
/**
* Unique identifier for the one-time event.
*
* This ID is used to track whether the event has already been sent in the current session.
* Events with the same [oneTimeEventId] will only be sent once, even if they are
* different instances of the same event class.
*/
val oneTimeEventId: String
}

View file

@ -6,6 +6,7 @@ import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.*
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.OneTimePerSessionEvent
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@ -31,6 +32,7 @@ object Analytics : GlobalAnalyticsEventHandler {
private val handlers = mutableMapOf<String, AnalyticsHandler>()
private val paramsInterceptors = ConcurrentHashMap<String, ParamsInterceptor>()
private val oneEventsPerSession = ConcurrentHashMap<String, Boolean>()
private val analyticsFilters = mutableSetOf<AnalyticsEventFilter>()
private val analyticsMutex = Mutex()
@ -85,6 +87,11 @@ object Analytics : GlobalAnalyticsEventHandler {
override fun send(event: AnalyticsEvent) {
analyticsScope.launch {
if (event is OneTimePerSessionEvent &&
oneEventsPerSession.putIfAbsent(event.oneTimeEventId, true) != null
) {
return@launch
}
event.params = applyParamsInterceptors(event)
val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) }

View file

@ -0,0 +1,22 @@
package com.tangem.data.news
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.models.news.NewsError
import com.tangem.domain.news.NewsErrorResolver
internal class DefaultNewsErrorResolver : NewsErrorResolver {
override fun resolve(throwable: Throwable?): NewsError {
return when (throwable) {
is ApiResponseError.HttpException -> {
NewsError.HttpError(
code = throwable.code.numericCode,
message = throwable.message.orEmpty(),
)
}
else -> {
NewsError.NotHttpError
}
}
}
}

View file

@ -1,10 +1,12 @@
package com.tangem.data.news.di
import com.tangem.data.news.DefaultNewsErrorResolver
import com.tangem.data.news.repository.DefaultNewsRepository
import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.NewsViewedStore
import com.tangem.domain.news.NewsErrorResolver
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -25,6 +27,7 @@ internal object NewsDataModule {
newsDetailsStore: NewsDetailsStore,
trendingNewsStore: TrendingNewsStore,
newsViewedStore: NewsViewedStore,
newsErrorResolver: NewsErrorResolver,
): NewsRepository {
return DefaultNewsRepository(
newsApi = newsApi,
@ -32,6 +35,10 @@ internal object NewsDataModule {
newsDetailsStore = newsDetailsStore,
trendingNewsStore = trendingNewsStore,
newsViewedStore = newsViewedStore,
newsErrorResolver = newsErrorResolver,
)
}
@Provides
fun provideNewsErrorResolver(): NewsErrorResolver = DefaultNewsErrorResolver()
}

View file

@ -9,6 +9,7 @@ import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.NewsViewedStore
import com.tangem.domain.models.news.*
import com.tangem.domain.news.NewsErrorResolver
import com.tangem.domain.news.model.NewsListBatchFlow
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig
@ -32,6 +33,7 @@ internal class DefaultNewsRepository(
private val newsDetailsStore: NewsDetailsStore,
private val trendingNewsStore: TrendingNewsStore,
private val newsViewedStore: NewsViewedStore,
private val newsErrorResolver: NewsErrorResolver,
) : NewsRepository {
override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow {
@ -176,12 +178,7 @@ internal class DefaultNewsRepository(
trendingNewsStore.clear()
trendingNewsStore.store(
key = TRENDING_NEWS_KEY,
value = TrendingNews.Error(
NewsError.Unknown(
message = apiResponse.cause.message,
code = null,
),
),
value = TrendingNews.Error(error = newsErrorResolver.resolve(apiResponse.cause.cause)),
)
}
is ApiResponse.Success<NewsTrendingResponse> -> {

View file

@ -1,19 +1,11 @@
package com.tangem.domain.models.news
import kotlinx.serialization.Serializable
sealed interface NewsError {
@Serializable
sealed class NewsError {
abstract val message: String?
abstract val code: Int?
data class HttpError(
val code: Int,
val message: String,
) : NewsError
data class ArticleNotFound(
override val message: String?,
override val code: Int?,
) : NewsError()
data class Unknown(
override val message: String?,
override val code: Int?,
) : NewsError()
data object NotHttpError : NewsError
}

View file

@ -12,5 +12,5 @@ sealed interface TrendingNews {
data class Data(val articles: List<ShortArticle>) : TrendingNews
@Serializable
data class Error(val throwable: NewsError) : TrendingNews
data class Error(val error: NewsError) : TrendingNews
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.news
import com.tangem.domain.models.news.NewsError
interface NewsErrorResolver {
fun resolve(throwable: Throwable?): NewsError
}

View file

@ -90,6 +90,7 @@ dependencies {
implementation(projects.core.analytics.models)
implementation(projects.core.navigation)
implementation(projects.core.utils)
implementation(projects.core.datasource)
/* Common */
implementation(projects.common.ui)

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,11 +250,20 @@ internal class FeedComponentModel @Inject constructor(
}
private fun updateCallbacks() {
state.update { feedListUM ->
stateController.update { feedListUM ->
feedListUM.copy(
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::onSortTypeClick,
onMarketItemClick = { item ->
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(
@ -309,30 +271,62 @@ internal class FeedComponentModel @Inject constructor(
appCurrency = currentAppCurrency.value,
)
}
},
onMarketOpenClick = { sortBy ->
}
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)
},
onArticleClick = { articleId ->
val trendingArticleId = state.value.trendingArticle?.id
}
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 (state.value.news.newsUMState) {
NewsUMState.CONTENT -> state.value.news.content.map { it.id }
when (currentState.news.newsUMState) {
NewsUMState.CONTENT -> currentState.news.content.map { it.id }
NewsUMState.LOADING,
NewsUMState.ERROR,
-> emptyList()
},
paginationConfig = null,
)
},
onOpenAllNews = {
params.feedClickIntents.onOpenAllNews()
},
),
)
}
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 {

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,27 +54,23 @@ internal class TrendingNewsStateFactory(
)
}
onStateUpdate(
currentState.copy(
return currentState.copy(
trendingArticle = trendingArticle?.let { article ->
getShortArticleConfigConverter(isTrending = true)
.convert(listOf(article))
}?.firstOrNull(),
news = updatedNews,
),
)
}
private fun handleErrorState(currentState: FeedListUM) {
onStateUpdate(
currentState.copy(
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(