Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-16 16:01:13 +05:00
parent 6dc6e28205
commit 3b2a867959
15 changed files with 213 additions and 47 deletions

View file

@ -1,5 +1,7 @@
package com.tangem.core.analytics.models
const val IS_NOT_HTTP_ERROR = "Is not http error"
sealed class AnalyticsParam {
sealed class CardBalanceState(val value: String) {

View file

@ -2,6 +2,7 @@ package com.tangem.data.markets.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR
sealed interface MarketsDataAnalyticsEvent {
@ -82,8 +83,4 @@ sealed interface MarketsDataAnalyticsEvent {
Custom("Custom"),
Unknown("Unknown"),
}
private companion object {
const val IS_NOT_HTTP_ERROR = "Is not http error"
}
}

View file

@ -1,5 +1,9 @@
package com.tangem.data.news.repository
import arrow.core.Either
import arrow.core.flatten
import arrow.core.left
import arrow.core.right
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.fold
@ -90,7 +94,7 @@ internal class DefaultNewsRepository(
.combine(newsLikedStore.getAll()) { articles, likedFlags ->
articles.map { article ->
article.copy(
isLiked = isNewsLiked(article.id),
isLiked = likedFlags[article.id] == true,
)
}
}
@ -99,9 +103,10 @@ internal class DefaultNewsRepository(
}
}
override suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?) {
fetchDetailedArticlesInternal(newsIds = newsIds, language = language)
}
override suspend fun fetchDetailedArticles(
newsIds: Collection<Int>,
language: String?,
): Either<Map<Int, Throwable>, Unit> = fetchDetailedArticlesInternal(newsIds = newsIds, language = language)
override suspend fun fetchTrendingNews(limit: Int, language: String?) {
fetchAndStoreTrendingNews(limit = limit, language = language)
@ -145,10 +150,9 @@ internal class DefaultNewsRepository(
return newsLikedStore.getSync()[articleId] == true
}
override suspend fun toggleNewsLiked(articleId: Int): Boolean = withContext(dispatchers.io) {
override suspend fun toggleNewsLiked(articleId: Int) = withContext(dispatchers.io) {
val isNewsLikedValue = !isNewsLiked(articleId)
newsLikedStore.updateLiked(listOf(articleId), isNewsLikedValue)
isNewsLikedValue
}
private fun updateViewedStatusForNewsBatch(
@ -162,9 +166,12 @@ internal class DefaultNewsRepository(
)
}
private suspend fun fetchDetailedArticlesInternal(newsIds: Collection<Int>, language: String?) =
private suspend fun fetchDetailedArticlesInternal(
newsIds: Collection<Int>,
language: String?,
): Either<Map<Int, Throwable>, Unit> = Either.catch {
withContext(dispatchers.io) {
if (newsIds.isEmpty()) return@withContext
if (newsIds.isEmpty()) return@withContext Unit.right()
val uniqueIds = newsIds.distinct()
val idsToFetch = buildList {
@ -174,26 +181,42 @@ internal class DefaultNewsRepository(
}
}
if (idsToFetch.isEmpty()) return@withContext
if (idsToFetch.isEmpty()) return@withContext Unit.right()
val fetchedArticles = supervisorScope {
idsToFetch.map { newsId ->
async {
newsApi.getNewsDetails(newsId = newsId, language = language)
.getOrThrow()
.toDomainDetailedArticle(
isLiked = isNewsLiked(newsId),
)
Either.catch {
newsApi.getNewsDetails(newsId = newsId, language = language)
.getOrThrow()
.toDomainDetailedArticle(
isLiked = isNewsLiked(newsId),
)
}.mapLeft {
newsId to it
}
}
}.awaitAll()
}
if (fetchedArticles.isNotEmpty()) {
newsDetailsStore.store(
articles = fetchedArticles.associateBy(DetailedArticle::id),
)
}
fetchedArticles
.filterIsInstance<Either.Right<DetailedArticle>>()
.map { it.value }
.let { articles ->
if (articles.isNotEmpty()) {
newsDetailsStore.store(
articles = articles.associateBy(DetailedArticle::id),
)
}
}
val errors = fetchedArticles
.filterIsInstance<Either.Left<Pair<Int, Throwable>>>()
.associate { it.value.first to it.value.second }
if (errors.isEmpty()) Unit.right() else errors.left()
}
}.mapLeft { t -> mapOf(GLOBAL_ERROR_ID to t) }.flatten()
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) {
return withContext(dispatchers.io) {
@ -378,5 +401,6 @@ internal class DefaultNewsRepository(
private const val INITIAL_BATCH_KEY = 0
private const val FIRST_PAGE = 1
private const val TRENDING_NEWS_KEY = "trending_news"
private const val GLOBAL_ERROR_ID = -1
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.news.repository
import arrow.core.Either
import com.tangem.domain.models.news.ArticleCategory
import com.tangem.domain.models.news.DetailedArticle
import com.tangem.domain.models.news.ShortArticle
@ -37,7 +38,7 @@ interface NewsRepository {
/**
* Fetches and caches detailed articles for provided ids in parallel.
*/
suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?)
suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?): Either<Map<Int, Throwable>, Unit>
/**
* Fetch list of trending news by limit and with correct locale and store it in runtime data store.
@ -64,7 +65,6 @@ interface NewsRepository {
/**
* Toggle news liked state
* @return an actual liked state of the article
*/
suspend fun toggleNewsLiked(articleId: Int): Boolean
suspend fun toggleNewsLiked(articleId: Int)
}

View file

@ -24,7 +24,6 @@ class ObserveNewsDetailsUseCase(
/**
* Prefetches the given article ids (can be called with current + next ids for pager preloading).
*/
suspend fun prefetch(newsIds: Collection<Int>, language: String?): Either<Throwable, Unit> = Either.catch {
suspend fun prefetch(newsIds: Collection<Int>, language: String?): Either<Map<Int, Throwable>, Unit> =
repository.fetchDetailedArticles(newsIds, language)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.news.usecase
import arrow.core.Either
import com.tangem.domain.news.repository.NewsRepository
class ToggleArticleLikedUseCase(private val repository: NewsRepository) {
@ -7,7 +8,7 @@ class ToggleArticleLikedUseCase(private val repository: NewsRepository) {
/**
* Toggle an article liked state.
*/
suspend fun toggleLiked(articleId: Int): Boolean {
return repository.toggleNewsLiked(articleId)
suspend fun toggleLiked(articleId: Int) = Either.catch {
repository.toggleNewsLiked(articleId)
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.feed.components.market.details.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.IS_NOT_HTTP_ERROR
import com.tangem.core.analytics.models.OneTimePerSessionEvent
internal sealed class MarketTokenAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "CoinPage", event = event, params = params) {
data class TokenNewsViewed(
private val token: String,
) : MarketTokenAnalyticsEvent(
event = "Token News Viewed",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to token,
),
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event + token
}
data class TokenNewsCarouselScrolled(
private val token: String,
) : MarketTokenAnalyticsEvent(
event = "Token News Carousel Scrolled",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to token,
),
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event + token
}
data class TokenNewsLoadError(
private val code: Int?,
private val message: String,
) : MarketTokenAnalyticsEvent(
event = "Token News Load Error",
params = mapOf(
ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(),
ERROR_MESSAGE to message,
),
)
}

View file

@ -5,6 +5,7 @@ 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.IS_NOT_HTTP_ERROR
import com.tangem.core.analytics.models.OneTimePerSessionEvent
internal sealed class FeedAnalyticsEvent(
@ -89,8 +90,4 @@ internal sealed class FeedAnalyticsEvent(
)
class TokenSearchedClicked : FeedAnalyticsEvent(event = "Token Searched Clicked")
private companion object {
const val IS_NOT_HTTP_ERROR = "Is not http error"
}
}

View file

@ -22,6 +22,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
@ -36,6 +37,7 @@ import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.feed.model.market.details.converter.DescriptionConverter
@ -242,6 +244,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
relatedNews = MarketsTokenDetailsUM.RelatedNews(
articles = persistentListOf(),
onArticledClicked = {},
onFirstVisible = {},
onScroll = {},
),
),
)
@ -322,10 +326,36 @@ internal class MarketsTokenDetailsModel @Inject constructor(
/* preselectedIds */ relatedNews.map { it.id },
)
},
onFirstVisible = {
analyticsEventHandler.send(
MarketTokenAnalyticsEvent.TokenNewsViewed(
token = params.token.symbol,
),
)
},
onScroll = {
analyticsEventHandler.send(
MarketTokenAnalyticsEvent.TokenNewsCarouselScrolled(
token = params.token.symbol,
),
)
},
),
)
}
}
}.onLeft { throwable ->
val (code, message) = if (throwable is ApiResponseError.HttpException) {
throwable.code.numericCode to throwable.message.orEmpty()
} else {
null to ""
}
analyticsEventHandler.send(
MarketTokenAnalyticsEvent.TokenNewsLoadError(
code = code,
message = message,
),
)
}
}
}

