Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 14:50:24 +03:00
commit 50fe9e603d
1919 changed files with 94940 additions and 15178 deletions

1
features/for-you/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
id("kotlin-parcelize")
id("configuration")
}
android {
namespace = "com.tangem.features.foryou.api"
}
dependencies {
/** Project - Core */
api(projects.core.decompose)
api(projects.core.ui)
/** Project - Domain */
api(projects.domain.models)
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.foryou
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
interface ForYouComponent : ComposableModularBottomSheetContentComponent {
data class Params(
val callbacks: ForYouModelCallbacks,
)
interface ForYouModelCallbacks {
fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency)
}
interface Factory : ComponentFactory<Params, ForYouComponent>
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.foryou
interface ForYouFeatureToggles {
val isForYouEnabled: Boolean
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.foryou
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
interface TokenSummaryComponent : ComposableModularBottomSheetContentComponent {
data class Params(
val userWalletId: UserWalletId,
val token: Token,
val selectedTokenPeriodId: String? = null,
val callbacks: TokenSummaryModelCallbacks,
)
interface TokenSummaryModelCallbacks {
fun onDismiss()
}
/**
* The token the summary is opened for. Has two shapes depending on the entry point:
* - [Portfolio] opened from a portfolio screen, where the full [CryptoCurrency] is available;
* - [Market] opened from a market-review screen, where there is no [CryptoCurrency] yet, only the
* raw id and display data.
*/
@Serializable
sealed interface Token {
@Serializable
data class Portfolio(val cryptoCurrency: CryptoCurrency) : Token
@Serializable
data class Market(
val cryptoCurrencyRawId: CryptoCurrency.RawID,
val title: String,
val tangemIconUrl: String,
) : Token
}
interface Factory : ComponentFactory<Params, TokenSummaryComponent>
}

1
features/for-you/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,73 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
android {
namespace = "com.tangem.features.foryou.impl"
packaging {
resources {
merges += "paymentrequest.proto"
}
}
}
dependencies {
/** Project - Features */
api(projects.features.forYou.api)
api(projects.features.promoBanners.api)
implementation(projects.features.commonFeatures.api)
/** Project - Core */
api(projects.core.configToggles)
api(projects.core.decompose)
api(projects.core.utils)
implementation(projects.core.ui)
/** Project - Common */
api(projects.common.ui)
implementation(projects.common.routing)
/** Project - Domain */
api(projects.domain.account.status)
api(projects.domain.appCurrency)
api(projects.domain.common)
api(projects.domain.wallets)
implementation(projects.domain.account)
implementation(projects.domain.models)
/** Project - Domain models */
implementation(projects.domain.appCurrency.models)
/** Compose */
api(deps.compose.animation)
api(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.reorderable)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
/** Other libraries */
implementation(deps.androidx.appCompat)
implementation(deps.arrow.core)
implementation(deps.decompose.ext.compose)
implementation(deps.decompose)
implementation(deps.haze)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.immutable.collections)
implementation(deps.lifecycle.compose)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,99 @@
package com.tangem.features.foryou.impl
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.model.ForYouModel
import com.tangem.features.foryou.impl.ui.ForYouContent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultForYouComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: ForYouComponent.Params,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
) : AppComponentContext by context, ForYouComponent {
private val model: ForYouModel = getOrCreateModel(params = params)
private val promoBannersBlockComponent: PromoBannersBlockComponent by lazy {
promoBannersBlockComponentFactory.create(
context = child("promoBannersBlockComponent"),
params = PromoBannersBlockComponent.Params(
placeholder = PromoBannersBlockComponent.Placeholder.FEED,
isInitiallyVisibleOnScreen = false,
),
)
}
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
TangemTopBar(
title = resourceReference(R.string.for_you_title),
type = TangemTopBarType.BottomSheet,
startContent = {
Icon(
imageVector = Icons.ic_chevron_left_20,
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.hazeEffectTangem { blurRadius = 8.dp }
.clickableSingle(
onClick = router::pop,
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(8.dp),
)
},
)
}
@Composable
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val uiState by model.uiState.collectAsStateWithLifecycle()
ForYouContent(
forYouUM = uiState,
bottomSheetState = bottomSheetState,
promoBannersBlockComponent = promoBannersBlockComponent,
contentPadding = contentPadding,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : ForYouComponent.Factory {
override fun create(context: AppComponentContext, params: ForYouComponent.Params): DefaultForYouComponent
}
}

View file

@ -0,0 +1,450 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import android.graphics.BlurMaskFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.min
/**
* Ring (donut) chart drawn behind a center [content] slot.
*
* The ring is painted via [Modifier.drawBehind] so the center label stays a normal composable
* ([content]) laid out on top no manual text measuring inside the canvas.
*
* Paint order (bottom top), all inside the canvas:
* 1. The full-circle [trackColor] track (always drawn it is the empty-state look when [segments] is empty),
* with its own inner shadow.
* 2. Each slice (reverse list order, so slice 0 ends up on top its round cap tucks over the next one):
* the solid stroke, then **its own** white inner shadow (Figma: X0 Y4 Blur8, white 24%) drawn right on
* top of it. Per-slice (not one shadow over the whole ring) is what gives each pill its glossy, raised
* look and the highlight at the colour seams. No colored glow/halo is used.
*
* Empty state: pass an empty [segments] list only the track + its inner shadow render, and [content] can
* show the "No data" label.
*
* Selection: when [selectedIndex] is non-null, everything except the chosen slice is dimmed with a
* theme-adaptive overlay ([TangemTheme.colors3.border.inverse.tertiary]) both the other slices and the
* track (unfilled remainder) so only the selected slice stays at full strength. Only one slice can be
* selected at a time selection is hoisted (the index is the slice's identity, as there are no segment ids
* yet). Taps are hit-tested against the ring band only and reported via [onSegmentClick]; the chart is
* interactive only when [onSegmentClick] is set **and** [segments] is non-empty.
*
* @param segments Slices, in priority order (index 0 is painted on top). See [DonutSegmentUM.weight].
* @param modifier Modifier; should carry the overall size (e.g. `Modifier.size(240.dp)`).
* @param selectedIndex Index of the currently selected slice, or `null` for no selection (nothing dimmed).
* @param onSegmentClick Invoked on every tap inside the chart: with the tapped slice index, or with `null`
* when the tap missed all slices (the center hole or the unfilled track). Passing `null` for the whole
* callback makes the chart non-interactive. Toggling/switching/clearing the selection is the caller's
* responsibility e.g. map a repeat tap or a miss to deselection, and a tap on another slice to a switch.
* @param strokeWidth Thickness of the ring.
* @param trackColor Fill of the unfilled remainder of the circle (and the empty-state ring).
* @param startAngle Angle (degrees) where the first slice starts. `-90f` = 12 o'clock.
* @param content Centered content (e.g. total value + caption, or the "No data" label).
*/
@Suppress("MagicNumber", "LongParameterList", "LongMethod", "NamedArguments")
@Composable
internal fun DonutChart(
segments: ImmutableList<DonutSegmentUM>,
modifier: Modifier = Modifier,
selectedIndex: Int? = null,
onSegmentClick: ((index: Int?) -> Unit)? = null,
strokeWidth: Dp = 28.dp,
trackColor: Color = TangemTheme.colors3.border.tertiary,
startAngle: Float = -90f,
content: @Composable ColumnScope.() -> Unit,
) {
val strokePx = with(LocalDensity.current) { strokeWidth.toPx() }
val dimOverlayColor = TangemTheme.colors3.border.inverse.tertiary
// Resolve each slice's themed colour once, here in composition (the palette reads TangemTheme, which
// isn't available inside the drawBehind DrawScope). Order is preserved 1:1 with [segments] so slice i
// keeps the colour its producer assigned by rank — the draw pass below indexes by i, not by paint order.
val segmentColors = segments.map { it.color.getColor() }
// Fade the dim in/out in step with the segment tooltip's pop-in (same spring as DonutSegmentTooltip).
val dimProgress by animateFloatAsState(
targetValue = if (selectedIndex != null) 1f else 0f,
animationSpec = spring(dampingRatio = DIM_SPRING_DAMPING, stiffness = DIM_SPRING_STIFFNESS),
label = "donutDim",
)
var highlightedIndex by remember { mutableStateOf<Int?>(null) }
if (selectedIndex != null) highlightedIndex = selectedIndex
val latestSelectedIndex by rememberUpdatedState(selectedIndex)
val latestOnSegmentClick by rememberUpdatedState(onSegmentClick)
val clickModifier = if (onSegmentClick != null && segments.isNotEmpty()) {
Modifier.pointerInput(segments, startAngle, strokePx) {
detectTapGestures(
onPress = { tap ->
val clickedIndex = segmentIndexAt(tap, size.toSize(), strokePx, segments, startAngle)
if (latestSelectedIndex != clickedIndex) latestOnSegmentClick?.invoke(clickedIndex)
},
)
}
} else {
Modifier
}
Box(
modifier = modifier
.then(clickModifier)
.drawBehind {
val arc = arcRect(strokePx)
// Inner shadow params from Figma: X0 Y4 Blur8 Spread0, white 24%.
val innerDx = 0f
val innerDy = 4.dp.toPx()
val innerBlur = 8.dp.toPx()
// 1. Track — full circle behind everything, plus its inner shadow.
drawArc(
color = trackColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
drawInnerShadowArc(arc, 0f, 360f, strokePx, InnerShadowColor, innerBlur, innerDx, innerDy)
// Dim intensity animates 0f..1f; scale the overlay's own alpha by it so the dim fades.
val dim = dimProgress.coerceIn(0f, 1f)
val dimColor = dimOverlayColor.copy(alpha = dimOverlayColor.alpha * dim)
// Once a selection exists, dim the whole track too, so the unfilled remainder fades
// along with the non-selected slices instead of staying bright. Drawn before the
// slices, so each slice (selected included) paints on top at full strength.
if (dim > 0f) {
drawArc(
color = dimColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
}
// Precompute each slice's [start, sweep] once. Sweeps are the *visual* angles: every
// non-zero slice is floored to a minimum share (see [visualSweepAngles]) so tiny holdings
// stay visible; larger slices shrink proportionally to make room. On a full ring the last
// slice's floor is bumped by the exact width its two lapped-over caps eat (see below).
val sweeps = visualSweepAngles(
weights = segments.map { it.weight.toFloat() },
capDeg = lastSegmentOverlapDeg(strokePx, arc.size.width),
)
val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep }
// 2. Slices — reversed so slice 0 sits on top of its neighbor. Each slice gets its own
// inner shadow right after its fill, so the glossy highlight follows every pill (and
// every colour seam), not just the ring's outer/inner contour.
for (i in segments.indices.reversed()) {
if (sweeps[i] <= 0f) continue
drawArc(
color = segmentColors[i],
startAngle = starts[i],
sweepAngle = sweeps[i],
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
drawInnerShadowArc(
arc = arc,
startAngle = starts[i],
sweepAngle = sweeps[i],
strokePx = strokePx,
shadowColor = InnerShadowColor,
blurPx = innerBlur,
dx = innerDx,
dy = innerDy,
)
// Dim every non-highlighted slice while a selection is active — the chosen one stays
// bright. Uses the animated [dim] so it fades, and [highlightedIndex] (not selectedIndex)
// so the right slice stays bright through the fade-out after deselection.
if (dim > 0f && i != highlightedIndex) {
drawArc(
color = dimColor,
startAngle = starts[i],
sweepAngle = sweeps[i],
useCenter = false,
topLeft = arc.topLeft,
size = arc.size,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
)
}
}
},
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 36.dp),
) {
content()
}
}
}
/**
* Maps a tap [tap] to the index of the slice under it, or `null` if the tap is outside the ring band or
* lands on a gap/track. Uses the same geometry as the drawing pass: the ring is centered, its outer radius
* is half the min side and its inner radius is `outer - strokePx`. The angular test reuses the
* `runningFold` start/sweep layout. A small radial tolerance makes the thin ring comfortable to hit.
*/
@Suppress("MagicNumber", "ReturnCount")
private fun segmentIndexAt(
tap: Offset,
size: Size,
strokePx: Float,
segments: List<DonutSegmentUM>,
startAngle: Float,
): Int? {
val cx = size.width / 2f
val cy = size.height / 2f
val dx = tap.x - cx
val dy = tap.y - cy
val outer = min(size.width, size.height) / 2f
val inner = outer - strokePx
val tolerance = strokePx * 0.4f
val dist = hypot(dx, dy)
if (dist < inner - tolerance || dist > outer + tolerance) return null
// Degrees clockwise from 3 o'clock — same convention as Canvas.drawArc.
val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat().mod(360f)
// Same cap compensation as the draw pass — centerline diameter is `min(size) - strokePx` (see
// [arcRect]) — so hit-testing matches the drawn geometry exactly.
val arcDiameter = min(size.width, size.height) - strokePx
val sweeps = visualSweepAngles(
weights = segments.map { it.weight.toFloat() },
capDeg = lastSegmentOverlapDeg(strokePx, arcDiameter),
)
val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep }
for (i in segments.indices) {
if (sweeps[i] <= 0f) continue
val relative = (angle - starts[i].mod(360f)).mod(360f)
if (relative <= sweeps[i]) return i
}
return null
}
/**
* Exact extra sweep (degrees) the last slice needs on a full ring to read the same visible width as a
* middle slice (see [visualSweepAngles]).
*
* A round cap bulges past its arc's angular end by one cap radius (`strokePx / 2`), i.e.
* `capAngle = toDegrees((strokePx / 2) / R)` with `R = arcDiameter / 2` `toDegrees(strokePx / arcDiameter)`.
* A middle slice loses one such bulge at its start (covered by the previous slice's end cap) but keeps its
* own end cap, so its visible width equals its sweep. The last slice additionally has its end covered by
* slice 0's start cap at the wrap a second cap's worth so it needs `2 × capAngle` back.
*/
private fun lastSegmentOverlapDeg(strokePx: Float, arcDiameter: Float): Float =
2f * Math.toDegrees((strokePx / arcDiameter).toDouble()).toFloat()
/** Square arc bounds, centered in this [DrawScope], inset by half the stroke so the ring fits inside. */
private fun DrawScope.arcRect(strokePx: Float): ArcRect {
val diameter = min(size.width, size.height)
val side = diameter - strokePx
val left = (size.width - diameter) / 2f + strokePx / 2f
val top = (size.height - diameter) / 2f + strokePx / 2f
return ArcRect(topLeft = Offset(left, top), size = Size(side, side))
}
private data class ArcRect(val topLeft: Offset, val size: Size)
/**
* Draws an inset (inner) shadow confined to a single stroked arc the canvas equivalent of CSS
* `box-shadow: inset`. Works on every API level (uses [BlurMaskFilter], not RenderEffect).
*
* Technique: in an isolated layer, paint the arc silhouette in [shadowColor], then "punch out" the same
* arc offset by ([dx], [dy]) and blurred via [PorterDuff.Mode.DST_OUT]. What survives is a blurred band
* of [shadowColor] hugging the edge opposite the offset i.e. the inner shadow.
*/
@Suppress("LongParameterList")
private fun DrawScope.drawInnerShadowArc(
arc: ArcRect,
startAngle: Float,
sweepAngle: Float,
strokePx: Float,
shadowColor: Color,
blurPx: Float,
dx: Float,
dy: Float,
) {
if (sweepAngle <= 0f || blurPx <= 0f) return
drawIntoCanvas { canvas ->
val native = canvas.nativeCanvas
val l = arc.topLeft.x
val t = arc.topLeft.y
val r = l + arc.size.width
val b = t + arc.size.height
val layer = native.saveLayer(null, null)
// 1. The slice silhouette in the shadow color.
val basePaint = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
strokeWidth = strokePx
strokeCap = android.graphics.Paint.Cap.ROUND
this.color = shadowColor.toArgb()
}
native.drawArc(l, t, r, b, startAngle, sweepAngle, false, basePaint)
// 2. Punch out an offset, blurred copy — leaves the shadow only along the inner edge.
val cutPaint = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
strokeWidth = strokePx
strokeCap = android.graphics.Paint.Cap.ROUND
this.color = android.graphics.Color.BLACK
xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
maskFilter = BlurMaskFilter(blurPx, BlurMaskFilter.Blur.NORMAL)
}
native.drawArc(l + dx, t + dy, r + dx, b + dy, startAngle, sweepAngle, false, cutPaint)
native.restoreToCount(layer)
}
}
/** Inner shadow — Figma #FFFFFF at 24% opacity (theme-independent). */
private val InnerShadowColor = Color.White.copy(alpha = 0.24f)
// Selection-dim spring, mirroring DonutSegmentTooltip's pop-in so the dim and the tooltip move together.
private const val DIM_SPRING_DAMPING = 0.82f
private const val DIM_SPRING_STIFFNESS = 1100f
// region Previews
@Suppress("MagicNumber")
@Preview(name = "DonutChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "DonutChart • Light", showBackground = true)
@Composable
private fun PreviewDonutChart() {
TangemThemePreviewRedesign {
// Tap a slice to select it (others dim); tap it again to clear. Caller owns the selection.
var selectedIndex by remember { mutableStateOf<Int?>(null) }
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier.size(260.dp),
selectedIndex = selectedIndex,
onSegmentClick = { index -> selectedIndex = index.takeIf { it != selectedIndex } },
segments = persistentListOf(
DonutSegmentUM(
weight = BigDecimal(0.55),
color = DonutSegmentColor.Brand,
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
),
DonutSegmentUM(
weight = BigDecimal(0.07),
color = DonutSegmentColor.Violet,
title = stringReference("Solana"),
fiatValue = stringReference("$728.30"),
),
DonutSegmentUM(
weight = BigDecimal(0.06),
color = DonutSegmentColor.Red,
title = stringReference("Polkadot"),
fiatValue = stringReference("$624.26"),
),
DonutSegmentUM(
weight = BigDecimal(0.05),
color = DonutSegmentColor.Green,
title = stringReference("Tether"),
fiatValue = stringReference("$520.18"),
),
),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$10,000.1333",
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
)
Text(
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
)
}
}
}
}
}
@Preview(name = "DonutChart Empty • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "DonutChart Empty • Light", showBackground = true)
@Composable
private fun PreviewDonutChartEmpty() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier.size(260.dp),
segments = persistentListOf(),
) {
Text(
text = stringResourceSafe(R.string.market_chart_bubble_no_data),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
)
}
}
}
}
// endregion

