Updated on 2026-08-14
This commit is contained in:
parent
3ed3dc91b1
commit
6f959bcd3f
21 changed files with 641 additions and 212 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
@ -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> -> {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.tangem.domain.news
|
||||||
|
|
||||||
|
import com.tangem.domain.models.news.NewsError
|
||||||
|
|
||||||
|
interface NewsErrorResolver {
|
||||||
|
|
||||||
|
fun resolve(throwable: Throwable?): NewsError
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue