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

@ -41,6 +41,11 @@ internal object NewsDomainModule {
return MarkArticleAsViewedUseCase(repository) return MarkArticleAsViewedUseCase(repository)
} }
@Provides
fun provideToggleArticleLikedUseCase(repository: NewsRepository): ToggleArticleLikedUseCase {
return ToggleArticleLikedUseCase(repository)
}
@Provides @Provides
fun provideGetNewsUseCase(repository: NewsRepository): GetNewsUseCase { fun provideGetNewsUseCase(repository: NewsRepository): GetNewsUseCase {
return GetNewsUseCase(repository) return GetNewsUseCase(repository)

View file

@ -77,6 +77,7 @@ sealed class AnalyticsParam {
data object Backup : ScreensSources("Backup") data object Backup : ScreensSources("Backup")
data object Onboarding : ScreensSources("Onboarding") data object Onboarding : ScreensSources("Onboarding")
data object LongTap : ScreensSources("Long Tap") data object LongTap : ScreensSources("Long Tap")
data object Market : ScreensSources("Market")
data object Markets : ScreensSources("Markets") data object Markets : ScreensSources("Markets")
data object MarketPulse : ScreensSources("Market Pulse") data object MarketPulse : ScreensSources("Market Pulse")
data object TangemPay : ScreensSources("Tangem Pay") data object TangemPay : ScreensSources("Tangem Pay")
@ -87,6 +88,9 @@ sealed class AnalyticsParam {
data object CreateWalletIntro : ScreensSources("Create Wallet Intro") data object CreateWalletIntro : ScreensSources("Create Wallet Intro")
data object AddNewWallet : ScreensSources("Add New Wallet") data object AddNewWallet : ScreensSources("Add New Wallet")
data object CreateWallet : ScreensSources("Create Wallet") data object CreateWallet : ScreensSources("Create Wallet")
data object NewsList : ScreensSources("News List")
data object NewsLink : ScreensSources("News Link")
data object NewsPage : ScreensSources("News Page")
} }
sealed class TxSentFrom(val value: String) { sealed class TxSentFrom(val value: String) {

View file

@ -3,6 +3,8 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore
import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.liked.DefaultNewsLikedStore
import com.tangem.datasource.local.news.liked.NewsLikedStore
import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.DefaultNewsViewedStore import com.tangem.datasource.local.news.viewed.DefaultNewsViewedStore
@ -34,4 +36,10 @@ internal object NewsStoreModule {
fun provideNewsViewedStore(): NewsViewedStore { fun provideNewsViewedStore(): NewsViewedStore {
return DefaultNewsViewedStore(store = RuntimeSharedStore()) return DefaultNewsViewedStore(store = RuntimeSharedStore())
} }
@Provides
@Singleton
fun provideNewsLikedStore(): NewsLikedStore {
return DefaultNewsLikedStore(store = RuntimeSharedStore())
}
} }

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.local.news.liked
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onStart
private typealias NewsLikedCache = Map<Int, Boolean>
internal class DefaultNewsLikedStore(
private val store: RuntimeSharedStore<NewsLikedCache>,
) : NewsLikedStore {
override fun getAll(): Flow<Map<Int, Boolean>> {
return store.get().onStart { emit(emptyMap()) }
}
override suspend fun getSync(): Map<Int, Boolean> {
return store.getSyncOrNull().orEmpty()
}
override suspend fun updateLiked(articleIds: Collection<Int>, liked: Boolean) {
if (articleIds.isEmpty()) return
store.update(emptyMap()) { current ->
val updated = current.toMutableMap()
articleIds.forEach { id ->
updated[id] = liked
}
updated
}
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.news.liked
import kotlinx.coroutines.flow.Flow
/**
* Store for news liked flags (runtime only).
*/
interface NewsLikedStore {
/**
* Observes all liked flags.
*/
fun getAll(): Flow<Map<Int, Boolean>>
/**
* Gets liked flags synchronously (returns empty map if no data).
*/
suspend fun getSync(): Map<Int, Boolean>
/**
* Updates liked flags for provided article ids.
*/
suspend fun updateLiked(articleIds: Collection<Int>, liked: Boolean)
}

View file

@ -170,6 +170,8 @@ fun PrimaryButtonIconStart(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showProgress: Boolean = false, showProgress: Boolean = false,
enabled: Boolean = true, enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) { ) {
TangemButton( TangemButton(
modifier = modifier, modifier = modifier,
@ -180,6 +182,8 @@ fun PrimaryButtonIconStart(
enabled = enabled, enabled = enabled,
showProgress = showProgress, showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1, textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
) )
} }
// endregion PrimaryButton // endregion PrimaryButton

View file

@ -4,6 +4,7 @@ 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.liked.NewsLikedStore
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.NewsErrorResolver
@ -27,6 +28,7 @@ internal object NewsDataModule {
newsDetailsStore: NewsDetailsStore, newsDetailsStore: NewsDetailsStore,
trendingNewsStore: TrendingNewsStore, trendingNewsStore: TrendingNewsStore,
newsViewedStore: NewsViewedStore, newsViewedStore: NewsViewedStore,
newsLikedStore: NewsLikedStore,
newsErrorResolver: NewsErrorResolver, newsErrorResolver: NewsErrorResolver,
): NewsRepository { ): NewsRepository {
return DefaultNewsRepository( return DefaultNewsRepository(
@ -35,6 +37,7 @@ internal object NewsDataModule {
newsDetailsStore = newsDetailsStore, newsDetailsStore = newsDetailsStore,
trendingNewsStore = trendingNewsStore, trendingNewsStore = trendingNewsStore,
newsViewedStore = newsViewedStore, newsViewedStore = newsViewedStore,
newsLikedStore = newsLikedStore,
newsErrorResolver = newsErrorResolver, newsErrorResolver = newsErrorResolver,
) )
} }

View file

@ -6,9 +6,13 @@ import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.news.models.response.NewsTrendingResponse import com.tangem.datasource.api.news.models.response.NewsTrendingResponse
import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.liked.NewsLikedStore
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.ArticleCategory
import com.tangem.domain.models.news.DetailedArticle
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.models.news.TrendingNews
import com.tangem.domain.news.NewsErrorResolver 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
@ -27,12 +31,14 @@ import timber.log.Timber
* Implementation of [NewsRepository]. * Implementation of [NewsRepository].
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
@Suppress("LongParameterList")
internal class DefaultNewsRepository( internal class DefaultNewsRepository(
private val newsApi: NewsApi, private val newsApi: NewsApi,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
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 newsLikedStore: NewsLikedStore,
private val newsErrorResolver: NewsErrorResolver, private val newsErrorResolver: NewsErrorResolver,
) : NewsRepository { ) : NewsRepository {
@ -71,9 +77,17 @@ internal class DefaultNewsRepository(
} }
override fun observeDetailedArticles(): Flow<Map<Int, DetailedArticle>> { override fun observeDetailedArticles(): Flow<Map<Int, DetailedArticle>> {
return newsDetailsStore.getAll().map { articles -> return newsDetailsStore.getAll()
articles.associateBy(DetailedArticle::id) .combine(newsLikedStore.getAll()) { articles, likedFlags ->
} articles.map { article ->
article.copy(
isLiked = isNewsLiked(article.id),
)
}
}
.map { articles ->
articles.associateBy(DetailedArticle::id)
}
} }
override suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?) { override suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?) {
@ -118,6 +132,16 @@ internal class DefaultNewsRepository(
newsViewedStore.updateViewed(articleIds, viewed) newsViewedStore.updateViewed(articleIds, viewed)
} }
private suspend fun isNewsLiked(articleId: Int): Boolean {
return newsLikedStore.getSync()[articleId] == true
}
override suspend fun toggleNewsLiked(articleId: Int): Boolean = withContext(dispatchers.io) {
val isNewsLikedValue = !isNewsLiked(articleId)
newsLikedStore.updateLiked(listOf(articleId), isNewsLikedValue)
isNewsLikedValue
}
private fun updateViewedStatusForNewsBatch( private fun updateViewedStatusForNewsBatch(
newsBatchFlow: NewsListBatchFlow, newsBatchFlow: NewsListBatchFlow,
scope: CoroutineScope, scope: CoroutineScope,
@ -148,7 +172,9 @@ internal class DefaultNewsRepository(
async { async {
newsApi.getNewsDetails(newsId = newsId, language = language) newsApi.getNewsDetails(newsId = newsId, language = language)
.getOrThrow() .getOrThrow()
.toDomainDetailedArticle() .toDomainDetailedArticle(
isLiked = isNewsLiked(newsId),
)
} }
}.awaitAll() }.awaitAll()
} }

View file

@ -6,7 +6,7 @@ import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto
import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto
import com.tangem.domain.models.news.* import com.tangem.domain.models.news.*
internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle { internal fun NewsDetailsResponse.toDomainDetailedArticle(isLiked: Boolean): DetailedArticle {
return DetailedArticle( return DetailedArticle(
id = id, id = id,
createdAt = createdAt, createdAt = createdAt,
@ -20,6 +20,7 @@ internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle {
shortContent = shortContent, shortContent = shortContent,
content = content, content = content,
originalArticles = originalArticles.map { it.toDomainOriginalArticle() }, originalArticles = originalArticles.map { it.toDomainOriginalArticle() },
isLiked = isLiked,
) )
} }

View file

@ -18,6 +18,7 @@ import kotlinx.serialization.Serializable
* @param shortContent - short description * @param shortContent - short description
* @param content - main text of the article * @param content - main text of the article
* @param originalArticles - original articles, which were base to build detailed article. * @param originalArticles - original articles, which were base to build detailed article.
* @param isLiked - flag indicating whether the news is liked or not
*/ */
@Serializable @Serializable
data class DetailedArticle( data class DetailedArticle(
@ -33,4 +34,5 @@ data class DetailedArticle(
val shortContent: String, val shortContent: String,
val content: String, val content: String,
val originalArticles: List<OriginalArticle>, val originalArticles: List<OriginalArticle>,
val isLiked: Boolean,
) )

View file

@ -61,4 +61,10 @@ interface NewsRepository {
* Updates viewed flag for provided news articles (applies to both regular and trending news). * Updates viewed flag for provided news articles (applies to both regular and trending news).
*/ */
suspend fun updateNewsViewed(articleIds: Collection<Int>, viewed: Boolean) suspend fun updateNewsViewed(articleIds: Collection<Int>, viewed: Boolean)
/**
* Toggle news liked state
* @return an actual liked state of the article
*/
suspend fun toggleNewsLiked(articleId: Int): Boolean
} }

View file

@ -0,0 +1,13 @@
package com.tangem.domain.news.usecase
import com.tangem.domain.news.repository.NewsRepository
class ToggleArticleLikedUseCase(private val repository: NewsRepository) {
/**
* Toggle an article liked state.
*/
suspend fun toggleLiked(articleId: Int): Boolean {
return repository.toggleNewsLiked(articleId)
}
}

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,12 +12,13 @@ internal class MarketDetailsAnalyticsEvent(
data class EventBuilder( data class EventBuilder(
val token: TokenMarketParams, 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", event = "Token Chart Screen Opened",
params = buildMap { params = buildMap {
put("Token", token.symbol) put("Token", token.symbol)
blockchain?.let { put("blockchain", it) } blockchain?.let { put("blockchain", it) }
put("Source", source) 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 androidx.compose.runtime.Stable
import arrow.core.getOrElse import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
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
@ -11,14 +12,17 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase 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.GetNewsListBatchFlowUseCase
import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase import com.tangem.domain.news.usecase.MarkArticleAsViewedUseCase
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase 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.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.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.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.ArticleUM
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM 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 getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase, private val markArticleAsViewedUseCase: MarkArticleAsViewedUseCase,
private val toggleArticleLikedUseCase: ToggleArticleLikedUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
) : Model() { ) : Model() {
private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>() private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>()
private val currentLanguage = Locale.getDefault().language 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 -> private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config ->
NewsDetailsPaginationManager( NewsDetailsPaginationManager(
@ -90,7 +96,7 @@ internal class NewsDetailsModel @Inject constructor(
articles = persistentListOf(), articles = persistentListOf(),
selectedArticleIndex = -1, selectedArticleIndex = -1,
onShareClick = {}, onShareClick = {},
onLikeClick = { /* [REDACTED_TODO_COMMENT] */ }, onLikeClick = ::onLikeClick,
onBackClick = params.onBackClicked, onBackClick = params.onBackClicked,
onArticleIndexChanged = ::onArticleIndexChanged, onArticleIndexChanged = ::onArticleIndexChanged,
), ),
@ -108,9 +114,36 @@ internal class NewsDetailsModel @Inject constructor(
val state: StateFlow<NewsDetailsUM> = _state.asStateFlow() val state: StateFlow<NewsDetailsUM> = _state.asStateFlow()
init { init {
trackScreenOpened()
loadNews() 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) { private fun onArticleIndexChanged(newIndex: Int) {
stateFactory.updateSelectedArticleIndex(newIndex) stateFactory.updateSelectedArticleIndex(newIndex)
val currentArticle = when (state.value.articlesStateUM) { 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.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.DetailedArticle 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.impl.R
import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.Source import com.tangem.features.feed.ui.news.details.state.Source
@ -23,7 +24,7 @@ import org.joda.time.DateTime
@Stable @Stable
internal class NewsDetailsConverter( internal class NewsDetailsConverter(
private val onSourceClick: (String) -> Unit, private val onSourceClick: (OriginalArticle) -> Unit,
) : Converter<DetailedArticle, ArticleUM> { ) : Converter<DetailedArticle, ArticleUM> {
override fun convert(value: DetailedArticle): ArticleUM { override fun convert(value: DetailedArticle): ArticleUM {
@ -38,6 +39,7 @@ internal class NewsDetailsConverter(
sources = buildSources(value), sources = buildSources(value),
newsUrl = value.newsUrl, newsUrl = value.newsUrl,
relatedTokens = value.relatedTokens.toImmutableList(), relatedTokens = value.relatedTokens.toImmutableList(),
isLiked = value.isLiked,
) )
} }
@ -66,7 +68,7 @@ internal class NewsDetailsConverter(
source = Source(id = originalArticle.source.id, name = originalArticle.source.name), source = Source(id = originalArticle.source.id, name = originalArticle.source.name),
publishedAt = mapFormattedDate(originalArticle.publishedAt), publishedAt = mapFormattedDate(originalArticle.publishedAt),
url = originalArticle.url, url = originalArticle.url,
onClick = { onSourceClick(originalArticle.url) }, onClick = { onSourceClick(originalArticle) },
imageUrl = originalArticle.imageUrl, imageUrl = originalArticle.imageUrl,
) )
}.toImmutableList() }.toImmutableList()

View file

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

View file

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

View file

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

View file

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