View file

@ -0,0 +1,86 @@
package com.tangem.features.foryou.impl.components
/** 7% of the full circle — the minimum visual share any non-zero segment is drawn at. */
internal const val MIN_VISUAL_SWEEP_FRACTION = 0.07f
private const val FULL_CIRCLE_DEG = 360f
/** Share of the round cap width the last segment is compensated for at its lapped-over seams. */
private const val LAST_SEGMENT_CAP_COMP_FACTOR = 0.75f
/**
* Maps normalized segment [weights] (each expected in `0f..1f`) to sweep angles in degrees, guaranteeing
* that every non-zero segment is drawn at least [minFraction] of the full circle (default 5% 18°), so a
* tiny holding never collapses into an invisible sliver.
*
* This is a purely **visual** transform: the returned angles drive the arc drawing, hit-testing, and the
* tooltip anchor. The real share shown in the tooltip must still come from the original `weight`.
*
* Rules:
* - Zero-weight segments always map to `0f` (the drawing / hit-test passes skip them).
* - Space for the bumped-up small segments is taken **proportionally** from the segments that are above the
* floor, so their relative proportions are preserved.
* - The total filled sweep (and therefore the unfilled track remainder) is kept unchanged whenever the
* floors fit inside it; it only grows into the track if the floors genuinely demand more room.
* - If there are so many segments that even the floor can't fit (`n * floor > 360°`), it falls back to an
* equal `360°/n` split.
* - [capDeg] compensates the round-cap squeeze on the **last segment only** (see [DonutChart] for the
* angle). The bump is `max(0, capDeg gap)`: full on a complete ring (where the last slice is lapped
* over at both seams), tapering as the unfilled track gap grows and reaching zero once the gap capDeg
* past that the last slice has a free end and is no worse off than a middle slice. Other slices lap over
* on one side and lose nothing net, so they're never bumped.
*
* The returned list has the same size and order as [weights].
*/
internal fun visualSweepAngles(
weights: List<Float>,
minFraction: Float = MIN_VISUAL_SWEEP_FRACTION,
capDeg: Float = 0f,
): List<Float> {
val base = weights.map { it.coerceIn(0f, 1f) * FULL_CIRCLE_DEG }
val activeIndices = base.indices.filter { base[it] > 0f }
val n = activeIndices.size
if (n == 0) return List(weights.size) { 0f }
val filledSum = activeIndices.sumOf { base[it].toDouble() }.toFloat()
// Never demand more than an equal share when the ring can't fit every floor.
val baseFloor = (minFraction * FULL_CIRCLE_DEG).coerceAtMost(FULL_CIRCLE_DEG / n)
// Compensation for the LAST segment only. On a full ring it's the one slice lapped-over by a round cap
// at both seams (its start by the previous slice's end cap, its end by slice 0's start cap), so it
// loses ~[capDeg] more visible width than the others. As a track gap opens, slice 0's start cap reaches
// its end less, so that extra loss shrinks linearly with the gap and hits zero once the gap ≥ capDeg —
// then the last slice is no worse off than a middle one, so no bump.
val gap = FULL_CIRCLE_DEG - filledSum
val comp = (capDeg * LAST_SEGMENT_CAP_COMP_FACTOR - gap).coerceAtLeast(0f)
val lastActive = activeIndices.last()
val floorOf = { index: Int ->
if (index == lastActive) (baseFloor + comp).coerceAtMost(FULL_CIRCLE_DEG / n) else baseFloor
}
// Preserve the filled sweep when the floors fit; otherwise grow just enough to satisfy them.
val floorsSum = activeIndices.sumOf { floorOf(it).toDouble() }.toFloat()
val budget = maxOf(filledSum, floorsSum).coerceAtMost(FULL_CIRCLE_DEG)
val result = MutableList(weights.size) { 0f }
val pinned = HashSet<Int>()
// Water-filling: repeatedly pin below-floor segments to their floor and re-split the rest
// proportionally, until no free segment falls below its floor. Converges in ≤ n iterations.
while (true) {
val freeIndices = activeIndices.filter { it !in pinned }
if (freeIndices.isEmpty()) {
pinned.forEach { result[it] = floorOf(it) }
break
}
val freeBudget = budget - pinned.sumOf { floorOf(it).toDouble() }.toFloat()
val freeBaseSum = freeIndices.sumOf { base[it].toDouble() }.toFloat()
freeIndices.forEach { result[it] = freeBudget * base[it] / freeBaseSum }
val newlyBelow = freeIndices.filter { result[it] < floorOf(it) }
if (newlyBelow.isEmpty()) {
pinned.forEach { result[it] = floorOf(it) }
break
}
pinned.addAll(newlyBelow)
}
return result
}

View file

@ -0,0 +1,199 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/**
* Frosted-glass selection tooltip for a [DonutChart] slice.
*
* Modeled on [com.tangem.core.ui.ds.contextmenu.TangemContextMenu]: it shows a [Popup] with the same
* springy pop-in animation, but renders the content on a translucent [TangemSurface] (`isMaterial = true`)
* for the glass-morphism look instead of the context menu's opaque card. The pill is centered over the
* chart's anchor (see [CenteredOverAnchorPositionProvider]).
*
* The popup is intentionally **non-focusable**, so its window is not modal: taps outside the pill pass
* straight through to the [DonutChart] underneath, letting the user switch to another slice in a single
* tap. `dismissOnClickOutside` stays on, so a tap anywhere off the pill including outside the chart card,
* elsewhere on the host screen fires [onDismissRequest].
*
* Caveat the caller must handle: that outside-tap detection fires on the **DOWN**, before the chart's tap
* resolves on the UP, and it also fires for taps that land on the chart. So [onDismissRequest] alone can't
* tell "tapped the chart" from "tapped elsewhere" the caller must veto/defer it when the press actually
* landed on the chart (see `MarketChart`), otherwise selecting a slice would briefly clear the selection
* first and flicker the popup.
*
* @param expanded Whether the tooltip is shown. Toggling to `false` plays the scale-out before the popup
* leaves the composition.
* @param title Asset name shown on the first line (e.g. `"Ethereum"`).
* @param fiatValue Pre-formatted fiat value shown on the second line (e.g. `"$5,720.22"`).
* @param percent Pre-formatted share shown after the value, dimmed (e.g. `"57.5%"`).
* @param onDismissRequest Fired on a tap anywhere off the pill (DOWN). Clear the selection here, but see the
* caveat above: defer it so a press on the chart can veto it before it commits.
* @param modifier Modifier applied to the pill surface.
* @param positionProvider Where the pill is placed. Defaults to centered over the anchor; pass a
* [SegmentTooltipPositionProvider] to anchor it to the end of the selected slice.
*/
@Suppress("LongParameterList")
@Composable
internal fun DonutSegmentTooltip(
expanded: Boolean,
title: TextReference,
fiatValue: TextReference,
percent: String,
positionProvider: PopupPositionProvider,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
if (expandedStates.currentState || expandedStates.targetState) {
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(focusable = false, dismissOnClickOutside = true),
) {
TooltipPill(
expandedStates = expandedStates,
title = title,
fiatValue = fiatValue,
percent = percent,
modifier = modifier,
)
}
}
}
private const val OUT_TRANSITION_DURATION = 75
private const val ENTER_SPRING_DAMPING = 0.82f
private const val ENTER_SPRING_STIFFNESS = 1100f
private const val DISMISSED_SCALE = 0.8f
@Suppress("MagicNumber")
@Composable
private fun TooltipPill(
expandedStates: MutableTransitionState<Boolean>,
title: TextReference,
fiatValue: TextReference,
percent: String,
modifier: Modifier = Modifier,
) {
val transition = rememberTransition(expandedStates, label = "DonutSegmentTooltip")
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
spring(dampingRatio = ENTER_SPRING_DAMPING, stiffness = ENTER_SPRING_STIFFNESS)
} else {
tween(durationMillis = 1, delayMillis = OUT_TRANSITION_DURATION - 1)
}
},
label = "scale",
) { isExpanded -> if (isExpanded) 1f else DISMISSED_SCALE }
val alpha by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
tween(durationMillis = 30)
} else {
tween(durationMillis = OUT_TRANSITION_DURATION)
}
},
label = "alpha",
) { isExpanded -> if (isExpanded) 1f else 0f }
TangemSurface(
modifier = modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = TransformOrigin.Center
},
isMaterial = true,
shape = RoundedCornerShape(percent = 50),
shadowRadius = 10.dp,
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = fiatValue.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
)
Text(
text = "$percent",
color = TangemTheme.colors3.text.tertiary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
)
}
}
}
}
// region Preview
@Suppress("MagicNumber")
@Preview(name = "DonutSegmentTooltip • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "DonutSegmentTooltip • Light", showBackground = true)
@Composable
private fun PreviewDonutSegmentTooltip() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.secondary)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
TooltipPill(
expandedStates = remember { MutableTransitionState(true) },
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
percent = "57.5%",
)
}
}
}
// endregion

View file

@ -0,0 +1,219 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import android.graphics.BlurMaskFilter
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/**
* One blurred color blob painted inside [CanvasGradientDivider].
*
* @param color Blob color.
* @param offset Blob center, relative to the line's top-center.
* @param height Blob height (its vertical diameter); the width is fixed by [DotWidth].
* @param blur Mask-blur radius applied via [BlurMaskFilter].
*/
internal data class CanvasGlowDot(
val color: Color,
val offset: DpOffset,
val height: Dp,
val blur: Dp = 8.dp,
)
/**
* Canvas-based glow divider: the line background and every blurred color blob are painted by hand inside a
* single `Box(Modifier.drawBehind { })`, with no child composables. Inside [Modifier.drawBehind] we clip
* to the capsule path, fill the base [LineColor], then draw each [CanvasGlowDot] as an oval whose native
* [Paint] carries a [BlurMaskFilter] the canvas equivalent of a Gaussian layer blur. The clip means a blob
* drawn past the line's bounds only paints its color onto the visible capsule.
*
* @param modifier Modifier for positioning; the capsule height comes from the parent, so the glow scales
* with the divider's length.
* @param lineWidth Width of the capsule.
*/
@Suppress("MagicNumber", "LongParameterList", "LongMethod")
@Composable
internal fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
val shape = RoundedCornerShape(size = 100.dp)
val infiniteTransition = rememberInfiniteTransition(label = "GlowDividerShadow")
val shadowAlpha by infiniteTransition.animateFloat(
initialValue = GLOW_MIN_ALPHA,
targetValue = GLOW_MAX_ALPHA,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "GlowDividerShadowAlpha",
)
val accent = TangemTheme.colors3.icon.accent
val dotColors = GlowDotColors(
violet = accent.violet,
green = accent.green,
blue = accent.blue,
orange = accent.orange,
)
Box(
modifier = modifier
.width(lineWidth)
.drawBehind {
val resolvedDots = canvasDefaultDots(size.height.toDp(), dotColors)
val cornerPx = size.width / 2f
val capsule = Path().apply {
addRoundRect(
RoundRect(
left = 0f,
top = 0f,
right = size.width,
bottom = size.height,
cornerRadius = CornerRadius(cornerPx, cornerPx),
),
)
}
clipPath(capsule) {
drawRect(color = LineColor)
// Blurred color blobs, drawn with a native BlurMaskFilter paint.
drawIntoCanvas { canvas ->
resolvedDots.forEach { dot ->
val blurPx = dot.blur.toPx()
val paint = Paint().apply {
color = dot.color
if (blurPx > 0f) {
asFrameworkPaint().maskFilter =
BlurMaskFilter(blurPx, BlurMaskFilter.Blur.NORMAL)
}
}
val ovalWidth = DotWidth.toPx()
val ovalHeight = dot.height.toPx()
val centerX = size.width / 2f + dot.offset.x.toPx()
val centerY = dot.offset.y.toPx() + ovalHeight / 2f
canvas.drawOval(
left = centerX - ovalWidth / 2f,
top = centerY - ovalHeight / 2f,
right = centerX + ovalWidth / 2f,
bottom = centerY + ovalHeight / 2f,
paint = paint,
)
}
}
}
}
.dropShadow(
shape = shape,
shadow = Shadow(
radius = 12.dp,
spread = 0.dp,
color = TangemTheme.colors3.bg.brand.copy(alpha = shadowAlpha),
),
)
.border(width = 0.5.dp, color = TangemTheme.colors3.border.secondary, shape = shape),
)
}
/** Glow pulse bounds for the animated drop shadow alpha. */
private const val GLOW_MIN_ALPHA = 0.3f
private const val GLOW_MAX_ALPHA = 0.65f
/** Line fill — Figma "Background color" #0000F9 at 56% opacity (alpha 0x8F). */
private val LineColor = Color(0x8F0000F9)
/** Fixed blob width (the oval's horizontal diameter). */
private val DotWidth = 12.dp
/** Accent colors for the glow blobs, resolved from `colors3.icon.accent.*` in the composable. */
private data class GlowDotColors(
val violet: Color,
val green: Color,
val blue: Color,
val orange: Color,
)
@Suppress("MagicNumber")
private fun canvasDefaultDots(lineHeight: Dp, colors: GlowDotColors): List<CanvasGlowDot> {
val step = lineHeight / 5
return listOf(
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = (-3).dp), height = step), // purple
CanvasGlowDot(colors.green, DpOffset(x = 4.dp, y = step * 1.7f), height = step), // green
CanvasGlowDot(colors.blue, DpOffset(x = (-2.5).dp, y = step * 2.2f), height = step), // blue
CanvasGlowDot(colors.orange, DpOffset(x = (-3.5).dp, y = step * 3), height = step + step / 2),
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = step * 4 + 4.dp), height = step),
)
}
// region Previews
@Preview(name = "Canvas glow • Light", showBackground = true)
@Preview(name = "Canvas glow • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewCanvasGradientDivider() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(64.dp),
contentAlignment = Alignment.Center,
) {
// Height comes from the parent — here a fixed 46.dp.
CanvasGradientDivider(modifier = Modifier.height(46.dp))
}
}
}
@Preview(name = "Length variants • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewCanvasLengthVariants() {
TangemThemePreviewRedesign {
Row(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(64.dp),
horizontalArrangement = Arrangement.spacedBy(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CanvasGradientDivider(modifier = Modifier.height(20.dp))
CanvasGradientDivider(modifier = Modifier.height(46.dp))
CanvasGradientDivider(modifier = Modifier.height(80.dp))
CanvasGradientDivider(modifier = Modifier.height(140.dp))
CanvasGradientDivider(modifier = Modifier.height(220.dp))
}
}
}
// endregion

View file

@ -0,0 +1,344 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.state.*
import com.tangem.features.foryou.impl.ui.components.AiInsightContent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.math.BigDecimal
@Composable
internal fun MarketChart(marketChart: MarketChartUM, modifier: Modifier = Modifier) {
var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) }
TangemSurface(
modifier = modifier
.hazeSourceTangem()
.onGloballyPositioned { cardBoundsInWindow = it.boundsInWindow() },
color = TangemTheme.colors3.bg.secondary,
) {
Column(modifier = Modifier.fillMaxWidth()) {
DonutChartBlock(marketChart.donutChart, cardBoundsInWindow)
Spacer(modifier = Modifier.height(16.dp))
if (marketChart is MarketChartUM.Loaded) {
TopHoldingBlock(
assetCount = marketChart.assetCount,
topHoldingPercent = marketChart.topHoldingPercent,
)
} else {
CantLoadDataBlock()
}
Spacer(modifier = Modifier.height(16.dp))
if (marketChart.aiInsight is AiInsightUM.Displayed) SpacerH8()
AiInsightContent(
aiInsightUM = marketChart.aiInsight,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
}
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.DonutChartBlock(donutChartUM: DonutChartUM, cardBoundsInWindow: Rect) {
var selectedIndex by remember { mutableStateOf<Int?>(null) }
val segments = donutChartUM.donutSegmentList
val scope = rememberCoroutineScope()
var dismissJob by remember { mutableStateOf<Job?>(null) }
var chartSize by remember { mutableStateOf(IntSize.Zero) }
var chartWindowOffset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.padding(32.dp)
.align(Alignment.CenterHorizontally)
.size(200.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
chartSize = coordinates.size
chartWindowOffset = coordinates.localToWindow(Offset.Zero)
}
// A press on the chart means this tap is "on the chart", not "outside" — veto the pending
// outside-dismiss before it commits.
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
dismissJob?.cancel()
}
},
selectedIndex = selectedIndex,
strokeWidth = DonutStrokeWidth,
startAngle = DonutStartAngle,
// Tap a slice → select; tap it again or miss (null) → deselect; tap another slice → switch.
onSegmentClick = { index ->
selectedIndex = index?.takeIf { it != selectedIndex }
},
segments = segments,
) {
if (donutChartUM is DonutChartUM.Loaded) {
Text(
text = donutChartUM.totalAmount,
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
minFontSize = 8.sp,
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
),
)
Text(
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
} else {
Text(
text = stringResourceSafe(R.string.market_chart_bubble_no_data),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
),
)
}
}
DonutSegmentTooltipBlock(
selectedIndex = selectedIndex,
segments = segments,
chartSize = chartSize,
chartWindowOffset = chartWindowOffset,
cardBoundsInWindow = cardBoundsInWindow,
onDismissRequest = {
dismissJob?.cancel()
dismissJob = scope.launch {
withFrameNanos { }
selectedIndex = null
}
},
)
}
}
@Suppress("LongParameterList")
@Composable
private fun DonutSegmentTooltipBlock(
selectedIndex: Int?,
segments: List<DonutSegmentUM>,
chartSize: IntSize,
chartWindowOffset: Offset,
cardBoundsInWindow: Rect,
onDismissRequest: () -> Unit,
) {
val density = LocalDensity.current
val gapPx = with(density) { 8.dp.roundToPx() }
val strokePx = with(density) { DonutStrokeWidth.toPx() }
var shownIndex by remember { mutableStateOf<Int?>(null) }
if (selectedIndex != null) shownIndex = selectedIndex
val isExpanded = selectedIndex?.let(segments::getOrNull) != null
val shownSegment = shownIndex?.let(segments::getOrNull) ?: return
val positionProvider = remember(
shownIndex,
segments,
chartSize,
chartWindowOffset,
cardBoundsInWindow,
strokePx,
gapPx,
) {
segmentTooltipPositionProvider(
selectedIndex = shownIndex,
segments = segments,
chartSize = chartSize,
chartWindowOffset = chartWindowOffset,
strokePx = strokePx,
startAngle = DonutStartAngle,
cardBoundsInWindow = cardBoundsInWindow,
gapPx = gapPx,
)
}
DonutSegmentTooltip(
expanded = isExpanded,
positionProvider = positionProvider,
title = shownSegment.title,
fiatValue = shownSegment.fiatValue,
percent = shownSegment.weight.format { percent() },
onDismissRequest = onDismissRequest,
)
}
private val DonutStrokeWidth = 28.dp
private val DonutStartAngle = -90f
@Composable
private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: TextReference) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = pluralStringResourceSafe(R.plurals.market_chart_assets_android, assetCount, assetCount),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
)
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = topHoldingPercent.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.small,
)
}
@Composable
private fun ColumnScope.CantLoadDataBlock() {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = stringResourceSafe(R.string.market_chart_can_not_load_data),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
)
}
// region Previews
private enum class MarketChartPreviewScenario { DISPLAYED, ASK_AI, NO_AI, NO_DATA }
private class MarketChartPreviewProvider : PreviewParameterProvider<MarketChartPreviewScenario> {
override val values: Sequence<MarketChartPreviewScenario>
get() = MarketChartPreviewScenario.entries.asSequence()
}
@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "MarketChart • Light", showBackground = true)
@Composable
private fun MarketChart_Preview(
@PreviewParameter(MarketChartPreviewProvider::class) scenario: MarketChartPreviewScenario,
) {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
MarketChart(marketChart = previewMarketChartState(scenario))
}
}
}
/** Maps a [scenario] to the state shown in the preview. */
@Suppress("MagicNumber")
@Composable
private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartUM = when (scenario) {
MarketChartPreviewScenario.DISPLAYED -> MarketChartUM.Loaded(
topHoldingPercent = stringReference("Top holding 41%"),
aiInsight = AiInsightUM.Displayed(
"Your portfolio leans on a single asset BTC is 42% of holdings. Stablecoins add 23% " +
"buffer. Consider trimming concentration for a smoother ride",
),
donutChart = previewLoadedDonut(),
)
MarketChartPreviewScenario.ASK_AI -> MarketChartUM.Loaded(
topHoldingPercent = stringReference("Top holding 41%"),
aiInsight = AiInsightUM.AskAiInsight(askAiInsightClick = {}),
donutChart = previewLoadedDonut(),
)
MarketChartPreviewScenario.NO_AI -> MarketChartUM.Loaded(
topHoldingPercent = stringReference("Top holding 41%"),
aiInsight = AiInsightUM.Hide,
donutChart = DonutChartUM.Loaded(
totalAmount = "$10,12345678912.1333",
donutSegmentList = persistentListOf(
DonutSegmentUM(
weight = BigDecimal(0.55),
color = DonutSegmentColor.Brand,
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
),
DonutSegmentUM(
weight = BigDecimal(0.45),
color = DonutSegmentColor.Green,
title = stringReference("Solana"),
fiatValue = stringReference("$728.30"),
),
),
),
)
MarketChartPreviewScenario.NO_DATA -> MarketChartUM.NoData
}
@Suppress("MagicNumber")
@Composable
private fun previewLoadedDonut(): DonutChartUM.Loaded = DonutChartUM.Loaded(
totalAmount = "$10,123456.1333",
donutSegmentList = persistentListOf(
DonutSegmentUM(
weight = BigDecimal(0.90),
color = DonutSegmentColor.Brand,
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
),
DonutSegmentUM(
weight = BigDecimal(0.03),
color = DonutSegmentColor.Violet,
title = stringReference("Solana"),
fiatValue = stringReference("$728.30"),
),
DonutSegmentUM(
weight = BigDecimal(0.03),
color = DonutSegmentColor.Red,
title = stringReference("Polkadot"),
fiatValue = stringReference("$624.26"),
),
DonutSegmentUM(
weight = BigDecimal(0.02),
color = DonutSegmentColor.Green,
title = stringReference("Tether"),
fiatValue = stringReference("$520.18"),
),
),
)
// endregion

