Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-26 11:05:04 +01:00
parent 636ae3ee50
commit a71b7ed4ba
43 changed files with 590 additions and 681 deletions

View file

@ -15,10 +15,13 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.model.feed.FeedModelClickIntents
@ -55,6 +58,9 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
source = "Market",
),
onBackClicked = { onChildBack() },
onArticleClick = { articleId, preselectedArticlesId ->
onArticleClick(articleId, preselectedArticlesId)
},
),
),
)
@ -73,8 +79,16 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
)
}
override fun onArticleClick(articleId: Int) {
innerRouter.push(FeedEntryChildFactory.Child.NewsDetails)
override fun onArticleClick(articleId: Int, preselectedArticlesId: List<Int>) {
innerRouter.push(
FeedEntryChildFactory.Child.NewsDetails(
params = DefaultNewsDetailsComponent.Params(
articleId = articleId,
onBackClicked = { onChildBack() },
preselectedArticlesId = preselectedArticlesId,
),
),
)
}
override fun onOpenAllNews() {
@ -121,20 +135,25 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val bottomSheetState = remember {
derivedStateOf { BottomSheetState.EXPANDED }
}
val background = TangemTheme.colors.background.tertiary
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
val bottomSheetState = remember {
derivedStateOf { BottomSheetState.EXPANDED }
}
BackHandler {
router.pop()
}
BackHandler {
router.pop()
}
EntryContent(
bottomSheetState = bottomSheetState,
stackState = stack.subscribeAsState(),
onHeaderSizeChange = {},
isOpenedInBottomSheet = false,
)
EntryContent(
bottomSheetState = bottomSheetState,
stackState = stack.subscribeAsState(),
onHeaderSizeChange = {},
isOpenedInBottomSheet = false,
)
}
}
private fun onChildBack() {
@ -157,6 +176,9 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
)
},
onBackClicked = { router.pop() },
onArticleClick = { articleId, preselectedArticlesId ->
clickIntents.onArticleClick(articleId, preselectedArticlesId)
},
),
)
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
@ -167,6 +189,13 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
shouldAlwaysShowSearchBar = false,
),
)
is FeedEntryRoute.NewsDetail -> FeedEntryChildFactory.Child.NewsDetails(
DefaultNewsDetailsComponent.Params(
articleId = entryRoute.articleId,
onBackClicked = { router.pop() },
preselectedArticlesId = entryRoute.preselectedArticlesId,
),
)
null -> FeedEntryChildFactory.Child.Feed
}
}

View file

