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 Onboarding : ScreensSources("Onboarding")
data object LongTap : ScreensSources("Long Tap") data object LongTap : ScreensSources("Long Tap")
data object Markets : ScreensSources("Markets") data object Markets : ScreensSources("Markets")
data object MarketPulse : ScreensSources("Market Pulse")
data object TangemPay : ScreensSources("Tangem Pay") data object TangemPay : ScreensSources("Tangem Pay")
data object WalletSettings : ScreensSources("Wallet Settings") data object WalletSettings : ScreensSources("Wallet Settings")
data object Upgrade : ScreensSources("Upgrade") 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.api.*
import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.OneTimePerSessionEvent
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -31,6 +32,7 @@ object Analytics : GlobalAnalyticsEventHandler {
private val handlers = mutableMapOf<String, AnalyticsHandler>() private val handlers = mutableMapOf<String, AnalyticsHandler>()
private val paramsInterceptors = ConcurrentHashMap<String, ParamsInterceptor>() private val paramsInterceptors = ConcurrentHashMap<String, ParamsInterceptor>()
private val oneEventsPerSession = ConcurrentHashMap<String, Boolean>()
private val analyticsFilters = mutableSetOf<AnalyticsEventFilter>() private val analyticsFilters = mutableSetOf<AnalyticsEventFilter>()
private val analyticsMutex = Mutex() private val analyticsMutex = Mutex()
@ -85,6 +87,11 @@ object Analytics : GlobalAnalyticsEventHandler {
override fun send(event: AnalyticsEvent) { override fun send(event: AnalyticsEvent) {
analyticsScope.launch { analyticsScope.launch {
if (event is OneTimePerSessionEvent &&
oneEventsPerSession.putIfAbsent(event.oneTimeEventId, true) != null
) {
return@launch
}
event.params = applyParamsInterceptors(event) event.params = applyParamsInterceptors(event)
val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(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 package com.tangem.data.news.di
import com.tangem.data.news.DefaultNewsErrorResolver
import com.tangem.data.news.repository.DefaultNewsRepository import com.tangem.data.news.repository.DefaultNewsRepository
import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.datasource.local.news.viewed.NewsViewedStore
import com.tangem.domain.news.NewsErrorResolver
import com.tangem.domain.news.repository.NewsRepository import com.tangem.domain.news.repository.NewsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -25,6 +27,7 @@ internal object NewsDataModule {
newsDetailsStore: NewsDetailsStore, newsDetailsStore: NewsDetailsStore,
trendingNewsStore: TrendingNewsStore, trendingNewsStore: TrendingNewsStore,
newsViewedStore: NewsViewedStore, newsViewedStore: NewsViewedStore,
newsErrorResolver: NewsErrorResolver,
): NewsRepository { ): NewsRepository {
return DefaultNewsRepository( return DefaultNewsRepository(
newsApi = newsApi, newsApi = newsApi,
@ -32,6 +35,10 @@ internal object NewsDataModule {
newsDetailsStore = newsDetailsStore, newsDetailsStore = newsDetailsStore,
trendingNewsStore = trendingNewsStore, trendingNewsStore = trendingNewsStore,
newsViewedStore = newsViewedStore, 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.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.datasource.local.news.viewed.NewsViewedStore
import com.tangem.domain.models.news.* 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.NewsListBatchFlow
import com.tangem.domain.news.model.NewsListBatchingContext import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.model.NewsListConfig
@ -32,6 +33,7 @@ internal class DefaultNewsRepository(
private val newsDetailsStore: NewsDetailsStore, private val newsDetailsStore: NewsDetailsStore,
private val trendingNewsStore: TrendingNewsStore, private val trendingNewsStore: TrendingNewsStore,
private val newsViewedStore: NewsViewedStore, private val newsViewedStore: NewsViewedStore,
private val newsErrorResolver: NewsErrorResolver,
) : NewsRepository { ) : NewsRepository {
override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow {
@ -176,12 +178,7 @@ internal class DefaultNewsRepository(
trendingNewsStore.clear() trendingNewsStore.clear()
trendingNewsStore.store( trendingNewsStore.store(
key = TRENDING_NEWS_KEY, key = TRENDING_NEWS_KEY,
value = TrendingNews.Error( value = TrendingNews.Error(error = newsErrorResolver.resolve(apiResponse.cause.cause)),
NewsError.Unknown(
message = apiResponse.cause.message,
code = null,
),
),
) )
} }
is ApiResponse.Success<NewsTrendingResponse> -> { is ApiResponse.Success<NewsTrendingResponse> -> {

View file

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

View file

@ -12,5 +12,5 @@ sealed interface TrendingNews {
data class Data(val articles: List<ShortArticle>) : TrendingNews data class Data(val articles: List<ShortArticle>) : TrendingNews
@Serializable @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.analytics.models)
implementation(projects.core.navigation) implementation(projects.core.navigation)
implementation(projects.core.utils) implementation(projects.core.utils)
implementation(projects.core.datasource)
/* Common */ /* Common */
implementation(projects.common.ui) implementation(projects.common.ui)

View file

@ -2,6 +2,9 @@ package com.tangem.features.feed.model.feed
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import arrow.core.getOrElse import arrow.core.getOrElse
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
@ -16,13 +19,16 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.impl.R 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.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.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.state.* import com.tangem.features.feed.ui.feed.state.*
import com.tangem.utils.Provider import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentHashMap import kotlinx.collections.immutable.toPersistentHashMap
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -32,18 +38,19 @@ import kotlinx.coroutines.launch
import org.joda.time.DateTime import org.joda.time.DateTime
import org.joda.time.DateTimeZone import org.joda.time.DateTimeZone
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.all
import kotlin.collections.map
@Stable @Stable
@ModelScoped @ModelScoped
@Suppress("LongParameterList")
internal class FeedComponentModel @Inject constructor( internal class FeedComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase, private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase,
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase, private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
private val stateController: FeedStateController,
) : Model() { ) : Model() {
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>() private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
@ -66,23 +73,12 @@ internal class FeedComponentModel @Inject constructor(
) )
internal val state: StateFlow<FeedListUM> internal val state: StateFlow<FeedListUM>
field = MutableStateFlow<FeedListUM>(initialState()) get() = stateController.uiState
private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) {
TrendingNewsStateFactory(
currentStateProvider = Provider { state.value },
onStateUpdate = { newState -> state.update { newState } },
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
)
}
val isVisibleOnScreen = MutableStateFlow(false) val isVisibleOnScreen = MutableStateFlow(false)
init { init {
initializeState()
updateCallbacks() updateCallbacks()
modelScope.launch(dispatchers.default) { modelScope.launch(dispatchers.default) {
@ -96,10 +92,50 @@ internal class FeedComponentModel @Inject constructor(
flow3 = marketsBatchFlowManager.errorStatesByOrder, flow3 = marketsBatchFlowManager.errorStatesByOrder,
flow4 = manageTrendingNewsUseCase.observeTrendingNews(), flow4 = manageTrendingNewsUseCase.observeTrendingNews(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult -> ) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult ->
updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder) val globalStateTransformer = UpdateGlobalFeedStateTransformer(
trendingNewsStateFactory.updateTrendingNewsState(result = trendingNewsResult) loadingStatesByOrder = loadingStatesByOrder,
updateGlobalState() errorStatesByOrder = errorStatesByOrder,
val currentSortType = state.value.marketChartConfig.currentSortByType 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 items = itemsByOrder[currentSortType]
val isLoading = loadingStatesByOrder[currentSortType] == true val isLoading = loadingStatesByOrder[currentSortType] == true
if (items != null && items.isNotEmpty() && !isLoading) { 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 { private fun initialState(): FeedListUM {
return FeedListUM( return FeedListUM(
currentDate = getCurrentDate(), currentDate = getCurrentDate(),
@ -141,6 +181,12 @@ internal class FeedComponentModel @Inject constructor(
onOpenAllNews = {}, onOpenAllNews = {},
onMarketItemClick = {}, onMarketItemClick = {},
onSortTypeClick = {}, onSortTypeClick = {},
onSliderScroll = {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselScrolled())
},
onSliderEndReached = {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselEndReached())
},
), ),
news = NewsUM( news = NewsUM(
content = persistentListOf(), content = persistentListOf(),
@ -165,101 +211,8 @@ internal class FeedComponentModel @Inject constructor(
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate) return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
} }
private fun updateMarketCharts( private fun handleSortTypeClicked(sortByType: SortByTypeUM) {
itemsByOrder: Map<SortByTypeUM, ImmutableList<com.tangem.common.ui.markets.models.MarketsListItemUM>>, stateController.update { currentState ->
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 ->
val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) -> val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) ->
when (chart) { when (chart) {
is MarketChartUM.Content -> { is MarketChartUM.Content -> {
@ -297,44 +250,85 @@ internal class FeedComponentModel @Inject constructor(
} }
private fun updateCallbacks() { private fun updateCallbacks() {
state.update { feedListUM -> stateController.update { feedListUM ->
feedListUM.copy( feedListUM.copy(
feedListCallbacks = feedListUM.feedListCallbacks.copy( feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::onSortTypeClick, onSortTypeClick = ::handleSortTypeClicked,
onMarketItemClick = { item -> onMarketItemClick = ::handleMarketItemClicked,
val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id) onMarketOpenClick = ::handleMarketOpenClicked,
if (tokenMarket != null) { onArticleClick = ::handleArticleClicked,
params.feedClickIntents.onMarketItemClick( onOpenAllNews = ::handleOpenAllNews,
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()
},
), ),
) )
} }
} }
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 { private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order {
return when (this) { return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating 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(), initialValue = emptyMap(),
) )
val errorStatesByOrder: StateFlow<Map<SortByTypeUM, Boolean>> = val errorStatesByOrder: StateFlow<Map<SortByTypeUM, Throwable?>> =
combine( combine(
TokenMarketListConfig.Order.entries.mapNotNull { order -> TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.hasError?.map { hasError -> order to hasError } managersByOrder[order]?.error?.map { error -> order to error }
}, },
) { errorStatesList -> ) { errorStatesList ->
errorStatesList.associate { (order, hasError) -> errorStatesList.associate { (order, error) ->
val sortByType = order.toSortByTypeUM() val sortByType = order.toSortByTypeUM()
sortByType to hasError sortByType to error
} }
}.stateIn( }.stateIn(
scope = modelScope, scope = modelScope,
@ -194,19 +194,19 @@ internal class FeedMarketsBatchFlowManager(
initialValue = false, initialValue = false,
) )
val hasError = batchFlow.state val error = batchFlow.state
.map { state -> .map { state ->
when (val status = state.status) { when (val status = state.status) {
is PaginationStatus.InitialLoadingError -> true is PaginationStatus.InitialLoadingError -> status.throwable
is PaginationStatus.Paginating -> status.lastResult is BatchFetchResult.Error is PaginationStatus.Paginating -> (status.lastResult as? BatchFetchResult.Error)?.throwable
else -> false else -> null
} }
} }
.distinctUntilChanged() .distinctUntilChanged()
.stateIn( .stateIn(
scope = modelScope, scope = modelScope,
started = Eagerly, started = Eagerly,
initialValue = false, initialValue = null,
) )
val onLastBatchLoadedSuccess = batchFlow.state 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.ShortArticle
import com.tangem.domain.models.news.TrendingNews import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter 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.FeedListUM
import com.tangem.features.feed.ui.feed.state.NewsUM import com.tangem.features.feed.ui.feed.state.NewsUM
import com.tangem.features.feed.ui.feed.state.NewsUMState 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.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
internal class TrendingNewsStateFactory( internal class UpdateTrendingNewsStateTransformer(
private val currentStateProvider: Provider<FeedListUM>, private val result: TrendingNews,
private val onStateUpdate: (FeedListUM) -> Unit,
private val onRetryClicked: () -> Unit, private val onRetryClicked: () -> Unit,
) { private val analyticsEventHandler: AnalyticsEventHandler,
) : FeedListUMTransformer {
fun updateTrendingNewsState(result: TrendingNews) { override fun transform(prevState: FeedListUM): FeedListUM {
val currentState = currentStateProvider() return when (result) {
when (result) { is TrendingNews.Data -> handleDataState(prevState, result.articles)
is TrendingNews.Data -> handleDataState(currentState, result.articles) is TrendingNews.Error -> {
is TrendingNews.Error -> handleErrorState(currentState) 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 (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles)
val commonArticlesUM = getShortArticleConfigConverter(isTrending = false) val commonArticlesUM = getShortArticleConfigConverter(isTrending = false)
.convert(commonArticles) .convert(commonArticles)
@ -40,26 +54,22 @@ internal class TrendingNewsStateFactory(
) )
} }
onStateUpdate( return currentState.copy(
currentState.copy( trendingArticle = trendingArticle?.let { article ->
trendingArticle = trendingArticle?.let { article -> getShortArticleConfigConverter(isTrending = true)
getShortArticleConfigConverter(isTrending = true) .convert(listOf(article))
.convert(listOf(article)) }?.firstOrNull(),
}?.firstOrNull(), news = updatedNews,
news = updatedNews,
),
) )
} }
private fun handleErrorState(currentState: FeedListUM) { private fun handleErrorState(currentState: FeedListUM): FeedListUM {
onStateUpdate( return currentState.copy(
currentState.copy( trendingArticle = null,
trendingArticle = null, news = NewsUM(
news = NewsUM( content = persistentListOf(),
content = persistentListOf(), onRetryClicked = onRetryClicked,
onRetryClicked = onRetryClicked, newsUMState = NewsUMState.ERROR,
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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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) SpacerH(12.dp)
@ -356,23 +357,36 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
items( items(
items = news.content, items = news.content,
key = ArticleConfigUM::id, key = ArticleConfigUM::id,
contentType = { "article" },
) { article -> ) { article ->
val articleModifier = if (news.content.indexOf(article) == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard( ArticleCard(
articleConfigUM = article, articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) }, onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = Modifier modifier = articleModifier
.heightIn(min = 164.dp) .heightIn(min = 164.dp)
.width(216.dp), .width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) )
} }
item { item(contentType = "show_more") {
ShowMoreArticlesCard( ShowMoreArticlesCard(
modifier = Modifier modifier = Modifier
.width(216.dp) .width(216.dp)
.heightIn(min = 164.dp), .heightIn(min = 164.dp)
onClick = feedListCallbacks.onOpenAllNews, .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 DEFAULT_CHART_SIZE_IN_MARKET = 5
private const val FOURTH_ITEM_INDEX = 3
private const val GRADIENT_START = 0f private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f private const val GRADIENT_END = 0.5f
private val LinearGradientFirstPart = Color(0xFF635EEC) private val LinearGradientFirstPart = Color(0xFF635EEC)

View file

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

View file

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