View file

@ -0,0 +1,117 @@
package com.tangem.features.foryou.impl.components
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.PopupPositionProvider
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
/**
* Builds the [DonutSegmentTooltip] position provider anchored to the end of the selected slice. Returns the
* centered fallback while the chart hasn't been measured yet or nothing is selected.
*
* @param startAngle Angle (degrees) where the first slice starts must match the [DonutChart] drawing pass
* (`-90f` = 12 o'clock) so the anchor lands on the real slice end.
*/
@Suppress("MagicNumber", "LongParameterList", "ComplexCondition")
internal fun segmentTooltipPositionProvider(
selectedIndex: Int?,
segments: List<DonutSegmentUM>,
chartSize: IntSize,
chartWindowOffset: Offset,
strokePx: Float,
startAngle: Float,
cardBoundsInWindow: Rect,
gapPx: Int,
): PopupPositionProvider {
if (selectedIndex == null || selectedIndex !in segments.indices ||
chartSize.width == 0 || chartSize.height == 0
) {
// Not shown in this state (selectedIndex is null / chart not measured) — position is irrelevant.
return SegmentTooltipPositionProvider(Offset.Zero, Rect.Zero, gapPx)
}
val diameter = min(chartSize.width, chartSize.height).toFloat()
val centerX = chartSize.width / 2f
val centerY = chartSize.height / 2f
val innerRadius = diameter / 2f - strokePx / 2
// End angle of the selected slice (before its round cap) — same *visual* layout as DonutChart's
// drawing pass, so the anchor lands on the (floored) slice end rather than its true-weight end.
val sweeps = visualSweepAngles(segments.map { it.weight.toFloat() })
val endAngleDeg = startAngle + sweeps.take(selectedIndex + 1).sum()
val endAngleRad = Math.toRadians(endAngleDeg.toDouble())
val anchorLocal = Offset(
x = centerX + innerRadius * cos(endAngleRad).toFloat(),
y = centerY + innerRadius * sin(endAngleRad).toFloat() - strokePx / 2,
)
val anchorInWindow = chartWindowOffset + anchorLocal
return SegmentTooltipPositionProvider(
anchorInWindow = anchorInWindow,
cardBoundsInWindow = cardBoundsInWindow,
gapPx = gapPx,
strokePx = strokePx.toInt(),
)
}
/**
* Positions the pill relative to the **end of the selected slice**, per the agreed spec.
*
* - **Base:** the pill's bottom-center sits [gapPx] above [anchorInWindow] (screen-up). [anchorInWindow] is
* the slice-end point on the ring's inner edge, in window coordinates.
* - **Card fallback:** if that placement would push the pill above the top of [cardBoundsInWindow] (the
* `MarketChart` card), it flips to a side placement the pill's start-center sits [gapPx] to the right
* of the anchor.
* - **Screen clamp:** the result is finally kept inside the window with a [gapPx] margin (shifted back by
* however much it overflowed).
*
* @param anchorInWindow Slice-end / inner-edge point in window px.
* @param cardBoundsInWindow `MarketChart` card bounds in window px (only the top edge gates the fallback).
* @param gapPx The 8dp gap, in px.
*/
internal class SegmentTooltipPositionProvider(
private val anchorInWindow: Offset,
private val cardBoundsInWindow: Rect,
private val gapPx: Int,
private val strokePx: Int = 0,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val w = popupContentSize.width
val h = popupContentSize.height
val ax = anchorInWindow.x.roundToInt()
val ay = anchorInWindow.y.roundToInt()
var x = ax - w / 2
var y = ay - h - gapPx
val isFlip = y < cardBoundsInWindow.top
if (isFlip) {
x = ax + gapPx + strokePx / 2
y = ay - h / 2 + strokePx / 2
}
// Keep the pill inside the card on every edge, shifting it back by however much it overflows
// (with a gap margin). The card sits within the screen, so this also keeps the pill on-screen.
val minX = cardBoundsInWindow.left.roundToInt() + gapPx
val minY = cardBoundsInWindow.top.roundToInt() + gapPx
val maxX = (cardBoundsInWindow.right.roundToInt() - w - gapPx).coerceAtLeast(minX)
val maxY = (cardBoundsInWindow.bottom.roundToInt() - h - gapPx).coerceAtLeast(minY)
val clampedX = x.coerceIn(minX, maxX)
val clampedY = y.coerceIn(minY, maxY)
return IntOffset(clampedX, clampedY)
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.foryou.impl.components.state
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import java.math.BigDecimal
/**
* One colored slice of a [com.tangem.features.foryou.impl.components.DonutChart].
*
* @param weight Fraction of the full circle this slice occupies, in `0f..1f`. The slices are laid out
* contiguously; whatever is left after `sum(weight)` shows through as the track.
* For a portfolio where slices sum to 1f the ring fills completely and no track is visible.
* Also doubles as the slice's share for the selection tooltip (rendered as `weight * 100%`).
* @param color Palette entry for the slice's solid fill. Assigned by the producer in segment order, so the
* slice's colour follows its rank; [DonutChart] resolves it to a themed [Color] in composition.
* @param title Human-readable name of the asset this slice represents (e.g. `"Ethereum"`). Shown in the
* selection tooltip. Empty by default for slices that don't need a label.
* @param fiatValue Pre-formatted fiat value of the slice (e.g. `"$5,720.22"`). Shown in the selection
* tooltip next to the share. Empty by default.
*/
@Immutable
internal data class DonutSegmentUM(
val color: DonutSegmentColor,
val weight: BigDecimal,
val title: TextReference,
val fiatValue: TextReference,
)
internal enum class DonutSegmentColor {
Brand,
Violet,
Red,
Green,
;
@ReadOnlyComposable
@Composable
fun getColor(): Color {
return when (this) {
Brand -> TangemTheme.colors3.border.brand
Violet -> TangemTheme.colors3.border.accent.violet
Red -> TangemTheme.colors3.border.accent.red
Green -> TangemTheme.colors3.border.accent.green
}
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.foryou.impl.components.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
internal sealed class MarketChartUM(
open val donutChart: DonutChartUM,
open val aiInsight: AiInsightUM,
) {
data class Loaded(
override val donutChart: DonutChartUM.Loaded,
override val aiInsight: AiInsightUM = AiInsightUM.Hide,
val topHoldingPercent: TextReference,
) : MarketChartUM(
donutChart = donutChart,
aiInsight = aiInsight,
) {
val assetCount: Int = donutChart.donutSegmentList.size
}
data object NoData : MarketChartUM(
donutChart = DonutChartUM.NoData,
aiInsight = AiInsightUM.Hide,
)
}
@Immutable
internal sealed class DonutChartUM(
open val donutSegmentList: ImmutableList<DonutSegmentUM>,
) {
data class Loaded(
val totalAmount: String,
override val donutSegmentList: ImmutableList<DonutSegmentUM>,
) : DonutChartUM(donutSegmentList = donutSegmentList)
data object NoData : DonutChartUM(donutSegmentList = persistentListOf())
}
@Immutable
internal sealed class AiInsightUM {
data object Hide : AiInsightUM()
data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightUM()
data class Displayed(val text: String) : AiInsightUM()
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.foryou.impl.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.decompose.model.Model
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.ForYouFeatureToggles
import com.tangem.features.foryou.impl.DefaultForYouComponent
import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles
import com.tangem.features.foryou.impl.model.ForYouModel
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ForYouFeatureModule {
@Provides
@Singleton
fun provideForYouFeatureToggles(featureTogglesManager: FeatureTogglesManager): ForYouFeatureToggles {
return DefaultForYouFeatureToggles(featureTogglesManager = featureTogglesManager)
}
}
@Module
@InstallIn(SingletonComponent::class)
internal interface ForYouComponentModule {
@Binds
@Singleton
fun bindForYouComponent(factory: DefaultForYouComponent.Factory): ForYouComponent.Factory
@Binds
@IntoMap
@ClassKey(ForYouModel::class)
fun bindForYouModel(impl: ForYouModel): Model
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.foryou.impl.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import kotlinx.collections.immutable.ImmutableList
internal data class ForYouUM(
val portfolioReviewUM: PortfolioReviewUM,
val notifications: ImmutableList<ForYouNotification>,
)
@Immutable
internal sealed interface PortfolioReviewUM {
val tokenList: ImmutableList<ForYouTokenListItemUM>
val marketChartUM: MarketChartUM
data class Loading(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM.NoData,
) : PortfolioReviewUM
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM,
val periodPickerUM: TangemSegmentedPickerUM,
val onPeriodClick: (TangemSegmentUM) -> Unit,
) : PortfolioReviewUM
}
@Immutable
internal data class ForYouTokenListItemUM(
val tokenRowUM: TangemTokenRowUM,
val tokenList: ImmutableList<TangemTokenRowUM>,
val isExpanded: Boolean,
val isExpandable: Boolean,
)

View file

@ -0,0 +1,13 @@
package com.tangem.features.foryou.impl.featuretoggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.foryou.ForYouFeatureToggles
import javax.inject.Inject
internal class DefaultForYouFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : ForYouFeatureToggles {
override val isForYouEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1469_FOR_YOU_ENABLED)
}

View file

@ -0,0 +1,123 @@
package com.tangem.features.foryou.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
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.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.transformer.SetPortfolioReviewTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Stable
@ModelScoped
internal class ForYouModel @Inject constructor(
paramsContainer: ParamsContainer,
userWalletsListRepository: UserWalletsListRepository,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : Model() {
private val params = paramsContainer.require<ForYouComponent.Params>()
private val expandedAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
val uiState: StateFlow<ForYouUM>
field = MutableStateFlow<ForYouUM>(
ForYouUM(
notifications = persistentListOf(),
portfolioReviewUM = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList<ForYouTokenListItemUM> {
repeat(4) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
id = index.toString(),
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
),
),
)
init {
combine(
flow = userWalletsListRepository.selectedUserWallet,
flow2 = multiAccountStatusListSupplier.invokeAsMap(),
flow3 = expandedAssetIds,
) { globalSelectedWallet, accountStatusList, expanded ->
// TODO For You add choose portfolio flow
val selectedWalletId = globalSelectedWallet?.walletId
uiState.update(
SetPortfolioReviewTransformer(
accountStatusList = accountStatusList[selectedWalletId],
appCurrency = selectedAppCurrencyFlow.value,
expandedAssetIds = expanded,
expandClick = ::onExpandClick,
onPeriodClick = ::onPeriodClick,
onTokenClick = { currency -> onTokenClick(selectedWalletId, currency) },
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
}
private fun onTokenClick(selectedWalletId: UserWalletId?, currency: CryptoCurrency) {
val walletId = selectedWalletId ?: return
params.callbacks.onTokenClick(walletId, currency)
}
private fun onExpandClick(assetId: String) {
expandedAssetIds.update { ids ->
if (assetId in ids) ids - assetId else ids + assetId
}
}
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
uiState.update { state ->
state.copy(
portfolioReviewUM = (state.portfolioReviewUM as? PortfolioReviewUM.Content)?.copy(
periodPickerUM = state.portfolioReviewUM.periodPickerUM.copy(
initialSelectedItem = tangemSegmentUM,
),
) ?: state.portfolioReviewUM,
)
}
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.foryou.impl.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cloud_exclamation_20
import com.tangem.features.foryou.impl.R
@Immutable
internal sealed class ForYouNotification(val state: TangemMessageBanner.State) {
data object UsedOutdatedData : ForYouNotification(
state = TangemMessageBanner.State(
title = resourceReference(R.string.warning_some_token_balances_not_updated),
iconEnd = TangemIconUM.Icon(
imageVector = Icons.ic_cloud_exclamation_20,
tintReference = { TangemTheme.colors3.icon.primary },
),
variant = TangemMessageBanner.Variant.Warning,
shouldShowGlowRing = false,
),
)
}

View file

@ -0,0 +1,55 @@
package com.tangem.features.foryou.impl.model.converter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.foryou.impl.components.state.*
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class ForYouMarketChartConverter(
private val appCurrency: AppCurrency,
private val topAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
) : Converter<TotalFiatBalance?, MarketChartUM> {
override fun convert(value: TotalFiatBalance?): MarketChartUM {
val topBalance = topAssets.sumOf { (_, assetBalance) -> assetBalance }
return when (value) {
is TotalFiatBalance.Loaded -> MarketChartUM.Loaded(
donutChart = DonutChartUM.Loaded(
totalAmount = value.amount.format {
fiat(
fiatCurrencySymbol = appCurrency.symbol,
fiatCurrencyCode = appCurrency.code,
)
},
donutSegmentList = topAssets.mapIndexed { index, (currencies, segmentBalance) ->
val segmentWeight = segmentBalance.toForYouPercent(value.amount).orZero()
DonutSegmentUM(
color = DonutSegmentColor.entries.getOrNull(index) ?: DonutSegmentColor.Brand,
weight = segmentWeight,
title = stringReference(currencies.firstOrNull()?.currency?.name.orEmpty()),
fiatValue = stringReference(segmentBalance.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}),
)
}.toPersistentList(),
),
aiInsight = AiInsightUM.Hide,
topHoldingPercent = stringReference(topBalance.toForYouPercent(value.amount).format { percent() }),
)
TotalFiatBalance.Loading,
TotalFiatBalance.Failed,
null,
-> MarketChartUM.NoData
}
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.foryou.impl.model.converter
import com.tangem.core.ui.ds.badge.TangemBadgeColor
import com.tangem.core.ui.ds.badge.TangemBadgeSize
import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Formatting helpers shared by the For You portfolio-review converters.
*
* Kept null-safe so that non-[CryptoCurrencyStatus.Loaded] states (which carry no fiat amount) degrade
* to `null` instead of throwing.
*/
/**
* Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC
* on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens
* have no raw id and fall back to their unique currency id, staying in their own group.
*/
internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value
/**
* Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be
* computed (no amount, or a zero total / amount).
*/
internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? {
if (this == null || totalFiatBalance.isZero() || isZero()) return null
return divide(totalFiatBalance, RoundingMode.HALF_UP)
}
// TODO For You: replace this placeholder with the real price-change badge once the design is wired.
internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
)

View file

@ -0,0 +1,161 @@
package com.tangem.features.foryou.impl.model.converter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
/**
* Builds the For You portfolio-review list: groups the given currency statuses by asset across networks
* (see [forYouGroupKey]) and maps each group to a [ForYouTokenListItemUM] an aggregate asset row plus,
* when the asset spans more than one network, its per-network child rows.
*
* The child rows are grouped by network (delegated to [ForYouTokenRowConverter]) so a network appears
* once per asset even if the asset is held on it in several accounts;
*
* Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter).
*/
internal class ForYouTokenListConverter(
private val appCurrency: AppCurrency,
private val totalFiatBalance: BigDecimal,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
private val onTokenClick: (CryptoCurrency) -> Unit,
) : Converter<List<CryptoCurrencyStatus>, ImmutableList<ForYouTokenListItemUM>> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val rowConverter = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
override fun convert(value: List<CryptoCurrencyStatus>): ImmutableList<ForYouTokenListItemUM> {
val assetItems = value
.groupBy { it.forYouGroupKey() }
.map { (assetId, currencies) -> createListItem(assetId, currencies) }
// Assets beyond the top ones are collapsed into a single non-expandable "Other" row at the bottom.
return if (otherAssets.count() > 0) {
assetItems + createOtherItem()
} else {
assetItems
}.toPersistentList()
}
private fun createListItem(assetId: String, currencies: List<CryptoCurrencyStatus>): ForYouTokenListItemUM {
// Group the asset's holdings by blockchain (network.id.rawId, derivation-independent) so each
// network appears once even when the asset is held across several accounts/derivations on it,
// summing those balances. Order by balance so the expanded breakdown reads top-down.
val networkGroups = currencies
.groupBy { it.currency.network.id.rawId }
.values
.sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } }
return ForYouTokenListItemUM(
tokenRowUM = createAssetRow(
assetId = assetId,
currencies = currencies,
networkCount = networkGroups.size,
),
tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(),
isExpanded = assetId in expandedAssetIds,
isExpandable = true,
)
}
private fun createAssetRow(
assetId: String,
currencies: List<CryptoCurrencyStatus>,
networkCount: Int,
): TangemTokenRowUM {
if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) {
return TangemTokenRowUM.Loading(id = assetId)
}
val asset = currencies.first()
val assetFiatBalance = currencies.sumOf { it.value.fiatAmount.orZero() }
val endContent = rowConverter.toEndContent(statuses = currencies, fiatAmount = assetFiatBalance)
val onlyCryptoCurrency = currencies.firstOrNull()?.currency
val isMain = onlyCryptoCurrency is CryptoCurrency.Coin
val subtitle = when {
networkCount > 1 -> pluralReference(R.plurals.common_networks_count, networkCount)
isMain -> resourceReference(R.string.common_main_network)
onlyCryptoCurrency != null -> stringReference(onlyCryptoCurrency.network.standardType.name)
else -> TextReference.EMPTY
}
return TangemTokenRowUM.Content(
id = assetId,
headIconUM = TangemIconUM.Currency(iconConverter.convert(asset)),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(asset.currency.symbol),
badge = forYouPlaceholderBadge(),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = subtitle,
),
topEndContentUM = endContent.top,
bottomEndContentUM = endContent.bottom,
onItemClick = { expandClick(assetId) },
onItemLongClick = null,
)
}
private fun createOtherItem(): ForYouTokenListItemUM {
val otherAssetsBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance }
return ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = OTHER_ROW_ID,
headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()),
titleUM = TangemTokenRowUM.TitleUM.Content(text = resourceReference(R.string.common_other)),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = pluralReference(R.plurals.market_chart_assets_android, otherAssets.count()),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference(
otherAssetsBalance.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference(otherAssetsBalance.toForYouPercent(totalFiatBalance).format { percent() }),
),
onItemClick = null,
onItemLongClick = null,
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
)
}
private companion object {
const val OTHER_ROW_ID = "for_you_other_assets"
}
}

