Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-15 12:01:25 +05:00
parent 2a5fca2b17
commit 417b2f0906
26 changed files with 313 additions and 32 deletions

View file

@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.Value
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
@ -51,7 +52,12 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
)
private val clickIntents = object : FeedEntryClickIntents {
override fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) {
override fun onMarketItemClick(
token: TokenMarketParams,
appCurrency: AppCurrency,
source: String,
newsId: Int?,
) {
innerRouter.push(
route = FeedEntryChildFactory.Child.TokenDetails(
params = DefaultMarketsTokenDetailsComponent.Params(
@ -60,11 +66,16 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
shouldShowPortfolio = true,
analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams(
blockchain = null,
source = "Market",
newsId = newsId,
source = source,
),
onBackClicked = { onChildBack() },
onArticleClick = { articleId, preselectedArticlesId ->
onArticleClick(articleId, preselectedArticlesId)
onArticleClick(
articleId = articleId,
preselectedArticlesId = preselectedArticlesId,
screenSource = AnalyticsParam.ScreensSources.Markets,
)
},
),
),
@ -76,7 +87,13 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
route = FeedEntryChildFactory.Child.TokenList(
params = DefaultMarketsTokenListComponent.Params(
onBackClicked = { onChildBack() },
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
onTokenClick = { token, currency ->
onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
preselectedSortType = sortBy ?: SortByTypeUM.Rating,
shouldAlwaysShowSearchBar = sortBy == null,
),
@ -87,16 +104,25 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
override fun onArticleClick(
articleId: Int,
preselectedArticlesId: List<Int>,
screenSource: AnalyticsParam.ScreensSources,
paginationConfig: NewsListConfig?,
) {
innerRouter.push(
FeedEntryChildFactory.Child.NewsDetails(
params = DefaultNewsDetailsComponent.Params(
screenSource = screenSource.value,
articleId = articleId,
onBackClicked = { onChildBack() },
preselectedArticlesId = preselectedArticlesId,
paginationConfig = paginationConfig,
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
onTokenClick = { token, currency ->
onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.NewsPage.value,
newsId = articleId,
)
},
),
),
)
@ -201,6 +227,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
clickIntents.onArticleClick(
articleId = articleId,
preselectedArticlesId = preselectedArticlesId,
screenSource = AnalyticsParam.ScreensSources.Token,
paginationConfig = null,
)
},
@ -209,17 +236,31 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
DefaultMarketsTokenListComponent.Params(
onBackClicked = { router.pop() },
onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) },
onTokenClick = { token, currency ->
clickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
preselectedSortType = SortByTypeUM.Rating,
shouldAlwaysShowSearchBar = false,
),
)
is FeedEntryRoute.NewsDetail -> FeedEntryChildFactory.Child.NewsDetails(
DefaultNewsDetailsComponent.Params(
screenSource = AnalyticsParam.ScreensSources.NewsLink.value,
articleId = entryRoute.articleId,
onBackClicked = { router.pop() },
preselectedArticlesId = entryRoute.preselectedArticlesId,
onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) },
onTokenClick = { token, currency ->
clickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.NewsPage.value,
newsId = entryRoute.articleId,
)
},
),
)
null -> FeedEntryChildFactory.Child.Feed

View file

@ -2,6 +2,7 @@ package com.tangem.features.feed.components
import androidx.compose.runtime.Immutable
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
@ -82,6 +83,7 @@ internal class FeedEntryChildFactory @Inject constructor(
feedEntryClickIntents.onArticleClick(
articleId = currentArticle,
preselectedArticlesId = prefetchedArticles,
screenSource = AnalyticsParam.ScreensSources.NewsList,
paginationConfig = paginationConfig,
)
},

View file

@ -78,6 +78,7 @@ internal class DefaultMarketsTokenDetailsComponent(
).screenOpened(
blockchain = analyticsParams.blockchain,
source = analyticsParams.source,
newsId = analyticsParams.newsId,
),
)
}
@ -138,5 +139,6 @@ internal class DefaultMarketsTokenDetailsComponent(
data class AnalyticsParams(
val blockchain: String?,
val source: String,
val newsId: Int? = null,
)
}

View file