View file

@ -3,11 +3,13 @@ package com.tangem.features.feed.model.news.details
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
@ -33,6 +35,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@ -129,7 +132,9 @@ internal class NewsDetailsModel @Inject constructor(
private fun onLikeClick(articleId: Int) {
modelScope.launch {
toggleArticleLikedUseCase.toggleLiked(articleId)
toggleArticleLikedUseCase
.toggleLiked(articleId)
.onLeft { Timber.e(it) }
analyticsEventHandler.send(NewsDetailsAnalyticsEvent.NewsLikeClicked(articleId))
}
}
@ -202,7 +207,35 @@ internal class NewsDetailsModel @Inject constructor(
observeNewsDetailsUseCase.prefetch(
newsIds = params.preselectedArticlesId,
language = currentLanguage,
).mapLeft {
).onLeft { errors ->
errors.onEach { (newsId, throwable) ->
when {
// an article is opened from deeplink and is not found
params.screenSource == AnalyticsParam.ScreensSources.NewsLink.value &&
newsId == params.articleId &&
throwable is ApiResponseError.HttpException &&
throwable.code == ApiResponseError.HttpException.Code.NOT_FOUND
-> {
analyticsEventHandler.send(
NewsDetailsAnalyticsEvent.NewsLinkMismatch(
newsId = params.articleId,
),
)
}
// global request executing error
newsId < 0 -> {
Timber.e(throwable)
}
else -> {
analyticsEventHandler.send(
NewsDetailsAnalyticsEvent.NewsArticleLoadError(
newsId = newsId,
),
)
}
}
}
}.mapLeft {
stateFactory.createErrorState()
}
}

View file

@ -41,4 +41,22 @@ internal sealed class NewsDetailsAnalyticsEvent(
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event + newsId
}
data class NewsArticleLoadError(
private val newsId: Int,
) : NewsDetailsAnalyticsEvent(
event = "News Article Load Error",
params = mapOf(
"News Id" to newsId.toString(),
),
)
data class NewsLinkMismatch(
private val newsId: Int,
) : NewsDetailsAnalyticsEvent(
event = "News Link Mismatch",
params = mapOf(
"News Id" to newsId.toString(),
),
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.feed.model.news.list.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
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.IS_NOT_HTTP_ERROR
internal sealed class NewsListAnalyticsEvent(
event: String,
@ -28,8 +29,4 @@ internal sealed class NewsListAnalyticsEvent(
"Selected Categories" to categoryId.toString(),
),
)
private companion object {
const val IS_NOT_HTTP_ERROR = "Is not http error"
}
}

View file

@ -3,7 +3,7 @@ package com.tangem.features.feed.ui.market.detailed.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -11,9 +11,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.items.DescriptionItem
@ -24,6 +24,8 @@ import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM.RelatedNews
private const val FOURTH_ITEM_INDEX = 3
@Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA]
internal fun LazyListScope.tokenMarketDetailsBody(
state: MarketsTokenDetailsUM.Body,
@ -215,7 +217,11 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp, top = 20.dp),
.padding(bottom = 32.dp, top = 20.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = relatedNews.onFirstVisible,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
@ -231,14 +237,23 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = listState,
) {
items(
itemsIndexed(
items = relatedNews.articles,
key = ArticleConfigUM::id,
) { article ->
key = { index, article -> article.id },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = relatedNews.onScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { relatedNews.onArticledClicked(article.id) },
modifier = Modifier
modifier = articleModifier
.height(164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),

View file

@ -46,6 +46,8 @@ internal object MarketsTokenDetailsPreview {
relatedNews = MarketsTokenDetailsUM.RelatedNews(
articles = persistentListOf(),
onArticledClicked = {},
onFirstVisible = {},
onScroll = {},
),
)
@ -136,6 +138,8 @@ internal object MarketsTokenDetailsPreview {
relatedNews = MarketsTokenDetailsUM.RelatedNews(
articles = persistentListOf(),
onArticledClicked = {},
onFirstVisible = {},
onScroll = {},
),
)
}

View file

@ -76,5 +76,7 @@ internal data class MarketsTokenDetailsUM(
data class RelatedNews(
val articles: ImmutableList<ArticleConfigUM>,
val onArticledClicked: (id: Int) -> Unit,
val onFirstVisible: () -> Unit,
val onScroll: () -> Unit,
)
}