View file

@ -0,0 +1,226 @@
package com.tangem.features.foryou.impl.model.converter
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.styledResourceReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
/**
* Builds a single per-network child row of an asset for the For You portfolio review.
*
* The input is all [CryptoCurrencyStatus]es of one asset on the *same* network (the asset may be held in
* several accounts on that network). They are aggregated into one row the crypto amount and fiat balance
* are the per-network totals so a network never appears twice within an asset's expanded breakdown.
*
* 1. all [CryptoCurrencyStatus.Loading] a Loading row;
* 2. any [CryptoCurrencyStatus.MissedDerivation] "no address" treatment (missing address dominates
* the balance for that portion can't be trusted);
* 3. any [CryptoCurrencyStatus.Unreachable] / [CryptoCurrencyStatus.NoAmount] "unreachable" treatment;
* 4. otherwise a normal content row summing the loaded/custom/no-quote/no-account amounts.
*
* [CryptoCurrencyStatus.Loading] entries inside an otherwise-resolved group are ignored for
* classification (they contribute nothing yet). The cache/flicker indicators derive from the most
* conservative [CryptoCurrencyStatus.Sources.total] across the contributing statuses.
*/
internal class ForYouTokenRowConverter(
private val appCurrency: AppCurrency,
private val totalFiatBalance: BigDecimal,
private val onTokenClick: (CryptoCurrency) -> Unit,
) {
private val iconConverter = CryptoCurrencyToIconStateConverter()
/** Builds one row for all [statuses] of a single asset on the same network. */
fun convertNetworkGroup(statuses: List<CryptoCurrencyStatus>): TangemTokenRowUM {
val representative = statuses.first()
if (statuses.all { it.value is CryptoCurrencyStatus.Loading }) {
return TangemTokenRowUM.Loading(id = representative.currency.id.value)
}
val currency = representative.currency
val cryptoAmount = statuses.sumOf { it.value.amount.orZero() }
val fiatAmount = statuses.sumOf { it.value.fiatAmount.orZero() }
val state = statuses.classify()
return TangemTokenRowUM.Content(
id = currency.id.value,
headIconUM = TangemIconUM.Currency(iconConverter.convert(representative)),
titleUM = toRowTitle(currency),
subtitleUM = toRowSubtitle(state, currency, cryptoAmount),
topEndContentUM = toRowTopEnd(state, fiatAmount),
bottomEndContentUM = toRowBottomEnd(state, fiatAmount),
onItemClick = { onTokenClick(currency) },
onItemLongClick = null,
)
}
/**
* Maps the aggregate status of [statuses] onto a row's top/bottom end content, rendering the given
* pre-summed [fiatAmount]. Reflects the same cache-flicker / could-not-refresh / no-address /
* unreachable treatment as [convertNetworkGroup], so the asset-level row surfaces the combined status
* of its holdings analogous to how `AccountCryptoPortfolioItemStateConverter` reflects a
* `TotalFiatBalance`'s status on the account row.
*
* Callers must handle the all-[CryptoCurrencyStatus.Loading] case (a Loading row) before calling this.
*/
fun toEndContent(statuses: List<CryptoCurrencyStatus>, fiatAmount: BigDecimal): EndContent {
val state = statuses.classify()
return EndContent(
top = toRowTopEnd(state, fiatAmount),
bottom = toRowBottomEnd(state, fiatAmount),
)
}
/** Title: For You always shows the asset symbol with the placeholder price-change badge. */
private fun toRowTitle(currency: CryptoCurrency): TangemTokenRowUM.TitleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(currency.symbol),
badge = forYouPlaceholderBadge(),
)
/**
* Subtitle: `network amount` for resolved states, error messaging otherwise. Kept single-line to
* match For You's style (no separate price-change line as in the wallet).
*/
private fun toRowSubtitle(
state: RowState,
currency: CryptoCurrency,
cryptoAmount: BigDecimal,
): TangemTokenRowUM.SubtitleUM = when (state) {
is RowState.Normal -> TangemTokenRowUM.SubtitleUM.Content(
text = stringReference(
"${currency.network.name} ${StringsSigns.DOT} ${
cryptoAmount.format {
crypto(
cryptoCurrency = currency,
)
}
}",
),
isFlickering = state.isFlickering,
)
RowState.NoAddress -> TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("${currency.network.name} ${StringsSigns.DOT} ${StringsSigns.DASH_SIGN}"),
)
RowState.Unreachable -> TangemTokenRowUM.SubtitleUM.Content(
text = stringReference(currency.network.name),
)
}
/** Top-end: fiat total for resolved states, dash / unreachable treatment otherwise. */
private fun toRowTopEnd(state: RowState, fiatAmount: BigDecimal): TangemTokenRowUM.EndContentUM = when (state) {
is RowState.Normal -> TangemTokenRowUM.EndContentUM.Content(
text = stringReference(
fiatAmount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
isFlickering = state.isFlickering,
startIcons = buildList {
if (state.isOnlyCache) {
add(
TangemIconUM.Icon(
iconRes = R.drawable.ic_error_sync_default_24,
tintReference = { TangemTheme.colors3.icon.tertiary },
),
)
}
}.toImmutableList(),
)
RowState.NoAddress,
RowState.Unreachable,
-> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN))
}
/** Bottom-end: percentage share for resolved states, no-address / unreachable treatment otherwise. */
private fun toRowBottomEnd(state: RowState, fiatAmount: BigDecimal): TangemTokenRowUM.EndContentUM = when (state) {
is RowState.Normal -> TangemTokenRowUM.EndContentUM.Content(
text = stringReference(fiatAmount.toForYouPercent(totalFiatBalance).format { percent() }),
isFlickering = state.isFlickering,
)
RowState.NoAddress -> attentionEndContent(R.string.common_no_address)
RowState.Unreachable -> attentionEndContent(R.string.common_unreachable)
}
private fun attentionEndContent(textRes: Int): TangemTokenRowUM.EndContentUM =
TangemTokenRowUM.EndContentUM.Content(
text = styledResourceReference(
id = textRes,
spanStyleReference = { SpanStyle(color = TangemTheme.colors3.text.status.warning) },
),
endIcons = persistentListOf(
TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors3.icon.status.warning },
),
),
)
/**
* Collapses a mixed group into a single [RowState]. Loading-only groups are handled earlier, so a
* group reaching here has at least one non-loading status. See the class KDoc for the priority rule.
*/
private fun List<CryptoCurrencyStatus>.classify(): RowState {
val resolved = filterNot { it.value is CryptoCurrencyStatus.Loading }.map { it.value }
return when {
resolved.any { it is CryptoCurrencyStatus.MissedDerivation } -> RowState.NoAddress
resolved.any {
it is CryptoCurrencyStatus.Unreachable || it is CryptoCurrencyStatus.NoAmount
} -> RowState.Unreachable
else -> {
val worstSource = resolved.map { it.sources.total }.worst()
RowState.Normal(
isFlickering = worstSource == StatusSource.CACHE,
isOnlyCache = worstSource == StatusSource.ONLY_CACHE,
)
}
}
}
/**
* The most conservative status across the group: any [StatusSource.ONLY_CACHE] (could-not-refresh)
* dominates a [StatusSource.CACHE] (still refreshing), which in turn dominates [StatusSource.ACTUAL].
*/
private fun List<StatusSource>.worst(): StatusSource = when {
any { it == StatusSource.ONLY_CACHE } -> StatusSource.ONLY_CACHE
any { it == StatusSource.CACHE } -> StatusSource.CACHE
else -> StatusSource.ACTUAL
}
/** The top and bottom end content of a token row, produced together from one classified group. */
data class EndContent(
val top: TangemTokenRowUM.EndContentUM,
val bottom: TangemTokenRowUM.EndContentUM,
)
/** Rendering-relevant collapse of the group's per-currency-status states. */
private sealed interface RowState {
/** Loaded / Custom / NoQuote / NoAccount — normal amounts, with cache/flicker indicators. */
data class Normal(val isFlickering: Boolean, val isOnlyCache: Boolean) : RowState
/** At least one MissedDerivation — no blockchain address obtained. */
data object NoAddress : RowState
/** At least one Unreachable / NoAmount — network could not be reached. */
data object Unreachable : RowState
}
}

View file