@ -43,7 +43,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data object NewsDetails : Child
data class NewsDetails(val params: DefaultNewsDetailsComponent.Params) : Child
}
fun createChild(
@ -67,9 +67,10 @@ internal class FeedEntryChildFactory @Inject constructor(
params = child.params,
)
}
Child.NewsDetails -> {
is Child.NewsDetails -> {
DefaultNewsDetailsComponent(
appComponentContext = appComponentContext,
params = child.params,
)
}
Child.NewsList -> {

View file

@ -15,7 +15,6 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
@ -87,13 +86,14 @@ internal class DefaultMarketsTokenDetailsComponent(
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
val background = LocalMainBottomSheetColor.current.value
MarketsTokenDetailsTopBar(
onBackClick = { params.onBackClicked() },
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = TangemTheme.colors.background.tertiary,
backgroundColor = background,
)
}
@ -131,6 +131,7 @@ internal class DefaultMarketsTokenDetailsComponent(
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams?,
val onBackClicked: () -> Unit,
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
)
@Serializable

View file

@ -20,6 +20,8 @@ import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.feed.impl.R
@ -41,6 +43,7 @@ internal class TokenActionsHandler @AssistedInject constructor(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
private val shareManager: ShareManager,
private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase,
) {
private val disabledActionsInDemoMode = buildSet {
@ -186,22 +189,37 @@ internal class TokenActionsHandler @AssistedInject constructor(
val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData ->
currencyData.userWallet.walletId to currencyData.status
}
if (cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true) {
router.push(
AppRoute.YieldSupplyActive(
val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull()
val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true
when {
tokenEnterStatus != null -> router.push(
AppRoute.CurrencyDetails(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = yieldSupplyApy,
),
)
} else {
router.push(
AppRoute.YieldSupplyPromo(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = yieldSupplyApy,
currency = cryptoCurrencyStatus.currency,
navigationAction = NavigationAction.YieldSupply(
isActive = isActiveYield,
),
),
)
isActiveYield -> {
router.push(
AppRoute.YieldSupplyActive(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = yieldSupplyApy,
),
)
}
else -> {
router.push(
AppRoute.YieldSupplyPromo(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = yieldSupplyApy,
),
)
}
}
}

View file

@ -44,7 +44,7 @@ internal sealed class QuickActionUM(
data class YieldMode(
private val apy: String,
) : QuickActionUM(
title = resourceReference(R.string.yield_module_start_earning),
title = resourceReference(R.string.common_yield_mode),
description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)),
icon = R.drawable.ic_analytics_up_mini_24,
)

View file

@ -48,7 +48,7 @@ internal data class TokenActionsBSContentUM(
iconRes = R.drawable.ic_staking_24,
),
YieldMode(
text = resourceReference(R.string.yield_module_start_earning),
text = resourceReference(R.string.common_yield_mode),
iconRes = R.drawable.ic_analytics_up_mini_24,
),
;

View file

@ -2,18 +2,63 @@ package com.tangem.features.feed.components.news.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.ui.news.details.NewsDetailsContent
import kotlinx.serialization.Serializable
internal class DefaultNewsDetailsComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {}
private val newsDetailsModel = getOrCreateModel<NewsDetailsModel, Params>(params = params)
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {}
override fun Title(bottomSheetState: State<BottomSheetState>) {
val background = LocalMainBottomSheetColor.current.value
val state by newsDetailsModel.state.collectAsStateWithLifecycle()
TangemTopAppBar(
containerColor = background,
title = null,
startButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_back_24,
onClicked = state.onBackClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
),
endButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_share_24,
onClicked = state.onShareClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
),
)
}
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
val state by newsDetailsModel.state.collectAsStateWithLifecycle()
NewsDetailsContent(
state = state,
modifier = modifier,
)
}
@Serializable
data class Params(
val articleId: Int,
val onBackClicked: () -> Unit,
val preselectedArticlesId: List<Int> = emptyList(),
val tokenIds: List<String> = emptyList(),
val categoryIds: List<Int> = emptyList(),
)
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -29,4 +30,9 @@ internal interface ModelModule {
@IntoMap
@ClassKey(MarketsTokenDetailsModel::class)
fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model
@Binds
@IntoMap
@ClassKey(NewsDetailsModel::class)
fun provideNewsDetailsModel(model: NewsDetailsModel): Model
}

View file

@ -31,6 +31,7 @@ import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import javax.inject.Inject
import kotlin.collections.all
import kotlin.collections.map
@Stable
@ModelScoped
@ -311,7 +312,17 @@ internal class FeedComponentModel @Inject constructor(
params.feedClickIntents.onMarketOpenClick(sortBy)
},
onArticleClick = { articleId ->
params.feedClickIntents.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()
},
)
},
onOpenAllNews = {
params.feedClickIntents.onOpenAllNews()

View file

@ -10,6 +10,6 @@ import com.tangem.features.feed.model.market.list.state.SortByTypeUM
internal interface FeedModelClickIntents {
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency)
fun onMarketOpenClick(sortBy: SortByTypeUM?)
fun onArticleClick(articleId: Int)
fun onArticleClick(articleId: Int, preselectedArticlesId: List<Int> = emptyList())
fun onOpenAllNews()
}

View file

@ -241,9 +241,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
onShouldShowPriceSubtitleChange = ::onShouldShowPriceSubtitleChange,
relatedNews = MarketsTokenDetailsUM.RelatedNews(
articles = persistentListOf(),
onArticledClicked = {
// TODO in [REDACTED_JIRA]
},
onArticledClicked = {},
),
),
)
@ -313,9 +311,16 @@ internal class MarketsTokenDetailsModel @Inject constructor(
),
).onRight { articles ->
state.update { marketsTokenDetailsUM ->
val relatedNews = relatedNewsConverter.convert(articles)
marketsTokenDetailsUM.copy(
relatedNews = marketsTokenDetailsUM.relatedNews.copy(
articles = relatedNewsConverter.convert(articles),
articles = relatedNews,
onArticledClicked = { articledId ->
params.onArticleClick(
/* articledId */ articledId,
/* preselectedIds */ relatedNews.map { it.id },
)
},
),
)
}

View file

@ -4,20 +4,14 @@ import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
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.ShortArticle
import com.tangem.features.feed.impl.R
import com.tangem.utils.StringsSigns
import com.tangem.features.feed.ui.utils.mapFormattedDate
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import org.joda.time.DateTime
class RelatedNewsConverter : Converter<List<ShortArticle>, ImmutableList<ArticleConfigUM>> {
@ -51,34 +45,4 @@ class RelatedNewsConverter : Converter<List<ShortArticle>, ImmutableList<Article
}
return (categoryLabels + tokenLabels).toPersistentSet()
}
private fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}
}