@ -60,6 +60,7 @@ internal class DefaultNewsDetailsComponent(
@Serializable
data class Params(
val screenSource: String,
val articleId: Int,
val onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),

View file

@ -269,6 +269,7 @@ internal class FeedComponentModel @Inject constructor(
params.feedClickIntents.onMarketItemClick(
token = tokenMarket.toSerializableParam(),
appCurrency = currentAppCurrency.value,
source = AnalyticsParam.ScreensSources.Market.value,
)
}
}
@ -317,6 +318,7 @@ internal class FeedComponentModel @Inject constructor(
-> emptyList()
},
paginationConfig = null,
screenSource = AnalyticsParam.ScreensSources.Markets,
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.feed.model.feed
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
@ -9,18 +10,20 @@ import com.tangem.features.feed.model.market.list.state.SortByTypeUM
* Callback interface for feed model navigation actions.
*/
internal interface FeedModelClickIntents {
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency)
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency, source: String, newsId: Int? = null)
fun onMarketOpenClick(sortBy: SortByTypeUM?)
/**
* Method to open article of news from different places.
* @param articleId - id of article, which should be opened.
* @param preselectedArticlesId - ids of articles, which were prefetched before opening news details.
* @param screenSource - source of screen, from which article was opened.
* @param paginationConfig - config of current pagination from news list. Must be null, when open from other places.
*/
fun onArticleClick(
articleId: Int,
preselectedArticlesId: List<Int> = emptyList(),
screenSource: AnalyticsParam.ScreensSources,
paginationConfig: NewsListConfig? = null,
)
fun onOpenAllNews()

View file