@ -0,0 +1,116 @@
package com.tangem.features.foryou.impl.model.transformer
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import com.tangem.features.foryou.impl.model.converter.ForYouMarketChartConverter
import com.tangem.features.foryou.impl.model.converter.ForYouTokenListConverter
import com.tangem.features.foryou.impl.model.converter.forYouGroupKey
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
/**
* Builds the [ForYouUM] state for the For You screen: the outdated-data notifications plus the portfolio
* review (market chart, period picker and the grouped token list).
*
* The token list is delegated to [ForYouTokenListConverter] and the market chart to
* [ForYouMarketChartConverter]; the period picker selection is carried over from the previous state so
* it is not reset on every balance refresh.
*
* Modelled on `SetTokenListTransformer` (a transformer that rebuilds the state while delegating the
* token-list construction to a dedicated converter).
*/
@Suppress("LongParameterList")
internal class SetPortfolioReviewTransformer(
private val accountStatusList: AccountStatusList?,
private val appCurrency: AppCurrency,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val onPeriodClick: (TangemSegmentUM) -> Unit,
private val onTokenClick: (CryptoCurrency) -> Unit,
) : Transformer<ForYouUM> {
override fun transform(prevState: ForYouUM): ForYouUM {
val currencies = accountStatusList?.flattenCurrencies().orEmpty()
val loadedBalance = accountStatusList?.totalFiatBalance as? TotalFiatBalance.Loaded
val totalFiatBalance = loadedBalance?.amount.orZero()
// Drop only assets we positively know are empty — a resolved, priced zero fiat balance. Currencies
// whose fiat we couldn't determine (unreachable / no-address / no-quote / still-loading — i.e. any
// non-content status, which all carry a null fiatAmount) are kept so the converter can still render
// them with the appropriate treatment instead of hiding a token the user actually holds.
// Then aggregate the rest into assets (the same token across networks shares its forYouGroupKey)
// and rank assets by their *summed* fiat balance.
val rankedAssets = currencies
.filterNot { it.value.fiatAmount?.isZero() == true }
.groupBy { it.forYouGroupKey() }
.map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } }
.sortedByDescending { (_, assetBalance) -> assetBalance }
// The top assets are shown individually (each flattened back to its networks so the converter can
// regroup them by network); the remaining assets are collapsed into a single "Other" row.
val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT)
val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT)
val topCurrencies = topAssets.flatMap { (networks, _) -> networks }
val tokenList = ForYouTokenListConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
otherAssets = otherAssets,
onTokenClick = onTokenClick,
).convert(topCurrencies)
val marketChartUM = ForYouMarketChartConverter(
appCurrency = appCurrency,
topAssets = topAssets,
).convert(accountStatusList?.totalFiatBalance)
return prevState.copy(
notifications = if (loadedBalance?.source == StatusSource.ONLY_CACHE) {
persistentListOf(ForYouNotification.UsedOutdatedData)
} else {
persistentListOf()
},
portfolioReviewUM = PortfolioReviewUM.Content(
periodPickerUM = when (prevState.portfolioReviewUM) {
is PortfolioReviewUM.Content -> prevState.portfolioReviewUM.periodPickerUM
is PortfolioReviewUM.Loading -> createPeriodPicker()
},
tokenList = tokenList,
marketChartUM = marketChartUM,
onPeriodClick = onPeriodClick,
),
)
}
private fun createPeriodPicker(): TangemSegmentedPickerUM {
// TODO For you replace with data from backend
val day = TangemSegmentUM(id = "0", title = stringReference("Day"))
return TangemSegmentedPickerUM(
items = persistentListOf(
day,
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = day,
isFixed = true,
isAltSurface = true,
)
}
private companion object {
const val TOP_HOLDINGS_COUNT = 4
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.foryou.impl.tokensummary
import androidx.compose.foundation.layout.PaddingValues
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.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.foryou.TokenSummaryComponent
import com.tangem.features.foryou.impl.tokensummary.entity.InfoBottomSheetContent
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryBottomSheetConfig
import com.tangem.features.foryou.impl.tokensummary.model.TokenSummaryModel
import com.tangem.features.foryou.impl.tokensummary.ui.TokenSummaryContent
import com.tangem.features.foryou.impl.tokensummary.ui.components.InfoBottomSheet
import com.tangem.features.foryou.impl.tokensummary.ui.components.TokenSummaryTopNavigation
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultTokenSummaryComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: TokenSummaryComponent.Params,
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
) : TokenSummaryComponent, AppComponentContext by context {
private val model: TokenSummaryModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenSummaryBottomSheetConfig.serializer(),
handleBackButton = false,
childFactory = { config, componentContext ->
when (config) {
TokenSummaryBottomSheetConfig.PortfolioSelector -> portfolioSelectorChild(componentContext)
is TokenSummaryBottomSheetConfig.Info -> infoChild(config)
}
},
)
private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent =
portfolioSelectorComponentFactory.create(
context = childByContext(componentContext),
params = PortfolioSelectorComponent.Params(
portfolioFetcher = model.portfolioFetcher,
controller = model.portfolioSelectorController,
bsCallback = model.portfolioSelectorCallback,
),
)
private fun infoChild(config: TokenSummaryBottomSheetConfig.Info): ComposableBottomSheetComponent =
object : ComposableBottomSheetComponent {
override fun dismiss() = model.bottomSheetNavigation.dismiss()
@Composable
override fun BottomSheet() {
InfoBottomSheet(
infoBottomSheetContent = InfoBottomSheetContent(
title = stringReference(config.indicatorType.title),
body = stringReference("helps to estimate the token's momentum and market sentiment."),
),
onDismiss = ::dismiss,
)
}
}
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val uiState by model.uiState.collectAsStateWithLifecycle()
TokenSummaryTopNavigation(
header = uiState.header,
onCloseClick = uiState.onCloseClick,
)
}
@Composable
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val state by model.uiState.collectAsStateWithLifecycle()
val bottomSheetSlot by bottomSheetSlot.subscribeAsState()
TokenSummaryContent(
tokenSummary = state,
contentPadding = contentPadding,
modifier = modifier,
)
bottomSheetSlot.child?.instance?.BottomSheet()
}
@AssistedFactory
interface Factory : TokenSummaryComponent.Factory {
override fun create(
context: AppComponentContext,
params: TokenSummaryComponent.Params,
): DefaultTokenSummaryComponent
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.foryou.impl.tokensummary.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.foryou.TokenSummaryComponent
import com.tangem.features.foryou.impl.tokensummary.DefaultTokenSummaryComponent
import com.tangem.features.foryou.impl.tokensummary.model.TokenSummaryModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface TokenSummaryComponentModule {
@Binds
@Singleton
fun bindTokenSummaryComponent(factory: DefaultTokenSummaryComponent.Factory): TokenSummaryComponent.Factory
@Binds
@IntoMap
@ClassKey(TokenSummaryModel::class)
fun bindTokenSummaryModel(impl: TokenSummaryModel): Model
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.foryou.impl.tokensummary.entity
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
/**
* Content of the informational bottom sheet shown when the user taps an indicator's info icon on the token
* summary screen. Displays a [title] and an explanatory [body].
*/
internal data class InfoBottomSheetContent(
val title: TextReference,
val body: TextReference,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,14 @@
package com.tangem.features.foryou.impl.tokensummary.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
@Immutable
internal sealed interface PeriodPickerUM {
data class Content(val picker: TangemSegmentedPickerUM) : PeriodPickerUM
data object Loading : PeriodPickerUM
data object Empty : PeriodPickerUM
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.foryou.impl.tokensummary.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.badge.TangemBadgeUM
@Immutable
internal sealed interface TokenIndicatorUM {
val indicatorType: IndicatorType
data class Content(
val sentimentBadge: TangemBadgeUM,
val scoreBadge: TangemBadgeUM,
override val indicatorType: IndicatorType,
) : TokenIndicatorUM
data class NoData(override val indicatorType: IndicatorType) : TokenIndicatorUM
data class Loading(override val indicatorType: IndicatorType) : TokenIndicatorUM
}
// TODO find out right source
internal enum class IndicatorType(val title: String) {
GalaxyScore("Galaxy score"),
Sentiment("Sentiment"),
RSI("RSI"),
MACD("MACD"),
MA_CROSS("MA Cross"),
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.foryou.impl.tokensummary.entity
import androidx.annotation.IntRange
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Immutable
internal sealed class TokenSentimentUM {
abstract val indicators: ImmutableList<TokenIndicatorUM>
data class Content(
val sentiment: TextReference,
@param:IntRange(from = -5, to = 5)
val totalScore: Int,
val lastUpdate: TextReference,
override val indicators: ImmutableList<TokenIndicatorUM>,
) : TokenSentimentUM()
data object Empty : TokenSentimentUM() {
override val indicators: ImmutableList<TokenIndicatorUM> = IndicatorType.entries
.map { TokenIndicatorUM.NoData(indicatorType = it) }
.toImmutableList()
}
data object Loading : TokenSentimentUM() {
override val indicators: ImmutableList<TokenIndicatorUM> = IndicatorType.entries
.map { TokenIndicatorUM.Loading(indicatorType = it) }
.toImmutableList()
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.foryou.impl.tokensummary.entity
import kotlinx.serialization.Serializable
/**
* Navigation config for the single bottom-sheet slot hosted by the token summary component.
*
* Both sheets are mutually exclusive the slot holds at most one child at a time.
*/
@Serializable
internal sealed interface TokenSummaryBottomSheetConfig {
/** Portfolio selector shown before opening swap in multi-account mode. */
@Serializable
data object PortfolioSelector : TokenSummaryBottomSheetConfig
/** Informational sheet describing the tapped [indicatorType]. */
@Serializable
data class Info(val indicatorType: IndicatorType) : TokenSummaryBottomSheetConfig
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.foryou.impl.tokensummary.entity
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
internal data class TokenSummaryHeaderUM(
val tangemIconUM: TangemIconUM,
val title: TextReference,
val subtitle: TextReference?,
)

View file

@ -0,0 +1,15 @@
package com.tangem.features.foryou.impl.tokensummary.entity
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.features.foryou.impl.components.state.AiInsightUM
internal data class TokenSummaryUm(
val header: TokenSummaryHeaderUM,
val periodPicker: PeriodPickerUM,
val aiInsight: AiInsightUM,
val tokenSentiment: TokenSentimentUM,
val onSwapClick: () -> Unit,
val onPeriodClick: (TangemSegmentUM) -> Unit,
val onInfoClick: (IndicatorType) -> Unit,
val onCloseClick: () -> Unit,
)

View file

@ -0,0 +1,204 @@
package com.tangem.features.foryou.impl.tokensummary.model
import androidx.compose.runtime.Stable
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
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.ui.R
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.status.producer.SingleAccountStatusProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import com.tangem.features.foryou.TokenSummaryComponent
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryBottomSheetConfig
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
import com.tangem.features.foryou.impl.tokensummary.model.transformer.TokenSummaryTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.Unit
@Stable
@ModelScoped
internal class TokenSummaryModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val appRouter: AppRouter,
private val portfolioFetcherFactory: PortfolioFetcher.Factory,
private val singleAccountStatusSupplier: SingleAccountStatusSupplier,
val portfolioSelectorController: PortfolioSelectorController,
) : Model() {
private val params = paramsContainer.require<TokenSummaryComponent.Params>()
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val swapNavigationJob = JobHolder()
private val selectedTokenPeriodId = MutableStateFlow(value = params.selectedTokenPeriodId)
/** Drives the single bottom-sheet slot hosted by the component (portfolio selector / info). */
val bottomSheetNavigation: SlotNavigation<TokenSummaryBottomSheetConfig> = SlotNavigation()
/** Feeds the portfolio selector with the wallet's accounts. */
val portfolioFetcher: PortfolioFetcher by lazy {
portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.Wallet(params.userWalletId),
scope = modelScope,
)
}
val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback {
override val onDismiss: () -> Unit = { bottomSheetNavigation.dismiss() }
override val onBack: () -> Unit = { bottomSheetNavigation.dismiss() }
}
val uiState: StateFlow<TokenSummaryUm>
field = MutableStateFlow<TokenSummaryUm>(buildInitialUiState())
init {
selectedTokenPeriodId
.onEach { periodId ->
uiState.update(
TokenSummaryTransformer(),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun buildInitialUiState(): TokenSummaryUm {
return TokenSummaryUm(
header = buildHeader(),
periodPicker = PeriodPickerUM.Content(
TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = null,
isFixed = true,
isAltSurface = true,
),
),
tokenSentiment = TokenSentimentUM.Loading,
aiInsight = AiInsightUM.Hide,
onSwapClick = ::onSwapClicked,
onPeriodClick = ::onPeriodClick,
onInfoClick = ::onInfoClick,
onCloseClick = params.callbacks::onDismiss,
)
}
private fun buildHeader(): TokenSummaryHeaderUM = when (val token = params.token) {
is TokenSummaryComponent.Token.Portfolio -> {
val currency = token.cryptoCurrency
TokenSummaryHeaderUM(
tangemIconUM = TangemIconUM.Currency(iconConverter.convert(currency)),
title = stringReference(currency.name.ifBlank { currency.symbol }),
subtitle = stringReference(currency.network.name),
)
}
is TokenSummaryComponent.Token.Market -> TokenSummaryHeaderUM(
tangemIconUM = TangemIconUM.Url(url = token.tangemIconUrl, fallbackRes = R.drawable.ic_custom_token_44),
title = stringReference(token.title),
subtitle = null,
)
}
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
if (tangemSegmentUM.id == selectedTokenPeriodId.value) return
uiState.update {
it.copy(tokenSentiment = TokenSentimentUM.Loading)
}
selectedTokenPeriodId.value = tangemSegmentUM.id
}
private fun onSwapClicked() {
modelScope.launch {
val account = if (portfolioSelectorController.isAccountModeSync()) {
portfolioSelectorController.selectAccount(null)
bottomSheetNavigation.activate(TokenSummaryBottomSheetConfig.PortfolioSelector)
val (_, selectedAccount) = portfolioSelectorController
.selectedAccountWithData(portfolioFetcher)
.filterNotNull()
.first()
bottomSheetNavigation.dismiss()
selectedAccount
} else {
singleAccountStatusSupplier(
SingleAccountStatusProducer.Params(
accountId = AccountId.forMainCryptoPortfolio(params.userWalletId),
),
)
.filterIsInstance<AccountStatus.CryptoPortfolio>()
.first()
}
val currency = account.flattenCurrencies()
.map(CryptoCurrencyStatus::currency)
.firstOrNull(::matchesSummaryToken)
navigateToSwap(currency)
}.saveIn(swapNavigationJob)
}
private fun matchesSummaryToken(currency: CryptoCurrency): Boolean = when (val token = params.token) {
is TokenSummaryComponent.Token.Portfolio -> {
val summaryCurrency = token.cryptoCurrency
currency.id.rawCurrencyId == summaryCurrency.id.rawCurrencyId && currency.network == summaryCurrency.network
}
is TokenSummaryComponent.Token.Market -> currency.id.rawCurrencyId == token.cryptoCurrencyRawId
}
private fun navigateToSwap(currency: CryptoCurrency?) {
appRouter.push(
AppRoute.Swap(
userWalletId = params.userWalletId,
fromCryptoCurrency = currency,
screenSource = "screen source", // TODO
),
)
}
private fun onInfoClick(indicatorType: IndicatorType) {
bottomSheetNavigation.activate(TokenSummaryBottomSheetConfig.Info(indicatorType))
}
}

View file

@ -0,0 +1,124 @@
package com.tangem.features.foryou.impl.tokensummary.model.transformer
import com.tangem.core.ui.ds.badge.TangemBadgeColor
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.badge.TangemBadgeUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongParameterList")
internal class TokenSummaryTransformer : Transformer<TokenSummaryUm> {
override fun transform(prevState: TokenSummaryUm): TokenSummaryUm {
return prevState.copy(
tokenSentiment = TokenSentimentUM.Content(
sentiment = calculateSentiment(),
lastUpdate = stringReference("Updated Jan 20 2026, 9:24 PM"), // TODO For You localization
totalScore = -4,
indicators = mapIndicators(),
),
)
}
}
private fun calculateSentiment(): TextReference {
val outlook = "Negative"
return stringReference("$outlook outlook") // TODO For You localization
}
@Suppress("LongMethod")
private fun mapIndicators() = persistentListOf(
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Neutral"),
color = TangemBadgeColor.Blue,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.GalaxyScore,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Positive"),
color = TangemBadgeColor.Green,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.Sentiment,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Negative"),
color = TangemBadgeColor.Red,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.RSI,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Negative"),
color = TangemBadgeColor.Red,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.MACD,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Negative"),
color = TangemBadgeColor.Red,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.MA_CROSS,
),
)

View file

@ -0,0 +1,383 @@
package com.tangem.features.foryou.impl.tokensummary.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
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.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.ds.badge.TangemBadge
import com.tangem.core.ui.ds.badge.TangemBadgeColor
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.badge.TangemBadgeUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
import com.tangem.features.foryou.impl.tokensummary.ui.preivew.previewContentSentiment
import com.tangem.features.foryou.impl.tokensummary.ui.preivew.previewTokenSummary
import com.tangem.features.foryou.impl.ui.components.AiInsightContent
import com.tangem.features.foryou.impl.ui.components.GradientScaleBar
import com.tangem.features.foryou.impl.ui.components.GradientScaleBarState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun TokenSummaryContent(
tokenSummary: TokenSummaryUm,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
var buttonHeight by remember { mutableStateOf(0.dp) }
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = contentPadding.calculateTopPadding()),
) {
when (val periodPicker = tokenSummary.periodPicker) {
is PeriodPickerUM.Content -> TangemSegmentedPicker(
modifier = Modifier.padding(16.dp),
tangemSegmentedPickerUM = periodPicker.picker,
onClick = tokenSummary.onPeriodClick,
)
PeriodPickerUM.Loading -> RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.height(44.dp),
radius = 12.dp,
)
PeriodPickerUM.Empty -> Unit
}
SpacerH32()
when (val tokenSentiment = tokenSummary.tokenSentiment) {
is TokenSentimentUM.Content -> SentimentsContent(
tokenSentiment = tokenSentiment,
aiInsight = tokenSummary.aiInsight,
modifier = Modifier.padding(horizontal = 16.dp),
)
is TokenSentimentUM.Empty -> EmptySentimentContent(
modifier = Modifier.padding(horizontal = 16.dp),
)
is TokenSentimentUM.Loading -> LoadingSentimentContent(
modifier = Modifier.padding(horizontal = 16.dp),
)
}
IndicatorsList(
indicators = tokenSummary.tokenSentiment.indicators,
onInfoClick = tokenSummary.onInfoClick,
modifier = Modifier
.fillMaxWidth(),
)
// Reserve space equal to the pinned button's full height
Spacer(modifier = Modifier.height(buttonHeight))
}
PrimaryButton(
text = stringResourceSafe(R.string.token_summary_go_to_swap_button),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } }
.navigationBarsPadding()
.padding(16.dp),
onClick = tokenSummary.onSwapClick,
)
}
}
@Composable
private fun EmptySentimentContent(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.token_summary_can_not_load_token),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
)
GradientScaleBar(
state = GradientScaleBarState.NoData,
modifier = Modifier.padding(vertical = 40.dp),
)
}
}
@Composable
private fun LoadingSentimentContent(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.token_summary_title),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerH8()
RectangleShimmer(
modifier = Modifier.size(width = 140.dp, height = 24.dp),
)
SpacerH8()
RectangleShimmer(
modifier = Modifier.size(width = 180.dp, height = 16.dp),
)
GradientScaleBar(
state = GradientScaleBarState.Loading,
modifier = Modifier.padding(vertical = 40.dp),
)
}
}
@Composable
private fun SentimentsContent(
tokenSentiment: TokenSentimentUM.Content,
aiInsight: AiInsightUM,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.token_summary_title),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.heading.small,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerH4()
Text(
text = tokenSentiment.sentiment.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.small,
)
SpacerH4()
Text(
text = tokenSentiment.lastUpdate.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
GradientScaleBar(
state = GradientScaleBarState.Content(value = tokenSentiment.totalScore),
modifier = Modifier.padding(vertical = 40.dp),
)
if (aiInsight is AiInsightUM.Displayed) SpacerH8()
AiInsightContent(
aiInsightUM = aiInsight,
modifier = Modifier.padding(bottom = 16.dp),
)
}
}
@Composable
private fun IndicatorsList(
indicators: ImmutableList<TokenIndicatorUM>,
onInfoClick: (IndicatorType) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
indicators.forEach { indicator ->
IndicatorRow(
indicator = indicator,
onInfoClick = { onInfoClick(indicator.indicatorType) },
)
}
}
}
@Composable
private fun IndicatorRow(indicator: TokenIndicatorUM, onInfoClick: () -> Unit, modifier: Modifier = Modifier) {
TangemRow(
modifier = modifier,
divider = true,
includeInnerPaddings = true,
contentLead = TangemRowContentLead.Equal,
verticalAlignment = TangemRowVerticalAlignment.Center,
titleSlot = {
Row(
modifier = Modifier
.clickableSingle(onClick = onInfoClick)
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = indicator.indicatorType.title,
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.caption.medium,
)
Icon(
modifier = Modifier.size(16.dp),
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.tertiary,
)
}
},
valueSlot = {
when (indicator) {
is TokenIndicatorUM.Content -> {
TangemBadge(badgeUM = indicator.scoreBadge)
TangemBadge(badgeUM = indicator.sentimentBadge)
}
is TokenIndicatorUM.Loading -> {
RectangleShimmer(
modifier = Modifier.size(width = 48.dp, height = 24.dp),
radius = 12.dp,
)
}
is TokenIndicatorUM.NoData -> {
TangemBadge(
badgeUM = TangemBadgeUM(
text = stringReference("None"), // TODO For You localization
size = TangemBadgeSize.X6,
color = TangemBadgeColor.Gray,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
)
}
}
},
)
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TokenSummaryContentPreview() {
TangemThemePreviewRedesign {
TokenSummaryContent(
tokenSummary = previewTokenSummary(
periodPickerUm = PeriodPickerUM.Content(
TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
),
tokenSentiment = previewContentSentiment,
),
contentPadding = PaddingValues.Zero,
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors3.bg.primary),
)
}
}
@Preview(name = "Loading · Light", showBackground = true, widthDp = 360)
@Preview(name = "Loading · Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TokenSummaryContentLoadingPreview() {
TangemThemePreviewRedesign {
TokenSummaryContent(
tokenSummary = previewTokenSummary(
periodPickerUm = PeriodPickerUM.Loading,
tokenSentiment = TokenSentimentUM.Loading,
),
contentPadding = PaddingValues.Zero,
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors3.bg.primary),
)
}
}
@Preview(name = "Empty · Light", showBackground = true, widthDp = 360)
@Preview(name = "Empty · Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TokenSummaryContentEmptyPreview() {
TangemThemePreviewRedesign {
TokenSummaryContent(
tokenSummary = previewTokenSummary(
periodPickerUm = PeriodPickerUM.Empty,
tokenSentiment = TokenSentimentUM.Empty,
),
contentPadding = PaddingValues.Zero,
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors3.bg.primary),
)
}
}
// endregion

View file