View file

@ -122,7 +122,7 @@ internal class MarketsListModel @Inject constructor(
flow4 = shouldShowYieldModeMarketPromoUseCase(
appCurrency = currentAppCurrency.value,
interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(),
),
).conflate(),
) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, isYieldModePromo ->
MarketsItemsData(
items = uiItems,
@ -138,7 +138,7 @@ internal class MarketsListModel @Inject constructor(
flow3 = shouldShowYieldModeMarketPromoUseCase(
appCurrency = currentAppCurrency.value,
interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(),
),
).conflate(),
) { uiItems, isInInitialLoadingErrorState, shouldShowYieldModePromo ->
MarketsItemsData(
items = uiItems,

View file

@ -0,0 +1,93 @@
package com.tangem.features.feed.model.news.details
import androidx.compose.runtime.Stable
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.url.UrlOpener
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class NewsDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase,
private val urlOpener: UrlOpener,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>()
private val currentLanguage = Locale.getDefault().language
private val converter = NewsDetailsConverter(
onSourceClick = { url ->
urlOpener.openUrl(url)
},
)
private val _state = MutableStateFlow(
NewsDetailsUM(
articles = persistentListOf(),
selectedArticleIndex = 0,
onShareClick = { /* [REDACTED_TODO_COMMENT] */ },
onLikeClick = { /* [REDACTED_TODO_COMMENT] */ },
onBackClick = params.onBackClicked,
onArticleIndexChanged = { /* [REDACTED_TODO_COMMENT] */ },
),
)
val state: StateFlow<NewsDetailsUM> = _state.asStateFlow()
init {
if (params.preselectedArticlesId.isNotEmpty()) {
handlePreselectedArticles()
} else {
// TODO handle pagination [REDACTED_TASK_KEY]
}
}
private fun handlePreselectedArticles() {
modelScope.launch {
observeNewsDetailsUseCase.prefetch(
newsIds = params.preselectedArticlesId,
language = currentLanguage,
)
observeNewsDetailsUseCase
.invoke()
.map { articlesMap ->
params.preselectedArticlesId.mapNotNull { articleId ->
articlesMap[articleId]?.let { detailedArticle ->
converter.convert(detailedArticle)
}
}
}
.map { articles ->
articles.toImmutableList()
}
.onEach { articles ->
val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0
_state.update { newsDetailsUM ->
newsDetailsUM.copy(
articles = articles,
selectedArticleIndex = selectedIndex,
)
}
}
.launchIn(modelScope)
}
}
}

View file

@ -0,0 +1,101 @@
package com.tangem.features.feed.model.news.details.converter
import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
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.features.feed.impl.R
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.SourceUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.joda.time.DateTime
@Stable
internal class NewsDetailsConverter(
private val onSourceClick: (String) -> Unit,
) : Converter<DetailedArticle, ArticleUM> {
override fun convert(value: DetailedArticle): ArticleUM {
return ArticleUM(
id = value.id,
title = value.title,
createdAt = mapFormattedDate(value.createdAt),
score = value.score,
tags = buildTags(value),
shortContent = value.shortContent,
content = value.content,
sources = buildSources(value),
)
}
private fun buildTags(article: DetailedArticle): ImmutableList<LabelUM> {
val categoryLabels = article.categories.map { category ->
LabelUM(text = TextReference.Str(category.name))
}
val tokenLabels = article.relatedTokens.map { token ->
LabelUM(
text = TextReference.Str(token.symbol),
leadingContent = LabelLeadingContentUM.Token(
iconUrl = getTokenIconUrlFromDefaultHost(
tokenId = CryptoCurrency.RawID(token.id),
),
),
)
}
return (categoryLabels + tokenLabels).toImmutableList()
}
private fun buildSources(article: DetailedArticle): ImmutableList<SourceUM> {
return article.originalArticles.map { originalArticle ->
SourceUM(
id = originalArticle.id,
title = originalArticle.title,
source = Source(id = originalArticle.source.id, name = originalArticle.source.name),
publishedAt = mapFormattedDate(originalArticle.publishedAt),
url = originalArticle.url,
onClick = { onSourceClick(originalArticle.url) },
)
}.toImmutableList()
}
private fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}
}

