Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-05 08:39:53 +01:00
parent 0d4fd5874e
commit 8cd088188f
20 changed files with 1119 additions and 489 deletions

View file

@ -851,6 +851,7 @@
<string name="news_related_tokens">Related tokens</string>
<string name="news_sources">Related news</string>
<string name="news_stay_in_the_loop">Stay in the loop</string>
<string name="news_trending_score">Trending score</string>
<string name="nfc_error_unavailable">NFC is not available on your device</string>
<string name="nft_about_title">About NFT</string>
<string name="nft_asset">NFT asset</string>

View file

@ -81,6 +81,7 @@ fun BottomFadeWithBlur(backgroundColor: Color, modifier: Modifier = Modifier) {
),
),
),
backgroundColor = Color.Transparent,
),
) {
progressive =

View file

@ -66,7 +66,7 @@ fun Chip(state: ChipUM, modifier: Modifier = Modifier) {
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ChipPreviewV() {
private fun ChipPreview() {
TangemThemePreview {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),

View file

@ -1,7 +1,6 @@
package com.tangem.core.ui.components.pager
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@ -22,132 +21,147 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import dev.chrisbanes.haze.HazeStyle
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
private const val ANIMATION_DURATION = 300
private const val MAX_VISIBLE_DOTS = 5
internal const val ANIMATION_DURATION = 300
internal const val MAX_VISIBLE_DOTS = 5
internal val SPACING = 4.dp
internal val HINT_DOT_SIZE = DpSize(6.dp, 6.dp)
private const val MIN_HIDDEN_FOR_SMALL_DOT = 2
private const val MIN_DISTANCE_FOR_SMALL_DOT = 3
private const val MIN_DISTANCE_FOR_HINT_DOT = 2
private val SPACING = 4.dp
private val BACKGROUND_SIZE = DpSize(92.dp, 32.dp)
private val CURRENT_DOT_SIZE = DpSize(16.dp, 8.dp)
private val NORMAL_DOT_SIZE = DpSize(8.dp, 8.dp)
private val HINT_DOT_SIZE = DpSize(6.dp, 6.dp)
private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier) {
val totalPages = pagerState.pageCount
val currentIndex = pagerState.currentPage
if (LocalRedesignEnabled.current) {
PagerIndicatorV2(pagerState, modifier)
} else {
PagerIndicatorV1(pagerState, modifier)
}
}
@Composable
private fun PagerIndicatorV1(pagerState: PagerState, modifier: Modifier = Modifier) {
val colors = PagerIndicatorColors(
active = TangemTheme.colors.control.key,
inactive = TangemTheme.colors.text.tertiary,
overlay = TangemTheme.colors.overlay.secondary,
)
PagerIndicatorContent(
pagerState = pagerState,
colors = colors,
modifier = modifier,
)
}
@Composable
private fun PagerIndicatorV2(pagerState: PagerState, modifier: Modifier = Modifier) {
val colors = PagerIndicatorColors(
active = TangemTheme.colors2.graphic.neutral.primary,
inactive = TangemTheme.colors2.graphic.neutral.tertiary,
overlay = TangemTheme.colors2.tabs.backgroundSecondary.copy(alpha = .1f),
)
PagerIndicatorContent(
pagerState = pagerState,
colors = colors,
modifier = modifier,
boxModifier = Modifier.hazeEffectTangem(style = HazeStyle(blurRadius = 22.dp, tint = null)),
)
}
@Composable
private fun rememberPagerIndicatorAnimationState(pagerState: PagerState): PagerIndicatorAnimationState {
val density = LocalDensity.current
return remember(pagerState.pageCount, density) {
PagerIndicatorAnimationState(pagerState.pageCount, pagerState.currentPage, density)
}
}
@Suppress("LongParameterList")
private fun calculateDotAlpha(
isSliding: Boolean,
slideDirection: Int,
index: Int,
displayLower: Int,
displayUpper: Int,
fadeProgress: Float,
): Float {
return when {
!isSliding -> 1f
slideDirection > 0 && index == displayLower -> 1f - fadeProgress
slideDirection > 0 && index == displayUpper - 1 -> fadeProgress
slideDirection < 0 && index == displayUpper - 1 -> 1f - fadeProgress
slideDirection < 0 && index == displayLower -> fadeProgress
else -> 1f
}
}
@Composable
private fun PagerIndicatorContent(
pagerState: PagerState,
colors: PagerIndicatorColors,
modifier: Modifier = Modifier,
boxModifier: Modifier = Modifier,
) {
val totalPages = pagerState.pageCount
if (totalPages == 0) return
val indicatorColor = TangemTheme.colors.control.key
val overlayColor = TangemTheme.colors.overlay.secondary
val inactiveIndicatorColor = TangemTheme.colors.text.tertiary
val density = LocalDensity.current
val (targetLower, targetUpper) = getWindowBounds(totalPages, currentIndex)
var displayLower by remember { mutableIntStateOf(targetLower) }
var displayUpper by remember { mutableIntStateOf(targetUpper) }
var prevTargetLower by remember { mutableIntStateOf(targetLower) }
val slideOffset = remember { Animatable(0f) }
var isSliding by remember { mutableStateOf(false) }
var slideDirection by remember { mutableIntStateOf(0) }
val fadeProgress = remember { Animatable(0f) }
var fadeJob by remember { mutableStateOf<Job?>(null) }
val animState = rememberPagerIndicatorAnimationState(pagerState)
val (targetLower, targetUpper) = getWindowBounds(pagerState.pageCount, pagerState.currentPage)
LaunchedEffect(targetLower) {
if (targetLower != prevTargetLower && totalPages > MAX_VISIBLE_DOTS) {
fadeJob?.cancel()
slideOffset.stop()
fadeProgress.stop()
val dir = if (targetLower > prevTargetLower) 1 else -1
val edgeDotSize = with(density) { (HINT_DOT_SIZE.width + SPACING).toPx() }
val halfEdge = edgeDotSize / 2
isSliding = true
slideDirection = dir
fadeProgress.snapTo(0f)
if (dir > 0) {
displayLower = prevTargetLower
displayUpper = targetUpper
slideOffset.snapTo(halfEdge)
} else {
displayLower = targetLower
displayUpper = prevTargetLower + MAX_VISIBLE_DOTS
slideOffset.snapTo(-halfEdge)
}
prevTargetLower = targetLower
fadeJob = launch {
fadeProgress.animateTo(1f, tween(ANIMATION_DURATION))
}
slideOffset.animateTo(
if (dir > 0) -halfEdge else halfEdge,
tween(ANIMATION_DURATION),
)
displayLower = targetLower
displayUpper = targetUpper
slideOffset.snapTo(0f)
isSliding = false
slideDirection = 0
}
animState.onBoundsChange(this, targetLower, targetUpper)
}
val visibleIndices = (displayLower until displayUpper).toList()
val visibleIndices = (animState.displayLower until animState.displayUpper).toList()
Box(
modifier = modifier
.width(BACKGROUND_SIZE.width)
.height(BACKGROUND_SIZE.height)
.background(
color = overlayColor,
color = colors.overlay,
shape = CircleShape,
)
.clip(CircleShape),
.clip(CircleShape)
.then(boxModifier),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier.offset {
IntOffset(slideOffset.value.roundToInt(), 0)
IntOffset(animState.slideOffset.value.roundToInt(), 0)
},
horizontalArrangement = Arrangement.spacedBy(SPACING),
verticalAlignment = Alignment.CenterVertically,
) {
visibleIndices.forEach { index ->
val dotAlpha = when {
!isSliding -> 1f
slideDirection > 0 && index == displayLower -> 1f - fadeProgress.value
slideDirection > 0 && index == displayUpper - 1 -> fadeProgress.value
slideDirection < 0 && index == displayUpper - 1 -> 1f - fadeProgress.value
slideDirection < 0 && index == displayLower -> fadeProgress.value
else -> 1f
}
val dotAlpha = calculateDotAlpha(
isSliding = animState.isSliding,
slideDirection = animState.slideDirection,
index = index,
displayLower = animState.displayLower,
displayUpper = animState.displayUpper,
fadeProgress = animState.fadeProgress.value,
)
key(index) {
Dot(
index = index,
currentIndex = currentIndex,
currentIndex = pagerState.currentPage,
totalPages = totalPages,
activeColor = indicatorColor,
inactiveColor = inactiveIndicatorColor,
activeColor = colors.active,
inactiveColor = colors.inactive,
modifier = Modifier.graphicsLayer { alpha = dotAlpha },
)
}
@ -156,19 +170,6 @@ fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier) {
}
}
private fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair<Int, Int> {
if (totalPages <= MAX_VISIBLE_DOTS) {
return 0 to totalPages
}
val lowerBound = when {
currentIndex <= 1 -> 0
currentIndex >= totalPages - 2 -> totalPages - MAX_VISIBLE_DOTS
else -> currentIndex - 2
}
val upperBound = min(lowerBound + MAX_VISIBLE_DOTS, totalPages)
return lowerBound to upperBound
}
private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize {
if (index == currentIndex) {
return CURRENT_DOT_SIZE
@ -180,6 +181,46 @@ private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize {
return params.calculateSize()
}
@Composable
private fun Dot(
index: Int,
currentIndex: Int,
totalPages: Int,
activeColor: Color,
inactiveColor: Color,
modifier: Modifier = Modifier,
) {
val isActive = index == currentIndex
val size = getDotSize(index, currentIndex, totalPages)
val animSpec = tween<Dp>(ANIMATION_DURATION)
val colorSpec = tween<Color>(ANIMATION_DURATION)
val animatedWidth by animateDpAsState(size.width, animSpec, label = "w$index")
val animatedHeight by animateDpAsState(size.height, animSpec, label = "h$index")
val animatedColor by animateColorAsState(
targetValue = if (isActive) activeColor else inactiveColor,
animationSpec = colorSpec,
label = "c$index",
)
val shape = RoundedCornerShape(animatedHeight / 2)
Box(
modifier = modifier
.width(animatedWidth)
.height(animatedHeight)
.background(animatedColor, shape),
)
}
@Immutable
private data class PagerIndicatorColors(
val active: Color,
val inactive: Color,
val overlay: Color,
)
private class DotSizeParams private constructor(
val posInWindow: Int,
val currentPosInWindow: Int,
@ -248,43 +289,27 @@ private class DotSizeParams private constructor(
}
}
@Preview(showBackground = true)
@Composable
private fun Dot(
index: Int,
currentIndex: Int,
totalPages: Int,
activeColor: Color,
inactiveColor: Color,
modifier: Modifier = Modifier,
) {
val isActive = index == currentIndex
val size = getDotSize(index, currentIndex, totalPages)
val animSpec = tween<Dp>(ANIMATION_DURATION)
val colorSpec = tween<Color>(ANIMATION_DURATION)
val animatedWidth by animateDpAsState(size.width, animSpec, label = "w$index")
val animatedHeight by animateDpAsState(size.height, animSpec, label = "h$index")
val animatedColor by animateColorAsState(
targetValue = if (isActive) activeColor else inactiveColor,
animationSpec = colorSpec,
label = "c$index",
)
val shape = RoundedCornerShape(animatedHeight / 2)
Box(
modifier = modifier
.width(animatedWidth)
.height(animatedHeight)
.background(animatedColor, shape),
)
private fun PagerIndicatorPreviewV1() {
TangemThemePreview {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4).forEach { page ->
PagerIndicator(rememberPagerState(page) { 5 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorPreview() {
TangemThemePreview {
private fun PagerIndicatorPreviewV2() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)

View file

@ -0,0 +1,95 @@
package com.tangem.core.ui.components.pager
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.*
import androidx.compose.ui.unit.Density
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.min
@Stable
internal class PagerIndicatorAnimationState(
private val totalPages: Int,
initialCurrentPage: Int,
private val density: Density,
) {
var displayLower by mutableIntStateOf(0)
private set
var displayUpper by mutableIntStateOf(0)
private set
val slideOffset = Animatable(0f)
var isSliding by mutableStateOf(false)
private set
var slideDirection by mutableIntStateOf(0)
private set
val fadeProgress = Animatable(0f)
private var fadeJob by mutableStateOf<Job?>(null)
private var prevTargetLower by mutableIntStateOf(0)
init {
val (lower, upper) = getWindowBounds(totalPages, initialCurrentPage)
displayLower = lower
displayUpper = upper
prevTargetLower = lower
}
suspend fun onBoundsChange(scope: CoroutineScope, targetLower: Int, targetUpper: Int) {
if (targetLower == prevTargetLower || totalPages <= MAX_VISIBLE_DOTS) {
return
}
fadeJob?.cancel()
slideOffset.stop()
fadeProgress.stop()
val dir = if (targetLower > prevTargetLower) 1 else -1
val edgeDotSize = with(density) { (HINT_DOT_SIZE.width + SPACING).toPx() }
val halfEdge = edgeDotSize / 2
isSliding = true
slideDirection = dir
fadeProgress.snapTo(0f)
if (dir > 0) {
displayUpper = targetUpper
slideOffset.snapTo(halfEdge)
} else {
displayLower = targetLower
displayUpper = prevTargetLower + MAX_VISIBLE_DOTS
slideOffset.snapTo(-halfEdge)
}
prevTargetLower = targetLower
fadeJob = scope.launch {
fadeProgress.animateTo(1f, tween(ANIMATION_DURATION))
}
slideOffset.animateTo(
if (dir > 0) -halfEdge else halfEdge,
tween(ANIMATION_DURATION),
)
displayLower = targetLower
displayUpper = targetUpper
slideOffset.snapTo(0f)
isSliding = false
slideDirection = 0
}
}
internal fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair<Int, Int> {
if (totalPages <= MAX_VISIBLE_DOTS) {
return 0 to totalPages
}
val lowerBound = when {
currentIndex <= 1 -> 0
currentIndex >= totalPages - 2 -> totalPages - MAX_VISIBLE_DOTS
else -> currentIndex - 2
}
val upperBound = min(lowerBound + MAX_VISIBLE_DOTS, totalPages)
return lowerBound to upperBound
}

View file

@ -80,7 +80,7 @@ internal fun TangemButtonInternal(
visible = iconRes != null && iconPosition == TangemButtonIconPosition.Start,
modifier = Modifier.size(size = size.toContentSize()),
) {
val wrappedIconRes = remember(this) { requireNotNull(iconRes) }
val wrappedIconRes = remember(this, iconRes) { requireNotNull(iconRes) }
TangemButtonIcon(iconRes = wrappedIconRes, state = state, iconColor = contentColor, size = size)
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M13.013,2.167C16.34,2.167 18.666,4.886 18.666,8.23C18.666,10.929 17.218,13.156 15.803,14.715C14.361,16.302 12.716,17.457 11.829,18.033L11.785,18.062L11.74,18.088C11.685,18.12 11.46,18.256 11.252,18.357C11.082,18.441 10.608,18.667 10.019,18.667C9.455,18.667 8.985,18.458 8.816,18.381C8.583,18.275 8.371,18.153 8.259,18.088L8.214,18.062L8.171,18.033C7.284,17.457 5.639,16.303 4.196,14.715C2.781,13.156 1.333,10.929 1.333,8.23C1.333,4.886 3.659,2.167 6.986,2.167C8.182,2.167 9.19,2.546 10,3.118C10.81,2.545 11.819,2.167 13.013,2.167Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -1,12 +1,12 @@
package com.tangem.features.feed.model.converter
import com.tangem.features.feed.ui.feed.components.articles.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.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.ShortArticle
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.utils.mapFormattedDate
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@ -16,7 +16,7 @@ import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
internal class ShortArticleToArticleConfigUMConverter(
private val isTrending: Provider<Boolean>,
private val isTrending: Provider<Boolean>?,
) : Converter<List<ShortArticle>, ImmutableList<ArticleConfigUM>> {
override fun convert(value: List<ShortArticle>): ImmutableList<ArticleConfigUM> {
@ -25,7 +25,7 @@ internal class ShortArticleToArticleConfigUMConverter(
id = shortArticle.id,
title = shortArticle.title,
score = shortArticle.score,
isTrending = isTrending(),
isTrending = isTrending?.invoke() ?: shortArticle.isTrending,
tags = buildArticleTags(shortArticle),
createdAt = mapFormattedDate(shortArticle.createdAt),
isViewed = shortArticle.viewed,

View file

@ -40,6 +40,7 @@ internal class NewsDetailsConverter(
newsUrl = value.newsUrl,
relatedTokens = value.relatedTokens.toImmutableList(),
isLiked = value.isLiked,
isTrending = value.isTrending,
)
}

View file

@ -1,12 +1,12 @@
package com.tangem.features.feed.model.news.list.statemanager
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase
import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter
import com.tangem.features.feed.model.converter.distinctBatchesContent
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.PaginationStatus
@ -30,7 +30,7 @@ internal open class NewsListBatchFlowManager(
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, NewsListConfig, Nothing>>()
private val converter by lazy {
ShortArticleToArticleConfigUMConverter(isTrending = Provider { false })
ShortArticleToArticleConfigUMConverter(null)
}
private val batchFlow = getNewsListBatchFlowUseCase(

View file

@ -10,12 +10,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.HorizontalFadeWithBlur
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ShowMoreArticlesCard
@ -25,72 +21,50 @@ import com.tangem.features.feed.ui.feed.state.NewsSliderConfig
@Composable
internal fun NewsSlider(newsSliderConfig: NewsSliderConfig) {
val background = LocalMainBottomSheetColor.current.value
val isRedesignEnabled = LocalRedesignEnabled.current
Box(
modifier = Modifier.fillMaxWidth(),
LazyRow(
modifier = Modifier.background(color = background),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
) {
LazyRow(
modifier = Modifier
.conditionalCompose(
condition = isRedesignEnabled,
modifier = {
hazeSourceTangem(zIndex = -1f)
},
itemsIndexed(
items = newsSliderConfig.content,
key = { index, _ -> index },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderScroll,
)
.background(color = background),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
) {
itemsIndexed(
items = newsSliderConfig.content,
key = { index, _ -> index },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { newsSliderConfig.callbacks.onArticleClick(article.id) },
modifier = articleModifier
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { newsSliderConfig.callbacks.onArticleClick(article.id) },
modifier = articleModifier
.width(228.dp)
.heightIn(min = 172.dp)
.fillMaxHeight(),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
if (newsSliderConfig.shouldShowSeeAllNewsItem) {
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(228.dp)
.heightIn(min = 172.dp)
.fillMaxHeight(),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderEndReached,
),
onClick = newsSliderConfig.callbacks.onOpenAllNews,
)
}
if (newsSliderConfig.shouldShowSeeAllNewsItem) {
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(228.dp)
.heightIn(min = 172.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderEndReached,
),
onClick = newsSliderConfig.callbacks.onOpenAllNews,
)
}
}
}
if (isRedesignEnabled) {
HorizontalFadeWithBlur(
modifier = Modifier
.align(Alignment.TopEnd)
.heightIn(min = 172.dp)
.fillMaxHeight()
.width(100.dp),
backgroundColor = background,
)
}
}
}

View file

@ -1,23 +1,69 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.ds.badge.TangemBadge
import com.tangem.core.ui.ds.badge.TangemBadgeColor
import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition
import com.tangem.core.ui.ds.badge.TangemBadgeShape
import com.tangem.core.ui.ds.badge.TangemBadgeSize
import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ArticleHeader(
isTrending: Boolean,
title: String,
createdAt: String,
score: Float,
tags: ImmutableList<LabelUM>,
modifier: Modifier = Modifier,
) {
if (LocalRedesignEnabled.current) {
ArticleHeaderV2(
isTrending = isTrending,
title = title,
createdAt = createdAt,
score = score,
tags = tags,
modifier = modifier,
)
} else {
ArticleHeaderV1(
title = title,
createdAt = createdAt,
score = score,
tags = tags,
modifier = modifier,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ArticleHeaderV1(
title: String,
createdAt: String,
score: Float,
@ -52,4 +98,161 @@ fun ArticleHeader(
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ArticleHeaderV2(
isTrending: Boolean,
title: String,
createdAt: String,
score: Float,
tags: ImmutableList<LabelUM>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.heightIn(min = 66.dp)
.padding(top = 16.dp),
verticalAlignment = Alignment.Bottom,
) {
DateBlock(
modifier = Modifier.weight(1f),
createdAt = createdAt,
)
SpacerW(30.dp)
VerticalDivider(
modifier = Modifier
.height(46.dp)
.padding(bottom = 4.dp),
color = TangemTheme.colors2.border.neutral.primary,
)
SpacerW(30.dp)
ScoreBlock(
modifier = Modifier.weight(1f),
score = score,
isTrending = isTrending,
)
}
Text(
modifier = Modifier.padding(vertical = 36.dp),
text = title,
style = TangemTheme.typography2.headingBold34,
color = TangemTheme.colors2.text.neutral.primary,
)
if (tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(20.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tags.forEach { tag ->
TangemBadge(
text = tag.text,
tangemIconUM = when (val content = tag.leadingContent) {
LabelLeadingContentUM.None -> null
is LabelLeadingContentUM.Token -> TangemIconUM.Url(content.iconUrl)
},
shape = TangemBadgeShape.Rounded,
size = TangemBadgeSize.X9,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Gray,
iconPosition = when (tag.leadingContent) {
LabelLeadingContentUM.None -> TangemBadgeIconPosition.None
is LabelLeadingContentUM.Token -> TangemBadgeIconPosition.Start
},
)
}
}
}
}
}
@Composable
private fun ScoreBlock(score: Float, isTrending: Boolean, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16),
tint = if (isTrending) {
TangemTheme.colors2.fill.status.attention
} else {
TangemTheme.colors2.graphic.neutral.primary
},
contentDescription = null,
)
Text(
text = score.toString(),
style = TangemTheme.typography2.bodyRegular16,
color = if (isTrending) {
TangemTheme.colors2.text.status.attention
} else {
TangemTheme.colors2.text.neutral.primary
},
)
}
Text(
text = stringResourceSafe(R.string.news_trending_score),
style = TangemTheme.typography2.captionSemibold13,
color = TangemTheme.colors2.text.neutral.tertiary,
)
}
}
@Composable
private fun DateBlock(createdAt: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_calendar_20),
tint = TangemTheme.colors2.fill.neutral.primary,
contentDescription = null,
)
Text(
text = createdAt,
style = TangemTheme.typography2.captionSemibold13,
color = TangemTheme.colors2.text.neutral.tertiary,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun ArticleHeaderPreviewV1() {
TangemThemePreview {
ArticleHeader(
title = "Something going good!",
createdAt = "1 hour ago",
score = 5.5f,
tags = persistentListOf(),
isTrending = true,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun ArticleHeaderPreviewV2() {
TangemThemePreviewRedesign {
ArticleHeader(
title = "Something going good!",
createdAt = "1 hour ago",
score = 5.5f,
tags = persistentListOf(),
isTrending = true,
)
}
}

View file

@ -3,53 +3,25 @@ package com.tangem.features.feed.ui.news.details
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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 coil.compose.SubcomposeAsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.tangem.features.feed.ui.feed.components.articles.ArticleHeader
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.haze.hazeSourceTangem
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.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.*
import com.tangem.features.feed.ui.news.details.components.ArticleDetail
import com.tangem.features.feed.ui.news.details.components.NewsDetailsPlaceholder
import com.tangem.features.feed.ui.news.details.components.RelatedTokensBlock
import com.tangem.features.feed.ui.news.details.state.*
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
@Composable
internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) {
@ -86,6 +58,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
@Composable
private fun Content(state: NewsDetailsUM, background: Color) {
val isRedesignEnabled = LocalRedesignEnabled.current
val pagerState = rememberPagerState(
initialPage = state.selectedArticleIndex,
pageCount = { state.articles.size },
@ -109,6 +82,12 @@ private fun Content(state: NewsDetailsUM, background: Color) {
Column(
modifier = Modifier
.fillMaxSize()
.conditionalCompose(
condition = isRedesignEnabled,
modifier = {
hazeSourceTangem(zIndex = 1f)
},
)
.background(background),
) {
Box(modifier = Modifier.fillMaxSize()) {
@ -138,242 +117,6 @@ private fun Content(state: NewsDetailsUM, background: Color) {
}
}
@Suppress("LongMethod")
@Composable
private fun ArticleDetail(
article: ArticleUM,
onLikeClick: () -> Unit,
relatedTokensUM: RelatedTokensUM,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
val pagerHeight = 32.dp
val contentPadding = pagerHeight + 56.dp + with(density) {
WindowInsets.navigationBars.getBottom(this).div(this.density)
}.dp
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = contentPadding),
) {
item("content") {
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 = if (article.isLiked) {
R.drawable.ic_heart_filled_20
} else {
R.drawable.ic_heart_20
},
iconTint = Color.Unspecified,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLikeClick()
},
)
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,
onItemClick = when (relatedTokensUM) {
is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick
else -> null
},
modifier = Modifier.padding(horizontal = 16.dp),
)
if (article.relatedArticles.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.relatedArticles.size}",
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
if (article.relatedArticles.isNotEmpty()) {
item("relatedArticles") {
LazyRow(
modifier = Modifier.padding(vertical = 12.dp),
state = rememberLazyListState(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = article.relatedArticles,
key = RelatedArticleUM::id,
) { article ->
RelatedNewsItem(
relatedArticle = article,
modifier = Modifier.fillParentMaxHeight(),
)
}
}
}
}
}
BottomFade(
modifier = Modifier
.align(Alignment.BottomCenter),
backgroundColor = background,
)
}
}
@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 RelatedNewsItem(relatedArticle: RelatedArticleUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.sizeIn(maxWidth = 256.dp, minHeight = 132.dp)
.background(color = TangemTheme.colors.background.action, shape = RoundedCornerShape(12.dp))
.clickable(onClick = relatedArticle.onClick)
.padding(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(modifier = Modifier.weight(1f)) {
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 = relatedArticle.media.name,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors.text.tertiary,
)
}
if (relatedArticle.title.isNotEmpty()) {
SpacerH(4.dp)
Text(
text = relatedArticle.title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
if (relatedArticle.imageUrl != null) {
SubcomposeAsyncImage(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
model = ImageRequest.Builder(context = LocalContext.current)
.data(relatedArticle.imageUrl)
.crossfade(enable = false)
.allowHardware(true)
.memoryCachePolicy(CachePolicy.DISABLED)
.build(),
loading = {
RectangleShimmer(
modifier = Modifier.size(40.dp),
radius = 4.dp,
)
},
error = {},
contentDescription = relatedArticle.media.name,
)
}
}
SpacerHMax()
Text(
text = relatedArticle.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
@ -396,4 +139,28 @@ private fun PreviewNewsDetailsContent() {
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewNewsDetailsContentV2() {
TangemThemePreviewRedesign {
val background = TangemTheme.colors.background.tertiary
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
NewsDetailsContent(
state = NewsDetailsUM(
articlesStateUM = ArticlesStateUM.Content,
articles = MockArticlesFactory.createMockArticles(),
selectedArticleIndex = 0,
onShareClick = {},
onLikeClick = {},
onBackClick = {},
onArticleIndexChanged = {},
),
)
}
}
}

View file

@ -0,0 +1,324 @@
package com.tangem.features.feed.ui.news.details.components
import androidx.compose.foundation.background
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.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.BottomFadeWithBlur
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.components.articles.ArticleHeader
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
@Composable
internal fun ArticleDetail(
article: ArticleUM,
onLikeClick: () -> Unit,
relatedTokensUM: RelatedTokensUM,
modifier: Modifier = Modifier,
) {
if (LocalRedesignEnabled.current) {
ArticleDetailV2(
article = article,
onLikeClick = onLikeClick,
relatedTokensUM = relatedTokensUM,
modifier = modifier,
)
} else {
ArticleDetailV1(
article = article,
onLikeClick = onLikeClick,
relatedTokensUM = relatedTokensUM,
modifier = modifier,
)
}
}
@Suppress("LongMethod")
@Composable
private fun ArticleDetailV1(
article: ArticleUM,
onLikeClick: () -> Unit,
relatedTokensUM: RelatedTokensUM,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
val pagerHeight = 32.dp
val contentPadding = pagerHeight + 56.dp + with(density) {
WindowInsets.navigationBars.getBottom(this).div(this.density)
}.dp
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = contentPadding),
) {
item("content") {
ArticleHeader(
title = article.title,
createdAt = article.createdAt.resolveReference(),
score = article.score,
tags = article.tags,
isTrending = article.isTrending,
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 = if (article.isLiked) {
R.drawable.ic_heart_filled_20
} else {
R.drawable.ic_heart_20
},
iconTint = Color.Unspecified,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLikeClick()
},
)
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,
onItemClick = when (relatedTokensUM) {
is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick
else -> null
},
modifier = Modifier.padding(horizontal = 16.dp),
)
if (article.relatedArticles.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.relatedArticles.size}",
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
if (article.relatedArticles.isNotEmpty()) {
item("relatedArticles") {
LazyRow(
modifier = Modifier.padding(vertical = 12.dp),
state = rememberLazyListState(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = article.relatedArticles,
key = RelatedArticleUM::id,
) { article ->
RelatedNewsItem(
relatedArticle = article,
modifier = Modifier.fillParentMaxHeight(),
)
}
}
}
}
}
BottomFade(
modifier = Modifier
.align(Alignment.BottomCenter),
backgroundColor = background,
)
}
}
@Suppress("LongMethod")
@Composable
internal fun ArticleDetailV2(
article: ArticleUM,
onLikeClick: () -> Unit,
relatedTokensUM: RelatedTokensUM,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
val pagerHeight = 32.dp
val contentPadding = pagerHeight + 56.dp + with(density) {
WindowInsets.navigationBars.getBottom(this).div(this.density)
}.dp
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = 0f)
.background(background),
contentPadding = PaddingValues(bottom = contentPadding),
) {
item("content") {
ArticleHeader(
title = article.title,
createdAt = article.createdAt.resolveReference(),
score = article.score,
tags = article.tags,
isTrending = article.isTrending,
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.typography2.bodyRegular16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier
.padding(top = 12.dp)
.padding(horizontal = 16.dp),
)
SpacerH(24.dp)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 24.dp),
color = TangemTheme.colors2.border.neutral.primary,
)
SpacerH(20.dp)
SecondaryTangemButton(
modifier = Modifier.padding(horizontal = 24.dp),
text = resourceReference(R.string.news_like),
size = com.tangem.core.ui.ds.button.TangemButtonSize.X9,
iconRes = if (article.isLiked) {
R.drawable.ic_like_20
} else {
R.drawable.ic_heart_20
},
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLikeClick()
},
shape = TangemButtonShape.Rounded,
)
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,
onItemClick = when (relatedTokensUM) {
is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick
else -> null
},
modifier = Modifier.padding(horizontal = 16.dp),
)
if (article.relatedArticles.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.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
)
}
}
}
if (article.relatedArticles.isNotEmpty()) {
item("relatedArticles") {
LazyRow(
modifier = Modifier
.padding(vertical = 12.dp),
state = rememberLazyListState(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = article.relatedArticles,
key = RelatedArticleUM::id,
) { article ->
RelatedNewsItem(
relatedArticle = article,
modifier = Modifier.fillParentMaxHeight(),
)
}
}
}
}
}
BottomFadeWithBlur(
modifier = Modifier
.align(Alignment.BottomCenter)
.height(80.dp)
.fillMaxWidth(),
backgroundColor = background,
)
}
}

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
@ -24,6 +25,7 @@ import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
internal fun QuickRecap(content: String, modifier: Modifier = Modifier) {
@ -105,20 +107,36 @@ private fun QuickRecapV2(content: String, modifier: Modifier = Modifier) {
Box {
VerticalDivider(
modifier = Modifier.fillMaxHeight(),
modifier = Modifier
.fillMaxHeight()
.padding(start = 10.dp),
thickness = 2.dp,
color = Color(QUICK_RECAP_DIVIDER_COLOR),
)
Text(
modifier = Modifier.padding(start = 16.dp),
modifier = Modifier.padding(start = 20.dp),
text = content,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodyRegular16,
color = TangemTheme.colors2.text.neutral.primary,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun QuickRecapPreview() {
TangemThemePreviewRedesign {
QuickRecapV2(
content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut" +
" labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris " +
"nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit " +
"esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt " +
"in culpa qui officia deserunt mollit anim id est laborum.",
)
}
}
private const val QUICK_RECAP_DIVIDER_COLOR = 0xFFA99FFF
private const val LINEAR_GRADIENT_FIRST_PART = 0xFFA3A0FF
private const val LINEAR_GRADIENT_SECOND_PART = 0xFFF79DFF

View file

@ -0,0 +1,188 @@
package com.tangem.features.feed.ui.news.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM
@Composable
internal fun RelatedNewsItem(relatedArticle: RelatedArticleUM, modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
RelatedNewsItemV2(relatedArticle, modifier)
} else {
RelatedNewsItemV1(relatedArticle, modifier)
}
}
@Composable
private fun RelatedNewsItemV1(relatedArticle: RelatedArticleUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.sizeIn(maxWidth = 256.dp, minHeight = 132.dp)
.background(color = TangemTheme.colors.background.action, shape = RoundedCornerShape(12.dp))
.clickable(onClick = relatedArticle.onClick)
.padding(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(modifier = Modifier.weight(1f)) {
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 = relatedArticle.media.name,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors.text.tertiary,
)
}
if (relatedArticle.title.isNotEmpty()) {
SpacerH(4.dp)
Text(
text = relatedArticle.title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
if (relatedArticle.imageUrl != null) {
SubcomposeAsyncImage(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
model = ImageRequest.Builder(context = LocalContext.current)
.data(relatedArticle.imageUrl)
.crossfade(enable = false)
.allowHardware(true)
.memoryCachePolicy(CachePolicy.DISABLED)
.build(),
loading = {
RectangleShimmer(
modifier = Modifier.size(40.dp),
radius = 4.dp,
)
},
error = {},
contentDescription = relatedArticle.media.name,
)
}
}
SpacerHMax()
Text(
text = relatedArticle.publishedAt.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Suppress("LongMethod")
@Composable
private fun RelatedNewsItemV2(relatedArticle: RelatedArticleUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.sizeIn(maxWidth = 228.dp, minHeight = 160.dp)
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
)
.clickable(onClick = relatedArticle.onClick)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.primary,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
)
.padding(16.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp)) {
Icon(
painter = painterResource(id = R.drawable.ic_explore_16),
contentDescription = null,
tint = TangemTheme.colors2.markers.iconGray,
modifier = Modifier.size(16.dp),
)
SpacerW(2.dp)
Text(
text = relatedArticle.media.name,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
if (relatedArticle.title.isNotEmpty()) {
SpacerH(8.dp)
Text(
text = relatedArticle.title,
style = TangemTheme.typography2.bodyRegular16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
if (relatedArticle.imageUrl != null) {
SubcomposeAsyncImage(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop,
model = ImageRequest.Builder(context = LocalContext.current)
.data(relatedArticle.imageUrl)
.crossfade(enable = false)
.allowHardware(true)
.memoryCachePolicy(CachePolicy.DISABLED)
.build(),
loading = {
RectangleShimmer(
modifier = Modifier.size(44.dp),
radius = 12.dp,
)
},
error = {},
contentDescription = relatedArticle.media.name,
)
}
}
SpacerHMax()
Text(
text = relatedArticle.publishedAt.resolveReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.feed.ui.news.details.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@ -16,6 +17,7 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.model.news.details.NewsDetailsModel.Companion.RELATED_TOKEN_MAX_COUNT
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
@ -38,11 +40,20 @@ internal fun RelatedTokensBlock(
Column(modifier = modifier) {
SpacerH(40.dp)
Text(
text = stringResourceSafe(R.string.news_related_tokens),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
if (LocalRedesignEnabled.current) {
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = stringResourceSafe(R.string.news_related_tokens),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
)
} else {
Text(
text = stringResourceSafe(R.string.news_related_tokens),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
SpacerH(12.dp)
BlockCard(

View file

@ -11,6 +11,7 @@ import kotlinx.collections.immutable.toPersistentList
internal object MockArticlesFactory {
fun createMockArticles(): ImmutableList<ArticleUM> = listOf(
ArticleUM(
isTrending = false,
id = 1,
title = "SEC delays decisions on ETH-staking ETFs and spot XRP/SOL funds",
createdAt = TextReference.Str("20 Jun, 21:45"),
@ -78,6 +79,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 3,
@ -107,6 +109,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 4,
@ -136,6 +139,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 5,
@ -151,6 +155,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 6,
@ -167,6 +172,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 7,
@ -182,6 +188,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 8,
@ -197,6 +204,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 9,
@ -213,6 +221,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
ArticleUM(
id = 10,
@ -229,6 +238,7 @@ internal object MockArticlesFactory {
newsUrl = "",
relatedTokens = persistentListOf(),
isLiked = false,
isTrending = false,
),
).toPersistentList()
}

View file

@ -25,6 +25,7 @@ internal sealed interface ArticlesStateUM {
data class LoadingError(val onRetryClicked: () -> Unit) : ArticlesStateUM
}
@Immutable
internal data class ArticleUM(
val id: Int,
val title: String,
@ -37,6 +38,7 @@ internal data class ArticleUM(
val newsUrl: String,
val relatedTokens: ImmutableList<RelatedToken>,
val isLiked: Boolean,
val isTrending: Boolean,
)
internal data class RelatedArticleUM(

View file

@ -1,4 +1,5 @@
@file:Suppress("MagicNumber", "LongMethod")
package com.tangem.feature.tester.presentation.storybook.page.badge
import androidx.compose.foundation.background
@ -186,7 +187,7 @@ private fun BadgeTypeRow(
) {
TangemBadge(
text = stringReference("New"),
tangemIconUM = TangemIconUM.Icon(R.drawable.ic_information_24),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_information_24),
size = size,
shape = shape,
color = color,