@ -0,0 +1,72 @@
package com.tangem.features.foryou.impl.tokensummary.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation.ContentAlign
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.foryou.impl.tokensummary.entity.InfoBottomSheetContent
/**
* Informational modal bottom sheet for the token summary screen.
*
* Renders the [infoBottomSheetContent]: a [title][InfoBottomSheetContent.title] with a trailing close (``) button in the top
* navigation, and a scrollable explanatory [body][InfoBottomSheetContent.body]. Visibility is driven by the hosting
* Decompose slot, so the config is always shown; [onDismiss] delegates back to the slot navigation.
*
* @param infoBottomSheetContent the info content to display.
* @param onDismiss invoked when the sheet is dismissed.
*/
@Composable
internal fun InfoBottomSheet(infoBottomSheetContent: InfoBottomSheetContent, onDismiss: () -> Unit) {
TangemBottomSheet<InfoBottomSheetContent>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = infoBottomSheetContent,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.primary,
title = { content ->
TangemTopNavigation(
windowInsets = WindowInsets(0),
blurBackground = false,
contentAlign = ContentAlign.Center,
endButton = { TangemButton.Close(onClick = onDismiss) },
contentColumn = {
Text(
text = content.title.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.body.medium,
)
},
)
},
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(all = 16.dp),
) {
Text(
text = content.body.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.subheading.medium,
)
}
},
)
}

View file

@ -0,0 +1,132 @@
package com.tangem.features.foryou.impl.tokensummary.ui.components
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.material3.Text
import androidx.compose.ui.text.style.TextOverflow
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.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
/**
* Top navigation ("Nav bar") for the token summary screen.
*
* Built on the redesigned [TangemTopNavigation]: a leading [currency icon][CurrencyIconState] in the start slot, a
* [title][TokenSummaryHeaderUM.title] over a [subtitle][TokenSummaryHeaderUM.subtitle] in the center slot, and a
* trailing close (``) button in the end slot. Title and subtitle are single-line and ellipsized on overflow.
*
* Hosted inside a modal bottom sheet, so [WindowInsets] is zeroed (no status-bar reservation) and the background blur
* is disabled.
*
* @param header content of the navigation bar the currency icon, title and subtitle to display.
* @param onCloseClick invoked when the trailing close button is tapped.
* @param modifier [Modifier] applied to the root navigation bar.
*/
@Composable
internal fun TokenSummaryTopNavigation(
header: TokenSummaryHeaderUM,
modifier: Modifier = Modifier,
onCloseClick: () -> Unit,
) {
TangemTopNavigation(
modifier = modifier,
windowInsets = WindowInsets(0),
blurBackground = false,
startButton = {
TangemIcon(
tangemIconUM = header.tangemIconUM,
modifier = Modifier.size(40.dp),
)
},
endButton = {
TangemButton.Close(
onClick = onCloseClick,
)
},
contentColumn = {
Text(
text = header.title.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (header.subtitle != null) {
Text(
text = header.subtitle.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TokenSummaryTopNavigationPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors3.bg.primary),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TokenSummaryTopNavigation(
header = previewHeader(
title = stringReference("Ethereum"),
subtitle = stringReference("ETH"),
),
onCloseClick = {},
)
TokenSummaryTopNavigation(
header = previewHeader(
title = stringReference("Ethereum"),
subtitle = null,
),
onCloseClick = {},
)
}
}
}
private fun previewHeader(title: TextReference, subtitle: TextReference?) = TokenSummaryHeaderUM(
tangemIconUM = TangemIconUM.Currency(
CurrencyIconState.CustomTokenIcon(
tint = TangemColorPalette.Black,
background = TangemColorPalette.Meadow,
topBadgeIconResId = R.drawable.img_polygon_22,
isGrayscale = false,
),
),
title = title,
subtitle = subtitle,
)
// endregion

View file

@ -0,0 +1,138 @@
package com.tangem.features.foryou.impl.tokensummary.ui.preivew
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.badge.TangemBadgeColor
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.badge.TangemBadgeUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
import kotlinx.collections.immutable.persistentListOf
internal fun previewTokenSummary(periodPickerUm: PeriodPickerUM, tokenSentiment: TokenSentimentUM) = TokenSummaryUm(
header = TokenSummaryHeaderUM(
tangemIconUM = TangemIconUM.Currency(
CurrencyIconState.CustomTokenIcon(
tint = TangemColorPalette.Black,
background = TangemColorPalette.Meadow,
topBadgeIconResId = R.drawable.img_polygon_22,
isGrayscale = false,
),
),
title = stringReference("Ethereum"),
subtitle = stringReference("ETH"),
),
tokenSentiment = tokenSentiment,
periodPicker = periodPickerUm,
aiInsight = AiInsightUM.Displayed(
"Your portfolio leans on a single asset BTC is 42% of holdings. Stablecoins add 23% " +
"buffer. Consider trimming concentration for a smoother ride",
),
onPeriodClick = {},
onCloseClick = {},
onSwapClick = {},
onInfoClick = {},
)
internal val previewContentSentiment = TokenSentimentUM.Content(
sentiment = stringReference("Negative outlook"),
lastUpdate = stringReference("Updated Jan 20 2026, 9:24 PM"),
totalScore = -4,
indicators = persistentListOf(
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Neutral"),
color = TangemBadgeColor.Blue,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.GalaxyScore,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Positive"),
color = TangemBadgeColor.Green,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.Sentiment,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Negative"),
color = TangemBadgeColor.Red,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.RSI,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Negative"),
color = TangemBadgeColor.Red,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.MACD,
),
TokenIndicatorUM.Content(
sentimentBadge = TangemBadgeUM(
text = stringReference("Negative"),
color = TangemBadgeColor.Red,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
scoreBadge = TangemBadgeUM(
text = stringReference("72.21"),
color = TangemBadgeColor.Gray,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
shape = TangemBadgeShape.Rounded,
),
indicatorType = IndicatorType.MA_CROSS,
),
),
)

View file

@ -0,0 +1,118 @@
package com.tangem.features.foryou.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
internal fun ForYouContent(
forYouUM: ForYouUM,
bottomSheetState: State<BottomSheetState>,
promoBannersBlockComponent: PromoBannersBlockComponent,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
LaunchedEffect(bottomSheetState, promoBannersBlockComponent) {
snapshotFlow { bottomSheetState.value == BottomSheetState.EXPANDED }
.distinctUntilChanged()
.collect(promoBannersBlockComponent::setVisibleOnScreen)
}
val background = LocalMainBottomSheetColor.current
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = contentPadding.calculateTopPadding())
.drawBehind { drawRect(background.value) },
) {
promoBannersBlockComponent.ContentWithPadding(
modifier = Modifier.padding(top = 12.dp),
walletId = null,
horizontalItemPadding = 16.dp,
)
forYouUM.notifications.fastForEachIndexed { index, notification ->
key(notification.state) {
TangemMessageBanner(
state = notification.state,
modifier = Modifier
.padding(horizontal = 16.dp)
.conditional(index == 0) {
padding(top = 12.dp)
}
.conditional(index == forYouUM.notifications.lastIndex) {
padding(bottom = 48.dp)
},
)
}
}
ForYouPortfolioReview(
portfolioReviewUM = forYouUM.portfolioReviewUM,
modifier = Modifier.padding(horizontal = 16.dp),
)
SpacerH(48.dp)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun ForYouContent_Preview(@PreviewParameter(ForYouContentPreviewProvider::class) params: ForYouUM) {
TangemThemePreviewRedesign {
ForYouContent(
forYouUM = params,
bottomSheetState = remember { mutableStateOf(BottomSheetState.EXPANDED) },
promoBannersBlockComponent = object : PromoBannersBlockComponent {
@Composable
override fun ContentWithPadding(horizontalItemPadding: Dp, walletId: String?, modifier: Modifier) {
}
override fun setVisibleOnScreen(isVisible: Boolean) {}
},
contentPadding = PaddingValues.Zero,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
}
private class ForYouContentPreviewProvider : PreviewParameterProvider<ForYouUM> {
override val values: Sequence<ForYouUM>
get() = sequenceOf(
ForYouUM(
notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
),
)
}
// endregion

View file

@ -0,0 +1,121 @@
package com.tangem.features.foryou.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_down_16
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.MarketChart
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.ui.components.ForYouPortfolioTokenList
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(R.string.for_you_portfolio_review_title),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
)
TangemBadge(
text = stringReference("All accounts"), // TODO For You
variant = TangemBadge.Variant.Solid,
size = TangemBadge.Size.X9,
iconEnd = TangemIconUM.Icon(Icons.ic_chevron_down_16),
)
}
SpacerH(16.dp)
MarketChart(
marketChart = portfolioReviewUM.marketChartUM,
modifier = Modifier.fillMaxWidth(),
)
SpacerH(8.dp)
when (portfolioReviewUM) {
is PortfolioReviewUM.Content -> {
TangemSegmentedPicker(
tangemSegmentedPickerUM = portfolioReviewUM.periodPickerUM,
onClick = portfolioReviewUM.onPeriodClick,
)
}
is PortfolioReviewUM.Loading -> TangemShimmer(
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
radius = 100.dp,
)
}
ForYouPortfolioTokenList(tokenList = portfolioReviewUM.tokenList)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun ForYouPortfolioReview_Review(
@PreviewParameter(ForYouPortfolioReviewPreviewProvider::class) params: PortfolioReviewUM,
) {
TangemThemePreviewRedesign {
ForYouPortfolioReview(
portfolioReviewUM = params,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
}
private class ForYouPortfolioReviewPreviewProvider : PreviewParameterProvider<PortfolioReviewUM> {
override val values: Sequence<PortfolioReviewUM>
get() = sequenceOf(
ForYouPortfolioReviewPreviewData.reviewContent,
PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList {
repeat(4) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
id = index.toString(),
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
)
}
}.toPersistentList(),
),
)
}
// endregion

View file

@ -0,0 +1,84 @@
package com.tangem.features.foryou.impl.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.CanvasGradientDivider
import com.tangem.features.foryou.impl.components.state.AiInsightUM
@Suppress("ModifierHeightWithText")
@Composable
internal fun AiInsightContent(aiInsightUM: AiInsightUM, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = aiInsightUM,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
) { currentState ->
when (currentState) {
is AiInsightUM.AskAiInsight -> {
SecondaryTangemButton(
modifier = modifier
.fillMaxWidth(),
onClick = currentState.askAiInsightClick,
size = TangemButtonSize.X9,
text = resourceReference(R.string.market_chart_ask_for_ai_summary_button),
)
}
is AiInsightUM.Displayed -> {
Row(
modifier = modifier
.height(IntrinsicSize.Min),
) {
CanvasGradientDivider(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 2.dp),
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp),
text = buildAnnotatedString {
withStyle(
SpanStyle(
brush = Brush.horizontalGradient(
listOf(
TangemTheme.colors3.icon.accent.violet,
TangemTheme.colors3.icon.accent.blue,
),
),
alpha = 1f,
),
) { append(stringResourceSafe(R.string.market_chart_ai_total)) }
append(" ")
append(currentState.text)
},
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
}
}
AiInsightUM.Hide -> {}
}
}
}

View file

@ -0,0 +1,363 @@
package com.tangem.features.foryou.impl.ui.components
import androidx.compose.animation.*
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.lerp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.common.ui.tokens.SlideInItemVisibility
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.toBoxSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRow
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_collapse_20
import com.tangem.core.ui.utils.ProvideSharedTransitionScope
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.core.ui.utils.sharedBoundsSafely
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun ForYouPortfolioTokenList(tokenList: ImmutableList<ForYouTokenListItemUM>, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
val outerLastIndex = tokenList.lastIndex
tokenList.fastForEachIndexed { index, listItem ->
key(listItem.tokenRowUM.id) {
PortfolioTokenItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex)
}
}
}
}
@Composable
private fun PortfolioTokenItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) {
PortfolioAssetItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex)
val lastIndex = listItem.tokenList.lastIndex.inc()
listItem.tokenList.fastForEachIndexed { tokenIndex, item ->
SlideInItemVisibility(
currentIndex = tokenIndex + 1,
lastIndex = lastIndex,
modifier = Modifier
.roundedShapeItemDecoration(
radius = 24.dp,
currentIndex = tokenIndex + 1,
addDefaultPadding = false,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors3.bg.secondary,
),
visible = listItem.isExpanded,
) {
val itemModifier = Modifier
.semantics { lazyListItemPosition = tokenIndex + 1 }
var position by remember { mutableStateOf(Offset.Zero) }
TangemTokenRow(
tokenRowUM = item,
isBalanceHidden = false, // TODO For You
modifier = itemModifier
.onGloballyPositioned {
position = it.positionInWindow()
}
.conditionalCompose(item.onItemClick != null) {
clickable(onClick = requireNotNull(item.onItemClick))
},
)
}
}
}
@Suppress("MagicNumber")
@Composable
private fun PortfolioAssetItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) {
val itemBackgroundColor = TangemTheme.colors3.bg.secondary
ProvideSharedTransitionScope(
modifier = Modifier
.padding(top = 8.dp)
.semantics { lazyListItemPosition = index }
.roundedShapeItemDecoration(
currentIndex = 0,
radius = 24.dp,
addDefaultPadding = false,
lastIndex = portfolioAssetExpandAnimation(listItem = listItem, outerLastIndex = outerLastIndex).value,
backgroundColor = itemBackgroundColor,
),
) {
val iconSharedContentState = rememberSharedContentState(key = "icon_${listItem.tokenRowUM.id}")
val titleSharedContentState = rememberSharedContentState(key = "title_${listItem.tokenRowUM.id}")
val boundsTransform = BoundsTransform { _, _ -> tween(250) }
AnimatedContent(
targetState = listItem.isExpanded,
transitionSpec = { portfolioAssetExpandFadeAnimation() },
) { isExpandedWrapped ->
val composables = remember(isExpandedWrapped) {
SharedTokenRowComposables(
icon = { modifier ->
PortfolioSharedAssetIcon(
listItem = listItem,
isExpandedWrapped = isExpandedWrapped,
itemBackgroundColor = itemBackgroundColor,
modifier = modifier.sharedBoundsSafely(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = this,
boundsTransform = boundsTransform,
),
)
},
title = { modifier ->
PortfolioSharedAssetTitle(
listItem = listItem,
isExpandedWrapped = isExpandedWrapped,
modifier = modifier.sharedBoundsSafely(
sharedContentState = titleSharedContentState,
animatedVisibilityScope = this,
boundsTransform = boundsTransform,
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
),
)
},
)
}
if (isExpandedWrapped) {
ForYouPortfolioListHeader(
tokenRowUM = listItem.tokenRowUM,
headComponent = composables.icon,
titleComponent = composables.title,
)
} else {
TangemTokenRow(
tokenRowUM = listItem.tokenRowUM,
headComponent = composables.icon,
titleComponent = composables.title,
isBalanceHidden = false, // todo For You
)
}
}
}
}
@Composable
private fun PortfolioSharedAssetIcon(
listItem: ForYouTokenListItemUM,
isExpandedWrapped: Boolean,
itemBackgroundColor: Color,
modifier: Modifier = Modifier,
) {
val headIcon = listItem.tokenRowUM.headIconUM
if (headIcon is TangemIconUM.Currency) {
val size = if (isExpandedWrapped) {
AccountIconSize.RedesignExtraSmall
} else {
AccountIconSize.RedesignedDefault
}
val currencyIconState = when (val currencyIconState = headIcon.currencyIconState) {
is CurrencyIconState.CryptoPortfolio.Icon -> currencyIconState.copy(size = size)
is CurrencyIconState.CryptoPortfolio.Letter -> currencyIconState.copy(size = size)
else -> currencyIconState
}
TangemCurrencyIcon(
state = currencyIconState,
shouldDisplayNetwork = false,
modifier = modifier
.size(size.toBoxSize())
// TODO For You replace with DC components
.drawWithContent {
drawContent()
if (!isExpandedWrapped) {
val offset = 34.dp.toPx()
drawBadge(
color = Color.Red,
containerColor = itemBackgroundColor,
offset = Offset(
x = offset,
y = offset,
),
size = 3.dp,
padding = 1.dp,
)
}
},
)
}
}
@Composable
private fun PortfolioSharedAssetTitle(
listItem: ForYouTokenListItemUM,
isExpandedWrapped: Boolean,
modifier: Modifier = Modifier,
) {
val targetAnimationFraction = if (isExpandedWrapped) 0f else 1f
val animationFraction = animateFloatAsState(
targetValue = targetAnimationFraction,
animationSpec = tween(durationMillis = 350),
)
val startStyle = TangemTheme.typography3.subheading.medium
val stopStyle = TangemTheme.typography3.body.medium
val textStyle by remember(animationFraction.value) {
derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) }
}
val resizedTitle = when (val titleUM = listItem.tokenRowUM.titleUM) {
is TangemTokenRowUM.TitleUM.Content -> titleUM.copy(
text = styledStringReference(
titleUM.text.resolveReference(),
{ textStyle.toSpanStyle() },
),
)
else -> titleUM
}
TokenRowTitle(
titleUM = if (isExpandedWrapped) {
(resizedTitle as? TangemTokenRowUM.TitleUM.Content)?.copy(badge = null) ?: resizedTitle
} else {
resizedTitle
},
modifier = modifier,
)
}
@Composable
private fun ForYouPortfolioListHeader(
tokenRowUM: TangemTokenRowUM,
headComponent: @Composable (Modifier) -> Unit,
titleComponent: @Composable (Modifier) -> Unit,
) {
TangemRow(
modifier = Modifier.background(TangemTheme.colors3.bg.secondary),
divider = true,
onClick = tokenRowUM.onItemClick,
verticalAlignment = TangemRowVerticalAlignment.Center,
startSlot = {
headComponent(Modifier)
},
titleSlot = {
titleComponent(Modifier)
val topEndUM = tokenRowUM.topEndContentUM
val bottomEndUM = tokenRowUM.bottomEndContentUM
when {
topEndUM is TangemTokenRowUM.EndContentUM.Content &&
bottomEndUM is TangemTokenRowUM.EndContentUM.Content -> {
Text(
text = annotatedReference {
appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary)
appendSpace()
append(topEndUM.text.resolveReference())
appendSpace()
appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary)
appendSpace()
appendColored(bottomEndUM.text.resolveReference(), TangemTheme.colors3.text.secondary)
}.resolveAnnotatedReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
topEndUM is TangemTokenRowUM.EndContentUM.Loading ||
bottomEndUM is TangemTokenRowUM.EndContentUM.Loading -> {
TextShimmer(style = TangemTheme.typography3.subheading.medium)
}
else -> Unit
}
SpacerWMax()
Icon(
imageVector = Icons.ic_chevron_collapse_20,
tint = TangemTheme.colors3.icon.primary,
contentDescription = null,
)
},
)
}
private fun DrawScope.drawBadge(
containerColor: Color,
color: Color = TangemColorPalette.Azure,
offset: Offset,
size: Dp = 5.dp,
padding: Dp = 2.dp,
) {
drawCircle(
color = containerColor,
center = offset,
radius = size.toPx(),
)
drawCircle(
color = color,
center = offset,
radius = (size - padding).toPx(),
)
}
@Suppress("MagicNumber")
@Composable
private fun portfolioAssetExpandAnimation(listItem: ForYouTokenListItemUM, outerLastIndex: Int): State<Int> {
// Snap immediately on expand; on collapse, hold the current value until all
// child items finish their shrink animation, then snap to fully-rounded shape.
return animateIntAsState(
targetValue = if (listItem.isExpanded) outerLastIndex else 0,
animationSpec = if (listItem.isExpanded) {
snap()
} else {
snap(delayMillis = minOf(50 * maxOf(listItem.tokenList.lastIndex, 0), 250) + 150)
},
label = "lastIndex",
)
}
@Suppress("MagicNumber")
private fun portfolioAssetExpandFadeAnimation(): ContentTransform {
return fadeIn(animationSpec = tween(350, delayMillis = 90))
.togetherWith(fadeOut(animationSpec = tween(350)))
}
@Stable
internal class SharedTokenRowComposables(
val title: @Composable (Modifier) -> Unit,
val icon: @Composable (Modifier) -> Unit,
)