View file

@ -357,7 +357,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = Modifier
.height(164.dp)
.heightIn(min = 164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
@ -365,7 +365,9 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
item {
ShowMoreArticlesCard(
modifier = Modifier.size(width = 216.dp, height = 164.dp),
modifier = Modifier
.width(216.dp)
.heightIn(min = 164.dp),
onClick = feedListCallbacks.onOpenAllNews,
)
}

View file

@ -4,22 +4,16 @@ import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
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.ShortArticle
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.utils.mapFormattedDate
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import org.joda.time.DateTime
internal class TrendingNewsStateFactory(
private val currentStateProvider: Provider<FeedListUM>,
@ -111,34 +105,4 @@ internal class TrendingNewsStateFactory(
}
return (categoryLabels + tokenLabels).toPersistentSet()
}
private fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}
}

View file

@ -142,14 +142,14 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif
)
}
val marketsNotification = state.marketsNotificationUM
AnimatedVisibility(
state.isInSearchMode.not() &&
state.list !is ListUM.LoadingError && state.isInSearchMode.not() &&
state.selectedSortBy != SortByTypeUM.YieldSupply,
) {
val wrappedNotification = remember(this) { state.marketsNotificationUM }
val showMore = stringResourceSafe(R.string.common_show_more)
when (marketsNotification) {
when (wrappedNotification) {
is MarketsNotificationUM.YieldSupplyPromo -> {
val description = stringResourceSafe(
R.string.markets_yield_supply_banner_description,
@ -165,7 +165,7 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif
}
YieldSupplyInMarketsPromoNotification(
config = marketsNotification.config.copy(
config = wrappedNotification.config.copy(
subtitle = clickableDescription,
),
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),

View file

@ -0,0 +1,288 @@
package com.tangem.features.feed.ui.news.details
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleHeader
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.pager.PagerIndicator
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
import com.tangem.features.feed.ui.news.details.state.SourceUM
@Composable
internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
val pagerState = rememberPagerState(
initialPage = state.selectedArticleIndex,
pageCount = { state.articles.size },
)
LaunchedEffect(state.selectedArticleIndex) {
if (pagerState.currentPage != state.selectedArticleIndex) {
pagerState.scrollToPage(state.selectedArticleIndex)
}
}
LaunchedEffect(pagerState.currentPage) {
if (pagerState.currentPage != state.selectedArticleIndex) {
state.onArticleIndexChanged(pagerState.currentPage)
}
}
Column(
modifier = modifier
.fillMaxSize()
.background(background),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (state.articles.isNotEmpty()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
ArticleDetail(
article = state.articles[page],
modifier = Modifier.fillMaxSize(),
onLikeClick = state.onLikeClick,
)
}
if (state.articles.size > 1) {
PagerIndicator(
pagerState = pagerState,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars),
)
}
}
}
}
}
@Suppress("LongMethod")
@Composable
private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onLikeClick: () -> Unit) {
val density = LocalDensity.current
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(bottom = 56.dp + WindowInsets.navigationBars.getBottom(density).dp),
) {
item {
ArticleHeader(
title = article.title,
createdAt = article.createdAt.resolveReference(),
score = article.score,
tags = article.tags,
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 16.dp),
)
if (article.shortContent.isNotEmpty()) {
QuickRecap(
content = article.shortContent,
modifier = Modifier
.padding(top = 32.dp)
.padding(horizontal = 16.dp),
)
}
Text(
text = article.content,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 16.dp),
)
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,
)
// TODO [REDACTED_TASK_KEY] add related tokens block
if (article.sources.isNotEmpty()) {
SpacerH(24.dp)
Row(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResourceSafe(R.string.news_sources),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
Text(
text = "${article.sources.size}",
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
if (article.sources.isNotEmpty()) {
item {
LazyRow(
modifier = Modifier.padding(vertical = 12.dp),
state = rememberLazyListState(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = article.sources,
key = SourceUM::id,
) { source ->
SourceItem(source = source)
}
}
}
}
}
}
@Composable
private fun QuickRecap(content: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier.height(IntrinsicSize.Min),
) {
VerticalDivider(
modifier = Modifier.fillMaxHeight(),
thickness = 2.dp,
color = TangemTheme.colors.stroke.primary,
)
Column(
modifier = Modifier
.padding(start = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_quick_recap_16),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResourceSafe(R.string.news_quick_recap),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.accent,
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = content,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
@Composable
private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.widthIn(max = 216.dp)
.heightIn(min = 132.dp)
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(12.dp),
)
.clickable(onClick = source.onClick)
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_explore_16),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.size(16.dp),
)
SpacerW(4.dp)
Text(
text = source.source.name,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors.text.tertiary,
)
}
if (source.title.isNotEmpty()) {
SpacerH(4.dp)
Text(
text = source.title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 12.dp),
)
}
Text(
text = source.publishedAt.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewNewsDetailsContent() {
TangemThemePreview {
NewsDetailsContent(
state = NewsDetailsUM(
articles = MockArticlesFactory.createMockArticles(),
selectedArticleIndex = 0,
onShareClick = {},
onLikeClick = {},
onBackClick = {},
),
)
}
}

View file

@ -0,0 +1,199 @@
package com.tangem.features.feed.ui.news.details.state
import com.tangem.core.ui.components.label.entity.LabelSize
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Suppress("MaximumLineLength", "LongMethod")
internal object MockArticlesFactory {
fun createMockArticles(): ImmutableList<ArticleUM> = listOf(
ArticleUM(
id = 1,
title = "SEC delays decisions on ETH-staking ETFs and spot XRP/SOL funds",
createdAt = TextReference.Str("20 Jun, 21:45"),
score = 6.5f,
tags = listOf(
LabelUM(text = TextReference.Str("Regulation"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "Bitwise has updated its Solana ETF, adding staking and setting a low management fee of 0.20%.",
content = "Bitwise Asset Management has revised its filing to launch a Solana ETF, renaming it \"Bitwise Solana Staking ETF\" and setting an exceptionally low management fee of just 0.20%.\n\nThe update comes as the SEC prepares to review several Solana ETF applications.",
sources = listOf(
SourceUM(
id = 1,
title = "Deeper liquidity could drive crypto market beyond \$6T",
source = Source(
id = 11,
name = "Coin-telegraph",
),
publishedAt = TextReference.Str("1h ago"),
url = "https://cointelegraph.com",
onClick = {},
),
SourceUM(
id = 2,
title = "Top gainers and losers in crypto this week",
source = Source(
id = 10,
name = "Investing",
),
publishedAt = TextReference.Str("2h ago"),
url = "https://investing.com",
onClick = {},
),
).toPersistentList(),
),
ArticleUM(
id = 2,
title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)",
createdAt = TextReference.Str("20 Jun, 20:15"),
score = 8.2f,
tags = listOf(
LabelUM(text = TextReference.Str("BTC"), size = LabelSize.BIG),
LabelUM(text = TextReference.Str("ETF"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "Bitcoin spot ETFs recorded fourth consecutive day of positive inflows.",
content = "Bitcoin spot ETFs continue their impressive streak with \$550 million in net positive inflows.\n\nBlackRock's IBIT led with \$250M.",
sources = listOf(
SourceUM(
id = 3,
title = "Bitcoin ETFs see massive inflows",
source = Source(
id = 12,
name = "Bloomberg",
),
publishedAt = TextReference.Str("3h ago"),
url = "https://bloomberg.com",
onClick = {},
),
).toPersistentList(),
),
ArticleUM(
id = 3,
title = "Ethereum network upgrade scheduled for Q2 2025",
createdAt = TextReference.Str("20 Jun, 18:30"),
score = 7.8f,
tags = listOf(
LabelUM(text = TextReference.Str("ETH"), size = LabelSize.BIG),
LabelUM(text = TextReference.Str("Technology"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "Ethereum developers announced major network upgrade.",
content = "The Ethereum Foundation announced a significant upgrade for Q2 2025.\n\nKey improvements include EVM enhancements.",
sources = listOf(
SourceUM(
id = 4,
title = "Ethereum core devs announce upgrade",
source = Source(
id = 15,
name = "CoinDesk",
),
publishedAt = TextReference.Str("5h ago"),
url = "https://coindesk.com",
onClick = {},
),
).toPersistentList(),
),
ArticleUM(
id = 4,
title = "Solana surpasses Ethereum in daily transaction volume",
createdAt = TextReference.Str("20 Jun, 16:00"),
score = 9.1f,
tags = listOf(
LabelUM(text = TextReference.Str("SOL"), size = LabelSize.BIG),
LabelUM(text = TextReference.Str("Market"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "Solana achieved new milestone processing more daily transactions than Ethereum.",
content = "Solana processed over 50 million transactions in a single day.\n\nDriven by DeFi and NFT activity.",
sources = listOf(
SourceUM(
id = 5,
title = "Solana transactions hit record",
source = Source(
id = 18,
name = "Times",
),
publishedAt = TextReference.Str("7h ago"),
url = "https://theblock.co",
onClick = {},
),
).toPersistentList(),
),
ArticleUM(
id = 5,
title = "DeFi protocol launches innovative yield farming strategy",
createdAt = TextReference.Str("20 Jun, 14:20"),
score = 6.9f,
tags = listOf(
LabelUM(text = TextReference.Str("DeFi"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "New DeFi protocol introduced innovative yield farming approach.",
content = "A newly launched protocol unveiled innovative yield farming mechanism.\n\nAPY rates range from 15% to 30%.",
sources = persistentListOf(),
),
ArticleUM(
id = 6,
title = "Crypto regulation bill advances in US Senate",
createdAt = TextReference.Str("20 Jun, 12:45"),
score = 8.7f,
tags = listOf(
LabelUM(text = TextReference.Str("Regulation"), size = LabelSize.BIG),
LabelUM(text = TextReference.Str("USA"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "Comprehensive cryptocurrency regulation bill passed Senate Banking Committee.",
content = "The US Senate Banking Committee advanced landmark crypto regulation bill.\n\nKey provisions include asset definitions.",
sources = persistentListOf(),
),
ArticleUM(
id = 7,
title = "Major bank announces crypto custody services",
createdAt = TextReference.Str("20 Jun, 10:30"),
score = 7.3f,
tags = listOf(
LabelUM(text = TextReference.Str("Adoption"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "World's largest bank announced cryptocurrency custody services.",
content = "Major financial institution announced comprehensive crypto custody services.\n\nSupporting Bitcoin and Ethereum initially.",
sources = persistentListOf(),
),
ArticleUM(
id = 8,
title = "NFT marketplace reports 300% increase in trading volume",
createdAt = TextReference.Str("20 Jun, 08:15"),
score = 5.8f,
tags = listOf(
LabelUM(text = TextReference.Str("NFT"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "Leading NFT marketplace experienced dramatic surge in trading activity.",
content = "Prominent NFT marketplace reported 300% increase in trading volume.\n\nNew features include lower fees.",
sources = persistentListOf(),
),
ArticleUM(
id = 9,
title = "Layer 2 solution achieves 100,000 TPS milestone",
createdAt = TextReference.Str("19 Jun, 22:00"),
score = 8.5f,
tags = listOf(
LabelUM(text = TextReference.Str("Technology"), size = LabelSize.BIG),
LabelUM(text = TextReference.Str("L2"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "New Layer 2 scaling solution achieved 100,000 TPS in testing.",
content = "Layer 2 solution processed 100,000 transactions per second.\n\nUsing zero-knowledge proof technology.",
sources = persistentListOf(),
),
ArticleUM(
id = 10,
title = "Stablecoin market cap reaches new all-time high",
createdAt = TextReference.Str("19 Jun, 19:30"),
score = 7.6f,
tags = listOf(
LabelUM(text = TextReference.Str("Stablecoins"), size = LabelSize.BIG),
LabelUM(text = TextReference.Str("Market"), size = LabelSize.BIG),
).toPersistentList(),
shortContent = "Total stablecoin market capitalization surpassed \$180 billion.",
content = "Stablecoin market cap reached \$180 billion all-time high.\n\nDriven by DeFi activity and institutional adoption.",
sources = persistentListOf(),
),
).toPersistentList()
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.feed.ui.news.details.state
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class NewsDetailsUM(
val articles: ImmutableList<ArticleUM>,
val selectedArticleIndex: Int,
val onShareClick: () -> Unit,
val onLikeClick: () -> Unit,
val onBackClick: () -> Unit,
val onArticleIndexChanged: (Int) -> Unit = {},
)
internal data class ArticleUM(
val id: Int,
val title: String,
val createdAt: TextReference,
val score: Float,
val tags: ImmutableList<LabelUM>,
val shortContent: String,
val content: String,
val sources: ImmutableList<SourceUM>,
)
internal data class SourceUM(
val id: Int,
val title: String,
val source: Source,
val publishedAt: TextReference,
val url: String,
val onClick: () -> Unit,
)
internal data class Source(
val id: Int,
val name: String,
)

View file

@ -0,0 +1,40 @@
package com.tangem.features.feed.ui.utils
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
import com.tangem.core.ui.utils.getFormattedDate
import com.tangem.features.feed.impl.R
import com.tangem.utils.StringsSigns
import org.joda.time.DateTime
internal fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}