@ -12,12 +12,13 @@ internal class MarketDetailsAnalyticsEvent(
data class EventBuilder(
val token: TokenMarketParams,
) {
fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent(
fun screenOpened(blockchain: String?, source: String, newsId: Int? = null) = MarketDetailsAnalyticsEvent(
event = "Token Chart Screen Opened",
params = buildMap {
put("Token", token.symbol)
blockchain?.let { put("blockchain", it) }
put("Source", source)
newsId?.let { put("News Id", it.toString()) }
},
)

View file

@ -2,6 +2,7 @@ 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.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -11,14 +12,17 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.models.news.OriginalArticle
import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase
import com.tangem.domain.news.usecase.ToggleArticleLikedUseCase
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.model.news.details.factory.NewsDetailsIndexManager
import com.tangem.features.feed.model.news.details.analytics.NewsDetailsAnalyticsEvent
import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter
import com.tangem.features.feed.model.news.details.loader.NewsRelatedTokensLoader
import com.tangem.features.feed.model.news.details.factory.NewsDetailsIndexManager
import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory
import com.tangem.features.feed.model.news.details.loader.NewsRelatedTokensLoader
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
@ -45,13 +49,15 @@ internal class NewsDetailsModel @Inject constructor(
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase,
private val toggleArticleLikedUseCase: ToggleArticleLikedUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>()
private val currentLanguage = Locale.getDefault().language
private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl)
private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = ::onOriginalArticleClick)
private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config ->
NewsDetailsPaginationManager(
@ -90,7 +96,7 @@ internal class NewsDetailsModel @Inject constructor(
articles = persistentListOf(),
selectedArticleIndex = -1,
onShareClick = {},
onLikeClick = { /* [REDACTED_TODO_COMMENT] */ },
onLikeClick = ::onLikeClick,
onBackClick = params.onBackClicked,
onArticleIndexChanged = ::onArticleIndexChanged,
),
@ -108,9 +114,36 @@ internal class NewsDetailsModel @Inject constructor(
val state: StateFlow<NewsDetailsUM> = _state.asStateFlow()
init {
trackScreenOpened()
loadNews()
}
private fun trackScreenOpened() {
analyticsEventHandler.send(
event = NewsDetailsAnalyticsEvent.NewsArticleOpened(
source = params.screenSource,
newsId = params.articleId,
),
)
}
private fun onLikeClick(articleId: Int) {
modelScope.launch {
toggleArticleLikedUseCase.toggleLiked(articleId)
analyticsEventHandler.send(NewsDetailsAnalyticsEvent.NewsLikeClicked(articleId))
}
}
private fun onOriginalArticleClick(originalArticle: OriginalArticle) {
analyticsEventHandler.send(
NewsDetailsAnalyticsEvent.RelatedNewsClicked(
newsId = params.articleId,
relatedNewsId = originalArticle.id,
),
)
urlOpener.openUrl(originalArticle.url)
}
private fun onArticleIndexChanged(newIndex: Int) {
stateFactory.updateSelectedArticleIndex(newIndex)
val currentArticle = when (state.value.articlesStateUM) {

View file

@ -0,0 +1,44 @@
package com.tangem.features.feed.model.news.details.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.OneTimePerSessionEvent
internal sealed class NewsDetailsAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets", event = event, params = params) {
data class NewsArticleOpened(
private val source: String,
private val newsId: Int,
) : NewsDetailsAnalyticsEvent(
event = "News Article Opened",
params = mapOf(
AnalyticsParam.SOURCE to source,
"News Id" to newsId.toString(),
),
)
data class RelatedNewsClicked(
private val newsId: Int,
private val relatedNewsId: Int,
) : NewsDetailsAnalyticsEvent(
event = "Related News Clicked",
params = mapOf(
"News Id" to newsId.toString(),
"Related News Id" to relatedNewsId.toString(),
),
)
data class NewsLikeClicked(
private val newsId: Int,
) : NewsDetailsAnalyticsEvent(
event = "News Like Clicked",
params = mapOf(
"News Id" to newsId.toString(),
),
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event + newsId
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.core.ui.utils.getFormattedDate
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.DetailedArticle
import com.tangem.domain.models.news.OriginalArticle
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.Source
@ -23,7 +24,7 @@ import org.joda.time.DateTime
@Stable
internal class NewsDetailsConverter(
private val onSourceClick: (String) -> Unit,
private val onSourceClick: (OriginalArticle) -> Unit,
) : Converter<DetailedArticle, ArticleUM> {
override fun convert(value: DetailedArticle): ArticleUM {
@ -38,6 +39,7 @@ internal class NewsDetailsConverter(
sources = buildSources(value),
newsUrl = value.newsUrl,
relatedTokens = value.relatedTokens.toImmutableList(),
isLiked = value.isLiked,
)
}
@ -66,7 +68,7 @@ internal class NewsDetailsConverter(
source = Source(id = originalArticle.source.id, name = originalArticle.source.name),
publishedAt = mapFormattedDate(originalArticle.publishedAt),
url = originalArticle.url,
onClick = { onSourceClick(originalArticle.url) },
onClick = { onSourceClick(originalArticle) },
imageUrl = originalArticle.imageUrl,
)
}.toImmutableList()

View file

@ -220,7 +220,7 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
) {
Text(
modifier = Modifier.padding(start = 16.dp),
text = "Related news",
text = stringResourceSafe(R.string.news_related_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)

View file

@ -112,10 +112,11 @@ private fun Content(state: NewsDetailsUM, background: Color) {
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
val article = state.articles[page]
ArticleDetail(
article = state.articles[page],
article = article,
modifier = Modifier.fillMaxSize(),
onLikeClick = state.onLikeClick,
onLikeClick = { state.onLikeClick(article.id) },
relatedTokensUM = state.relatedTokensUM,
)
}
@ -176,13 +177,23 @@ private fun ArticleDetail(
SpacerH(24.dp)
SecondaryButtonIconStart(
modifier = Modifier.padding(horizontal = 16.dp),
iconResId = R.drawable.ic_heart_20,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = onLikeClick,
)
if (article.isLiked) {
PrimaryButtonIconStart(
modifier = Modifier.padding(horizontal = 16.dp),
iconResId = R.drawable.ic_heart_20,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = { onLikeClick() },
)
} else {
SecondaryButtonIconStart(
modifier = Modifier.padding(horizontal = 16.dp),
iconResId = R.drawable.ic_heart_20,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = { onLikeClick() },
)
}
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,

View file

@ -48,6 +48,7 @@ internal object MockArticlesFactory {
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = true,
),
ArticleUM(
id = 2,
@ -76,6 +77,7 @@ internal object MockArticlesFactory {
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 3,
@ -104,6 +106,7 @@ internal object MockArticlesFactory {
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 4,
@ -132,6 +135,7 @@ internal object MockArticlesFactory {
).toPersistentList(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 5,
@ -146,6 +150,7 @@ internal object MockArticlesFactory {
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 6,
@ -161,6 +166,7 @@ internal object MockArticlesFactory {
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 7,
@ -175,6 +181,7 @@ internal object MockArticlesFactory {
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 8,
@ -189,6 +196,7 @@ internal object MockArticlesFactory {
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 9,
@ -204,6 +212,7 @@ internal object MockArticlesFactory {
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
ArticleUM(
id = 10,
@ -219,6 +228,7 @@ internal object MockArticlesFactory {
sources = persistentListOf(),
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
),
).toPersistentList()
}

View file

@ -12,7 +12,7 @@ internal data class NewsDetailsUM(
val articles: ImmutableList<ArticleUM>,
val selectedArticleIndex: Int,
val onShareClick: () -> Unit,
val onLikeClick: () -> Unit,
val onLikeClick: (articleId: Int) -> Unit,
val onBackClick: () -> Unit,
val onArticleIndexChanged: (Int) -> Unit,
val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading,
@ -36,6 +36,7 @@ internal data class ArticleUM(
val sources: ImmutableList<SourceUM>,
val newsUrl: String,
val relatedTokens: ImmutableList<RelatedToken>,
val isLiked: Boolean,
)
internal data class SourceUM(