View file

@ -0,0 +1,181 @@
package com.tangem.features.foryou.impl.ui.components
import android.content.res.Configuration
import androidx.annotation.IntRange
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.dropShadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlin.math.roundToInt
/**
* Visual state of the [GradientScaleBar].
*/
internal sealed interface GradientScaleBarState {
/**
* Loaded state renders the horizontal error info success gradient track with a circular
* indicator snapped to [value].
*
* @param value current value to point at; coerced into [range].
* @param range inclusive range of selectable values; its size defines the number of positions
* (default [DEFAULT_RANGE] = `-5..5`, i.e. 11 positions).
*/
data class Content(
@param:IntRange(from = -5, to = 5) val value: Int,
val range: kotlin.ranges.IntRange = DEFAULT_RANGE,
) : GradientScaleBarState
/** Loading state — renders an animated shimmer placeholder sized to the track. */
data object Loading : GradientScaleBarState
/** No-data state — renders a static disabled track. */
data object NoData : GradientScaleBarState
}
/**
* Horizontal gradient scale bar with a round indicator snapped to a value on the scale.
*
* Renders one of three variants depending on [state]:
* - [GradientScaleBarState.Content] the error info success gradient track with the circular
* indicator snapped to one of the evenly-spaced positions defined by its range (e.g. the default
* `-5..5` yields 11 positions). The indicator never overflows the track: its center travels from
* the left edge (`range.first`) to the right edge (`range.last`).
* - [GradientScaleBarState.Loading] an animated shimmer placeholder.
* - [GradientScaleBarState.NoData] a static disabled track.
*
* All variants share the same track geometry ([TRACK_HEIGHT], [TRACK_CORNER]) and occupy the same
* vertical space ([INDICATOR_SIZE]), so switching between states does not shift the layout.
*
* @param state visual state to render.
* @param modifier the [Modifier] to be applied to the component. Width is taken from the incoming
* constraints (defaults to intrinsic content otherwise) pass `Modifier.fillMaxWidth()` to stretch.
*/
@Composable
internal fun GradientScaleBar(state: GradientScaleBarState, modifier: Modifier = Modifier) {
BoxWithConstraints(
modifier = modifier
.padding(vertical = 5.dp)
.height(INDICATOR_SIZE),
) {
when (state) {
is GradientScaleBarState.Content -> ContentBar(state = state)
GradientScaleBarState.Loading -> RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TRACK_HEIGHT)
.align(Alignment.Center),
radius = TRACK_CORNER,
)
GradientScaleBarState.NoData -> Box(
modifier = Modifier
.fillMaxWidth()
.height(TRACK_HEIGHT)
.align(Alignment.Center)
.background(TangemTheme.colors3.bg.disabled, RoundedCornerShape(TRACK_CORNER)),
)
}
}
}
@Composable
private fun BoxWithConstraintsScope.ContentBar(state: GradientScaleBarState.Content) {
val trackBrush = Brush.horizontalGradient(
colors = listOf(
TangemTheme.colors3.bg.status.error,
TangemTheme.colors3.bg.status.info,
TangemTheme.colors3.bg.status.success,
),
)
val indicatorColor = TangemTheme.colors3.icon.primary
// Track — centered vertically, thinner than the indicator.
Box(
modifier = Modifier
.fillMaxWidth()
.height(TRACK_HEIGHT)
.offset(y = (INDICATOR_SIZE - TRACK_HEIGHT) / 2)
.background(trackBrush, RoundedCornerShape(TRACK_CORNER))
.clip(RoundedCornerShape(TRACK_CORNER)),
)
// Indicator — snapped to one of the positions defined by the range.
val range = state.range
val steps = range.last - range.first + 1
val fraction = if (steps <= 1) {
0f
} else {
(state.value.coerceIn(range) - range.first).toFloat() / (steps - 1)
}
Box(
modifier = Modifier
.offset {
val travel = maxWidth.toPx() - INDICATOR_SIZE.toPx()
IntOffset(x = (fraction * travel).roundToInt(), y = 0)
}
.size(INDICATOR_SIZE)
.dropShadow(
shape = CircleShape,
shadow = Shadow(
radius = 4.dp, // TODO
spread = 0.dp,
color = Color.Black.copy(alpha = 0.25f),
),
)
.clip(CircleShape)
.background(indicatorColor),
)
}
private val DEFAULT_RANGE = -5..5
private val INDICATOR_SIZE = 10.dp
private val TRACK_HEIGHT = 6.dp
private val TRACK_CORNER = 10.dp
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun GradientScaleBar_Preview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
GradientScaleBar(
state = GradientScaleBarState.Content(value = -5),
modifier = Modifier.fillMaxWidth(),
)
GradientScaleBar(
state = GradientScaleBarState.Loading,
modifier = Modifier.fillMaxWidth(),
)
GradientScaleBar(
state = GradientScaleBarState.NoData,
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View file

@ -0,0 +1,88 @@
package com.tangem.features.foryou.impl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
/**
* Horizontal wallet pill-tab strip for the For You screen.
*
* Faithful replica of the `WalletTabItem` / `walletListItem` reference in
* `features/common-features/impl/.../choosetoken/ui/ChooseTokenScreen.kt` (which is `private`),
* adapted to a standalone composable (For You renders inside a plain scrollable Column, not a LazyListScope).
*/
@Composable
internal fun WalletTabsBlock(walletList: WalletListUM, modifier: Modifier = Modifier) {
if (walletList.items.isEmpty()) return
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(walletList.items) { um ->
WalletTabItem(um)
}
}
}
@Composable
private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
val isSelected = state.isSelected
val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary
val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1
val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary
val countBackground = if (isSelected) {
TangemTheme.colors.button.secondary.copy(alpha = 0.2f)
} else {
TangemTheme.colors.button.primary.copy(alpha = 0.1f)
}
Row(
modifier = modifier
.clip(RoundedCornerShape(percent = 50))
.background(backgroundColor)
.clickable(onClick = state.onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = state.text.resolveReference(),
color = buttonTextColor,
style = TangemTheme.typography2.bodySemibold16,
)
val count = state.count
if (count != null) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.background(countBackground, shape = CircleShape)
.defaultMinSize(minWidth = 20.dp)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = count.resolveReference(),
color = countTextColor,
style = TangemTheme.typography.caption1,
)
}
}
}
}

View file

@ -0,0 +1,168 @@
package com.tangem.features.foryou.impl.ui.preview
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.badge.TangemBadgeColor
import com.tangem.core.ui.ds.badge.TangemBadgeSize
import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
internal object ForYouPortfolioReviewPreviewData {
val reviewContent = PortfolioReviewUM.Content(
periodPickerUM = TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
marketChartUM = MarketChartUM.Loaded(
donutChart = DonutChartUM.Loaded(
totalAmount = "10000$",
// Colours are assigned in segment order (rank), matching the transformer's palette-by-index.
donutSegmentList = persistentListOf(
DonutSegmentUM(
color = DonutSegmentColor.Brand,
weight = BigDecimal("0.55"),
title = stringReference("Ethereum"),
fiatValue = stringReference("\$5,720.22"),
),
DonutSegmentUM(
color = DonutSegmentColor.Green,
weight = BigDecimal("0.45"),
title = stringReference("Solana"),
fiatValue = stringReference("\$728.30"),
),
),
),
topHoldingPercent = stringReference("Top holding 42%"),
),
tokenList = persistentListOf(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = "network_0",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("USDC"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("2 networks"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$5,479"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("54,8%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
tokenList = persistentListOf(
TangemTokenRowUM.Content(
id = "network_0_token_0",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("USDC"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("Solana $DOT 3,479 USDC"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$3,479"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("34,7%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
TangemTokenRowUM.Content(
id = "network_0_token_1",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("USDC"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("Ethereum $DOT 2,000 USDC"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$2,000"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("20,1%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
),
isExpanded = true,
isExpandable = true,
),
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
id = "network_1",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("Bitcoin"),
badge = TangemBadgeUM(
text = stringReference("Positive"),
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Green,
),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("Main network"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("\$849"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("8,49%"),
),
onItemClick = {},
onItemLongClick = { _, _ -> },
),
tokenList = persistentListOf(),
isExpanded = false,
isExpandable = false,
),
),
)
}

View file

@ -0,0 +1,142 @@
package com.tangem.features.foryou.impl.components
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
internal class DonutSegmentSweepsTest {
@Test
fun `GIVEN empty weights WHEN visualSweepAngles THEN returns empty`() {
// Act
val actual = visualSweepAngles(emptyList())
// Assert
assertThat(actual).isEmpty()
}
@Test
fun `GIVEN all zero weights WHEN visualSweepAngles THEN all zero and size preserved`() {
// Act
val actual = visualSweepAngles(listOf(0f, 0f, 0f))
// Assert
assertThat(actual).containsExactly(0f, 0f, 0f).inOrder()
}
@Test
fun `GIVEN all segments above floor WHEN visualSweepAngles THEN sweeps stay proportional to weight`() {
// Arrange — 0.5 / 0.3 / 0.2, none below 5%.
val weights = listOf(0.5f, 0.3f, 0.2f)
// Act
val actual = visualSweepAngles(weights)
// Assert — untouched: weight * 360.
assertThat(actual[0]).isWithin(TOLERANCE).of(180f)
assertThat(actual[1]).isWithin(TOLERANCE).of(108f)
assertThat(actual[2]).isWithin(TOLERANCE).of(72f)
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a segment below floor WHEN visualSweepAngles THEN it is raised to the floor and larger ones shrink`() {
// Arrange — only 0.05 is below the floor; filled sum is the whole circle.
val weights = listOf(0.8f, 0.15f, 0.05f)
// Act
val actual = visualSweepAngles(weights)
// Assert — the tiny slice is floored, the rest shrink to keep the sum at 360°.
assertThat(actual[2]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
// Proportion between the two large slices is preserved (288 / 54 == actual[0] / actual[1]).
assertThat(actual[0] / actual[1]).isWithin(TOLERANCE).of(288f / 54f)
}
@Test
fun `GIVEN zero-weight slices among real ones WHEN visualSweepAngles THEN zeros stay zero`() {
// Arrange — a 0f slice sits between real ones.
val weights = listOf(0.9f, 0f, 0.08f, 0.02f)
// Act
val actual = visualSweepAngles(weights)
// Assert
assertThat(actual[1]).isEqualTo(0f)
assertThat(actual[3]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a single tiny segment WHEN visualSweepAngles THEN it grows into the track up to the floor`() {
// Arrange — 2% with no larger slice to borrow from; it must grow into the unfilled track.
val weights = listOf(0.02f)
// Act
val actual = visualSweepAngles(weights)
// Assert
assertThat(actual[0]).isWithin(TOLERANCE).of(FLOOR_DEG)
}
@Test
fun `GIVEN filled sum below the full circle and floors fit WHEN visualSweepAngles THEN filled sum preserved`() {
// Arrange — segments sum to 0.5 of the circle; the 0.03 slice is below the floor.
val weights = listOf(0.4f, 0.07f, 0.03f)
val filledSum = (0.4f + 0.07f + 0.03f) * 360f
// Act
val actual = visualSweepAngles(weights)
// Assert — small one floored, total filled sweep (track remainder) unchanged.
assertThat(actual[2]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual.sum()).isWithin(TOLERANCE).of(filledSum)
}
@Test
fun `GIVEN more segments than the floor allows WHEN visualSweepAngles THEN falls back to an equal split`() {
// Arrange — 25 equal slices; 25 floors would overflow 360°, so the floor drops to 360/25.
val weights = List(25) { 0.04f }
// Act
val actual = visualSweepAngles(weights)
// Assert
actual.forEach { assertThat(it).isWithin(TOLERANCE).of(360f / 25f) }
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a full ring and capDeg WHEN visualSweepAngles THEN only the last floored slice is bumped`() {
// Arrange — two tiny slices below the floor on a full ring; index 2 is the last active.
val weights = listOf(0.9f, 0.05f, 0.05f)
// Act
val actual = visualSweepAngles(weights, capDeg = CAP_DEG)
// Assert — the non-last floored slice sits at the plain floor, the last one is bumped above it.
assertThat(actual[1]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual[2]).isGreaterThan(actual[1])
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a gap wider than capDeg WHEN visualSweepAngles THEN the last slice is not bumped`() {
// Arrange — filled sum well below the circle, so the gap far exceeds capDeg.
val weights = listOf(0.4f, 0.05f)
// Act
val actual = visualSweepAngles(weights, capDeg = CAP_DEG)
// Assert — no compensation: the last floored slice stays at the plain floor.
assertThat(actual[1]).isWithin(TOLERANCE).of(FLOOR_DEG)
}
private companion object {
const val TOLERANCE = 0.01f
const val CAP_DEG = 12f
// Derived from the production constant so these tests track it instead of hardcoding the angle.
const val FLOOR_DEG = MIN_VISUAL_SWEEP_FRACTION * 360f
}
}

View file

@ -0,0 +1,273 @@
package com.tangem.features.foryou.impl.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class ForYouModelTest {
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk()
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private var model: ForYouModel? = null
@BeforeEach
fun setup() {
// Default: a real, non-empty emission so the model's `getOrElse { Default }` mapping path is
// actually exercised in every test, not bypassed by an empty flow.
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
}
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Nested
inner class InitialState {
@Test
fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading with skeleton rows`() = runTest {
// Arrange
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf())
// Act
val model = createModel(testScope = this)
// Assert — before advancing, the model exposes skeleton placeholder rows
val loading = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Loading
assertThat(loading.tokenList).hasSize(4)
assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue()
assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData)
}
}
@Nested
inner class ContentState {
@Test
fun `GIVEN selected wallet and statuses emitted WHEN advanced THEN uiState becomes Content`() = runTest {
// Arrange
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
stubSelectedWallet(
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert
val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(content.tokenList.map { it.tokenRowUM.id }).containsExactly("btc")
assertThat(content.marketChartUM).isInstanceOf(MarketChartUM.Loaded::class.java)
assertThat(model.uiState.value.notifications).isEmpty()
}
@Test
fun `GIVEN total balance from outdated source WHEN advanced THEN outdated-data notification is shown`() =
runTest {
// Arrange
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
stubSelectedWallet(
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
source = StatusSource.ONLY_CACHE,
)
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert
assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData)
}
}
@Nested
inner class ExpandClick {
@Test
fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest {
// Arrange
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
stubSelectedWallet(
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
val model = createModel(testScope = this)
advanceUntilIdle()
val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(initialContent.tokenList.single().isExpanded).isFalse()
// Act — click once to expand
initialContent.assetRow().onItemClick?.invoke()
advanceUntilIdle()
// Assert
val expandedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(expandedContent.tokenList.single().isExpanded).isTrue()
// Act — click again to collapse
expandedContent.assetRow().onItemClick?.invoke()
advanceUntilIdle()
// Assert
val collapsedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(collapsedContent.tokenList.single().isExpanded).isFalse()
}
}
@Nested
inner class PeriodClick {
@Test
fun `GIVEN Content state WHEN period clicked THEN initialSelectedItem updates without resetting rest`() =
runTest {
// Arrange
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
stubSelectedWallet(
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
val model = createModel(testScope = this)
advanceUntilIdle()
val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
val weekItem = contentBefore.periodPickerUM.items[1]
// Act
contentBefore.onPeriodClick(weekItem)
// Assert
val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList)
}
}
private fun PortfolioReviewUM.Content.assetRow(): TangemTokenRowUM.Content =
tokenList.single().tokenRowUM as TangemTokenRowUM.Content
/** Wires the repository + supplier so the model derives Content from a single selected wallet. */
private fun stubSelectedWallet(
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
source: StatusSource = StatusSource.ACTUAL,
) {
val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"))
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
linkedMapOf(
wallet.walletId to createAccountStatusList(currencies, totalFiatBalance, source),
),
)
}
private fun createModel(testScope: TestScope): ForYouModel {
return ForYouModel(
paramsContainer = MutableParamsContainer(
ForYouComponent.Params(
callbacks = object : ForYouComponent.ForYouModelCallbacks {
override fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) = Unit
},
),
),
userWalletsListRepository = userWalletsListRepository,
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private fun createAccountStatusList(
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
source: StatusSource = StatusSource.ACTUAL,
): AccountStatusList = mockk {
every { flattenCurrencies() } returns currencies
every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded(
amount = totalFiatBalance,
source = source,
)
}
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
every { amount } returns BigDecimal.ONE
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
every { sources } returns CryptoCurrencyStatus.Sources()
}
private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
val network: Network = mockk {
every { name } returns "Network"
every { isTestnet } returns false
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
}
val currencyId: CryptoCurrency.ID = mockk {
every { value } returns "coin-$rawCurrencyId"
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
}
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.name } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}

View file

@ -0,0 +1,120 @@
package com.tangem.features.foryou.impl.model.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouPortfolioFormattersTest {
@Nested
inner class ForYouGroupKey {
@Test
fun `GIVEN standard currency with raw id WHEN forYouGroupKey THEN returns rawCurrencyId value`() {
// Arrange
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin")
every { value } returns "coin-id-value"
}
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
val status = createStatus(currency)
// Act
val result = status.forYouGroupKey()
// Assert
assertThat(result).isEqualTo("bitcoin")
}
@Test
fun `GIVEN custom token with no raw id WHEN forYouGroupKey THEN falls back to currency id value`() {
// Arrange
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns null
every { value } returns "custom-currency-id"
}
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
val status = createStatus(currency)
// Act
val result = status.forYouGroupKey()
// Assert
assertThat(result).isEqualTo("custom-currency-id")
}
private fun createStatus(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Loading,
)
}
@Nested
inner class ToForYouPercent {
@Test
fun `GIVEN null amount WHEN toForYouPercent THEN returns null`() {
// Arrange
val amount: BigDecimal? = null
// Act
val result = amount.toForYouPercent(BigDecimal("100"))
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN zero total WHEN toForYouPercent THEN returns null`() {
// Arrange
val amount = BigDecimal("10")
// Act
val result = amount.toForYouPercent(BigDecimal.ZERO)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN zero amount WHEN toForYouPercent THEN returns null`() {
// Arrange
val amount = BigDecimal.ZERO
// Act
val result = amount.toForYouPercent(BigDecimal("100"))
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN non-zero amount and total WHEN toForYouPercent THEN returns the share as a ratio`() {
// Arrange — 50.00 / 200 = 0.25 (ratio, scaled to the amount's scale)
val amount = BigDecimal("50.00")
// Act
val result = amount.toForYouPercent(BigDecimal("200"))
// Assert
assertThat(result).isEqualTo(BigDecimal("0.25"))
}
@Test
fun `GIVEN a share requiring rounding WHEN toForYouPercent THEN applies HALF_UP rounding`() {
// Arrange — 1.0000 / 3 = 0.3333... rounds HALF_UP to the amount's scale (4)
val amount = BigDecimal("1.0000")
// Act
val result = amount.toForYouPercent(BigDecimal("3"))
// Assert
assertThat(result).isEqualTo(BigDecimal("0.3333"))
}
}
}

View file

@ -0,0 +1,295 @@
package com.tangem.features.foryou.impl.model.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.foryou.impl.R
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouTokenListConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class Convert {
@Test
fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
// Act
val result = converter.convert(listOf(status))
// Assert
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network))
}
@Test
fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() {
// Arrange
val currency = createToken(
rawCurrencyId = "usdc",
symbol = "USDC",
networkId = "ethereum",
standardTypeName = "ERC20",
)
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
// Act
val result = converter.convert(listOf(status))
// Assert
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(stringReference("ERC20"))
}
@Test
fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count`() {
// Arrange — same asset (shared rawCurrencyId) on two different networks
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200")))
val converter = createConverter(
totalFiatBalance = BigDecimal("300"),
)
// Act
val result = converter.convert(listOf(statusEth, statusSol))
// Assert
val item = result.single()
val row = item.tokenRowUM as TangemTokenRowUM.Content
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2))
assertThat(item.tokenList).hasSize(2)
}
@Test
fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() {
// Arrange
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500")))
val converter = createConverter(
totalFiatBalance = BigDecimal("600"),
)
// Act
val result = converter.convert(listOf(statusEth, statusSol))
// Assert — Solana holding (500) ranks above Ethereum holding (100)
val childIds = result.single().tokenList.map { it.id }
assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder()
}
@Test
fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, CryptoCurrencyStatus.Loading)
val converter = createConverter(totalFiatBalance = BigDecimal.ZERO)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java)
}
@Test
fun `GIVEN no other assets WHEN convert THEN no Other row is appended`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(
totalFiatBalance = BigDecimal("100"),
otherAssets = emptyList(),
)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result).hasSize(1)
}
@Test
fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(
totalFiatBalance = BigDecimal("100"),
otherAssets = listOf(otherAsset(BigDecimal("50"))),
)
// Act
val result = converter.convert(listOf(status))
// Assert
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
assertThat(otherRow.id).isEqualTo("for_you_other_assets")
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 1))
}
@Test
fun `GIVEN more than one other asset WHEN convert THEN Other row subtitle is plural`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(
totalFiatBalance = BigDecimal("100"),
otherAssets = listOf(
otherAsset(BigDecimal("30")),
otherAsset(BigDecimal("15")),
otherAsset(BigDecimal("5")),
),
)
// Act
val result = converter.convert(listOf(status))
// Assert
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 3))
}
@Test
fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(
totalFiatBalance = BigDecimal("100"),
expandedAssetIds = setOf("bitcoin"),
)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result.single().isExpanded).isTrue()
}
@Test
fun `GIVEN asset id not in expandedAssetIds WHEN convert THEN item isExpanded is false`() {
// Arrange
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
val converter = createConverter(
totalFiatBalance = BigDecimal("100"),
expandedAssetIds = emptySet(),
)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result.single().isExpanded).isFalse()
}
}
private fun createConverter(
totalFiatBalance: BigDecimal,
expandedAssetIds: Set<String> = emptySet(),
otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>> = emptyList(),
): ForYouTokenListConverter = ForYouTokenListConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
expandedAssetIds = expandedAssetIds,
expandClick = {},
otherAssets = otherAssets,
onTokenClick = {},
)
/**
* Builds an "other" asset entry only its summed [balance] and the number of entries drive the
* collapsed "Other" row, so the currency list is left empty.
*/
private fun otherAsset(balance: BigDecimal): Pair<List<CryptoCurrencyStatus>, BigDecimal> =
emptyList<CryptoCurrencyStatus>() to balance
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
every { this@mockk.amount } returns amount
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
every { sources } returns CryptoCurrencyStatus.Sources()
}
private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin {
val network = createNetwork(networkId = networkId, standardTypeName = "MAIN")
val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
private fun createToken(
rawCurrencyId: String,
symbol: String,
networkId: String,
standardTypeName: String = "ERC20",
): CryptoCurrency.Token {
val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName)
val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
return mockk<CryptoCurrency.Token> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 6
every { isCustom } returns false
every { iconUrl } returns null
every { contractAddress } returns "0xCONTRACT"
}
}
private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk {
every { value } returns idValue
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
}
private fun createNetwork(networkId: String, standardTypeName: String): Network {
val standardType: Network.StandardType = mockk {
every { name } returns standardTypeName
}
return mockk {
every { id } returns mockk {
every { rawId } returns Network.RawID(networkId)
}
every { name } returns networkId
every { isTestnet } returns false
every { this@mockk.standardType } returns standardType
}
}
}

View file

@ -0,0 +1,287 @@
package com.tangem.features.foryou.impl.model.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouTokenRowConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class ConvertNetworkGroup {
@Test
fun `GIVEN all statuses Loading WHEN convertNetworkGroup THEN row is Loading with representative id`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading))
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses)
// Assert
assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth"))
}
@Test
fun `GIVEN single loaded status WHEN convertNetworkGroup THEN row is Content with its amounts`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert
assertThat(result.id).isEqualTo("coin-eth")
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.text).isEqualTo(BigDecimal("400").expectedFiatText())
assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").expectedPercentText(BigDecimal("1000")))
}
@Test
fun `GIVEN several statuses of the same asset on one network WHEN convertNetworkGroup THEN amounts are summed`() {
// Arrange — same asset held in two accounts on the same network aggregates into one row
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("200"))),
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.text).isEqualTo(BigDecimal("600").expectedFiatText())
}
@Test
fun `GIVEN mixed Loading and Loaded statuses WHEN convertNetworkGroup THEN row is Content`() {
// Arrange — not *all* statuses are Loading, so it should not collapse to a Loading row
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
val statuses = listOf(
createStatus(currency, CryptoCurrencyStatus.Loading),
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses)
// Assert
assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java)
}
@Test
fun `GIVEN loaded status from cache WHEN convertNetworkGroup THEN content flickers`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(
currency,
loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"), source = StatusSource.CACHE),
),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.isFlickering).isTrue()
assertThat(bottomEnd.isFlickering).isTrue()
assertThat(topEnd.startIcons).isEmpty()
}
@Test
fun `GIVEN loaded status only-cache WHEN convertNetworkGroup THEN error-sync start icon shown`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(
currency,
loadedValue(
amount = BigDecimal("1"),
fiatAmount = BigDecimal("100"),
source = StatusSource.ONLY_CACHE,
),
),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.isFlickering).isFalse()
assertThat(topEnd.startIcons).hasSize(1)
}
@Test
fun `GIVEN missed derivation status WHEN convertNetworkGroup THEN no-address treatment`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(createStatus(currency, missedDerivationValue()))
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert — top-end is a dash, bottom-end carries the attention "no address" icon
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.endIcons).isEmpty()
assertThat(bottomEnd.endIcons).hasSize(1)
}
@Test
fun `GIVEN unreachable status WHEN convertNetworkGroup THEN dash on top and attention icon on bottom`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(createStatus(currency, unreachableValue()))
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert — top-end is a bare dash, the attention "unreachable" icon lives on the bottom end
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.endIcons).isEmpty()
assertThat(bottomEnd.endIcons).hasSize(1)
}
@Test
fun `GIVEN mixed Loaded and Unreachable WHEN convertNetworkGroup THEN collapses to unreachable`() {
// Arrange — one account resolved, another unreachable: the row must surface the error state
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
createStatus(currency, unreachableValue()),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert — the unreachable treatment (attention icon on the bottom end) wins over the loaded amount
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(bottomEnd.endIcons).hasSize(1)
}
@Test
fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() {
// Arrange — missed derivation is the most severe terminal state and dominates
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(currency, unreachableValue()),
createStatus(currency, missedDerivationValue()),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert — top-end is a dash (no-address treatment), not an unreachable label
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.endIcons).isEmpty()
}
}
private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = {},
)
/** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] for a resolved row. */
private fun BigDecimal.expectedFiatText(): TextReference = stringReference(
format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) },
)
/** Mirrors the production percent-share rendering used by [ForYouTokenRowConverter] for a resolved row. */
private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference(
toForYouPercent(total).format { percent() },
)
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(
amount: BigDecimal,
fiatAmount: BigDecimal,
source: StatusSource = StatusSource.ACTUAL,
): CryptoCurrencyStatus.Loaded = mockk {
every { this@mockk.amount } returns amount
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
every { sources } returns CryptoCurrencyStatus.Sources(
networkSource = source,
quoteSource = source,
stakingBalanceSource = source,
)
}
private fun missedDerivationValue(): CryptoCurrencyStatus.MissedDerivation = mockk {
every { amount } returns null
every { fiatAmount } returns null
every { isError } returns true
}
private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = mockk {
every { amount } returns null
every { fiatAmount } returns null
every { isError } returns true
}
private fun createCurrency(
id: String,
symbol: String,
networkName: String = "Network",
): CryptoCurrency {
val network: Network = mockk {
every { name } returns networkName
every { isTestnet } returns false
every { this@mockk.id } returns mockk { every { rawId } returns Network.RawID(id) }
}
val currencyId: CryptoCurrency.ID = mockk {
every { value } returns id
every { rawCurrencyId } returns null
}
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}

View file

@ -0,0 +1,352 @@
package com.tangem.features.foryou.impl.model.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class SetPortfolioReviewTransformerTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class TokenList {
@Test
fun `GIVEN currency with resolved zero fiat balance WHEN transform THEN it is dropped from the list`() {
// Arrange
val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH")
val currencies = listOf(
createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)),
createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))),
)
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert — only the ETH asset survives; the zero-fiat BTC is dropped
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth")
}
@Test
fun `GIVEN non-content status with null fiat WHEN transform THEN it is kept not dropped`() {
// Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero;
// it must still be shown so the user sees the token they hold, with the appropriate treatment.
val unreachable = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val loaded = createCurrency(rawCurrencyId = "eth", symbol = "ETH")
val currencies = listOf(
createStatus(unreachable, unreachableValue()),
createStatus(loaded, loadedValue(BigDecimal("100"))),
)
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0)
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder()
}
@Test
fun `GIVEN same asset across networks WHEN transform THEN aggregated into one asset ranked by summed fiat`() {
// Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset
val onEth = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
val onSol = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
val other = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(
createStatus(onEth, loadedValue(BigDecimal("50"))),
createStatus(onSol, loadedValue(BigDecimal("60"))),
createStatus(other, loadedValue(BigDecimal("10"))),
)
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("120"))))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert — 2 ranked assets: usdc (110 total) ahead of btc (10)
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder()
}
@Test
fun `GIVEN more than TOP_HOLDINGS_COUNT assets WHEN transform THEN excess assets collapse into Other`() {
// Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other"
val currencies = (1..5).map { index ->
createStatus(
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
loadedValue(BigDecimal(100 - index)),
)
}
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("470"))))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert — 4 top asset rows + 1 "Other" row
assertThat(result.tokenList).hasSize(5)
assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets")
}
@Test
fun `GIVEN exactly TOP_HOLDINGS_COUNT assets WHEN transform THEN no Other row is appended`() {
// Arrange
val currencies = (1..4).map { index ->
createStatus(
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
loadedValue(BigDecimal(100 - index)),
)
}
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("394"))))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.tokenList).hasSize(4)
}
@Test
fun `GIVEN null account status list WHEN transform THEN token list is empty`() {
// Arrange
val transformer = createTransformer(accountStatusList = null)
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.tokenList).isEmpty()
}
}
@Nested
inner class MarketChart {
@Test
fun `GIVEN loaded total balance WHEN transform THEN market chart is Loaded with one segment per top asset`() {
// Arrange
val currencies = listOf(
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("70"))),
createStatus(createCurrency(rawCurrencyId = "eth", symbol = "ETH"), loadedValue(BigDecimal("30"))),
)
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
val marketChart = result.marketChartUM as MarketChartUM.Loaded
assertThat(marketChart.assetCount).isEqualTo(2)
}
@Test
fun `GIVEN non-loaded total balance WHEN transform THEN market chart is NoData`() {
// Arrange
val currencies = listOf(
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("100"))),
)
val transformer = createTransformer(accountStatusList(currencies, TotalFiatBalance.Loading))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
}
@Test
fun `GIVEN null account status list WHEN transform THEN market chart is NoData`() {
// Arrange
val transformer = createTransformer(accountStatusList = null)
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
}
}
@Nested
inner class PeriodPicker {
@Test
fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() {
// Arrange
val currencies = listOf(
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
)
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10"))))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.periodPickerUM.items.map { it.title }).containsExactly(
stringReference("Day"),
stringReference("Week"),
stringReference("Month"),
).inOrder()
assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day"))
}
@Test
fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() {
// Arrange
val currencies = listOf(
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
)
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10"))))
val prevContent = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
val weekItem = prevContent.periodPickerUM.items[1]
val prevState = loadingState().copy(
portfolioReviewUM = prevContent.copy(
periodPickerUM = prevContent.periodPickerUM.copy(initialSelectedItem = weekItem),
),
)
// Act
val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
}
}
@Nested
inner class Notifications {
@Test
fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() {
// Arrange
val currencies = listOf(
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
)
val transformer = createTransformer(
accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)),
)
// Act
val result = transformer.transform(loadingState())
// Assert
assertThat(result.notifications).containsExactly(ForYouNotification.UsedOutdatedData)
}
@Test
fun `GIVEN total balance from actual source WHEN transform THEN no notification is emitted`() {
// Arrange
val currencies = listOf(
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
)
val transformer = createTransformer(
accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ACTUAL)),
)
// Act
val result = transformer.transform(loadingState())
// Assert
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN null account status list WHEN transform THEN no notification is emitted`() {
// Arrange
val transformer = createTransformer(accountStatusList = null)
// Act
val result = transformer.transform(loadingState())
// Assert
assertThat(result.notifications).isEmpty()
}
}
private fun createTransformer(
accountStatusList: AccountStatusList?,
expandedAssetIds: Set<String> = emptySet(),
) = SetPortfolioReviewTransformer(
accountStatusList = accountStatusList,
appCurrency = appCurrency,
expandedAssetIds = expandedAssetIds,
expandClick = {},
onPeriodClick = {},
onTokenClick = {},
)
private fun accountStatusList(
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: TotalFiatBalance,
): AccountStatusList = mockk {
every { flattenCurrencies() } returns currencies
every { this@mockk.totalFiatBalance } returns totalFiatBalance
}
private fun loaded(amount: BigDecimal, source: StatusSource = StatusSource.ACTUAL): TotalFiatBalance.Loaded =
TotalFiatBalance.Loaded(amount = amount, source = source)
private fun loadingState(): ForYouUM = ForYouUM(
portfolioReviewUM = PortfolioReviewUM.Loading(
tokenList = persistentListOf<ForYouTokenListItemUM>(),
marketChartUM = MarketChartUM.NoData,
),
notifications = persistentListOf(),
)
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
every { amount } returns BigDecimal.ONE
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
every { sources } returns CryptoCurrencyStatus.Sources()
}
/** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */
private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable(
priceChange = null,
fiatRate = null,
networkAddress = null,
)
private fun createCurrency(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
val network: Network = mockk {
every { name } returns "Network"
every { isTestnet } returns false
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
}
val currencyId: CryptoCurrency.ID = mockk {
every { value } returns "coin-$rawCurrencyId"
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
}
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns currencyId
every { this@mockk.symbol } returns symbol
every { this@mockk.name } returns symbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}