From 1ce53d8496ee002ad29cd77f2718d24d1fb9139c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 16:59:33 +0200 Subject: [PATCH 01/12] Updated on 2026-08-14 --- .../core/ui/ds2/for_you_temp/DonutChart.kt | 406 ++++++++++++++ .../ds2/for_you_temp/DonutSegmentTooltip.kt | 260 +++++++++ .../ui/ds2/for_you_temp/GradientDivider.kt | 210 +++++++ .../core/ui/ds2/for_you_temp/MarketChart.kt | 513 ++++++++++++++++++ .../ds2/for_you_temp/models/DonutSegment.kt | 23 + .../for_you_temp/models/MarketChartState.kt | 43 ++ 6 files changed, 1455 insertions(+) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt new file mode 100644 index 0000000000..9731c91966 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt @@ -0,0 +1,406 @@ +package com.tangem.core.ui.ds2.for_you_temp + +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.rememberUpdatedState +import androidx.compose.runtime.setValue +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.toSize +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.TangemColorPalette +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 [com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment.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") +@Composable +fun DonutChart( + segments: List, + 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() } + // Theme-adaptive dim overlay for non-selected slices (black@20% in dark, white@20% in light). + val dimOverlayColor = TangemTheme.colors3.border.inverse.tertiary + + // 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", + ) + // Keep the previously selected slice bright while the dim fades back out (selectedIndex is already null + // by then, so we can't rely on it during the exit animation). + var highlightedIndex by remember { mutableStateOf(null) } + if (selectedIndex != null) highlightedIndex = selectedIndex + + // pointerInput below is set up once (its keys don't include selectedIndex/onSegmentClick), so the tap + // lambda would capture a STALE selectedIndex and re-fire for the already-selected slice. Read the latest + // values through rememberUpdatedState instead. + val latestSelectedIndex by rememberUpdatedState(selectedIndex) + val latestOnSegmentClick by rememberUpdatedState(onSegmentClick) + + val clickModifier = if (onSegmentClick != null && segments.isNotEmpty()) { + Modifier.pointerInput(segments, startAngle, strokePx) { + detectTapGestures { tap -> + val clickedIndex = segmentIndexAt(tap, size.toSize(), strokePx, segments, startAngle) + + if (latestSelectedIndex != clickedIndex) latestOnSegmentClick?.invoke(clickedIndex) + } + } + } else { + Modifier + } + + Box( + modifier = modifier + .background(TangemTheme.colors3.bg.secondary) + .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. + val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } + 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 = segments[i].color, + 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, + 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 = kotlin.math.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(kotlin.math.atan2(dy, dx).toDouble()).toFloat().mod(360f) + + val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } + 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 +} + +/** 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. */ +private val InnerShadowColor = TangemColorPalette.Base.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(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 = listOf( + DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand), + DonutSegment(weight = 0.07f, color = TangemTheme.colors3.border.accent.violet), + DonutSegment(weight = 0.06f, color = TangemTheme.colors3.border.accent.red), + DonutSegment(weight = 0.05f, color = TangemTheme.colors3.border.accent.green), + ), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$10,000.1333", + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.heading.medium, + ) + Text( + text = "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 = emptyList(), + ) { + Text( + text = "No data", + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.heading.medium, + ) + } + } + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt new file mode 100644 index 0000000000..54475c3495 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt @@ -0,0 +1,260 @@ +package com.tangem.core.ui.ds2.for_you_temp + +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.fillMaxSize +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.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +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.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.for_you_temp.models.DonutSegment +import com.tangem.core.ui.ds2.surface.TangemSurface +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import kotlin.math.roundToInt + +/** + * 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. + */ +@Composable +fun DonutSegmentTooltip( + modifier: Modifier = Modifier, + expanded: Boolean, + title: String, + fiatValue: String, + percent: String, + positionProvider: PopupPositionProvider, + onDismissRequest: () -> Unit, +) { + 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, + title: String, + fiatValue: String, + 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), + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.caption.medium, + maxLines = 1, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = fiatValue, + 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, + ) + } + } + } +} + +/** + * 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. + */ +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() + + // Base: bottom-center, gap above the anchor (screen-up). + 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) + } +} + +// 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 = "Ethereum", + fiatValue = "$5,720.22", + percent = "57.5%", + ) + } + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt new file mode 100644 index 0000000000..ff05156520 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt @@ -0,0 +1,210 @@ +package com.tangem.core.ui.ds2.for_you_temp + +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 +import com.tangem.core.ui.res.generated.TangemColorPalette + +/** + * Canvas-based take on the glow divider: the line background and every blurred color blob are painted by + * hand inside a single `Box(Modifier.drawBehind { … })`. + * + * How it differs from [GlowDotsDivider] (which layers child `Box`es with [androidx.compose.ui.draw.blur]): + * here there are no child composables at all. Inside [androidx.compose.ui.draw.drawBehind] we clip to the + * capsule path, fill the base color, then draw each [GlowDot] as a circle 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. Lays out to [lineWidth]×[lineHeight]. + * @param lineWidth Width of the capsule. + * @param lineHeight Height of the capsule. + * @param lineColor Solid base fill of the line. + * @param dots Color blobs; [GlowDot.offset] is the blob center relative to the line's top-center, + * [GlowDot.size] its diameter, [GlowDot.blur] the mask-blur radius. + */ +data class CanvasGlowDot( + val color: Color, + val offset: DpOffset, + val height: Dp, + val blur: Dp = 8.dp, +) + +@Suppress("MagicNumber", "LongParameterList") +@Composable +fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) { + val shape = RoundedCornerShape(size = 100.dp) + + // Gentle "breathing" glow: pulse the drop-shadow alpha between MIN and MAX. Designer hasn't + // provided timing yet, so 1600ms per direction reads as a calm, non-distracting pulse. + val infiniteTransition = rememberInfiniteTransition(label = "GlowDividerShadow") + val shadowAlpha by infiniteTransition.animateFloat( + initialValue = GlowMinAlpha, + targetValue = GlowMaxAlpha, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1600, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "GlowDividerShadowAlpha", + ) + + Box( + modifier = modifier + .width(lineWidth) + .drawBehind { + val resolvedDots = canvasDefaultDots(size.height.toDp()) + 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) { + // Base background. + 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 GlowMinAlpha = 0.3f +private const val GlowMaxAlpha = 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 + +/** + * Placeholder snake-scatter of the four Figma "selection colors", sized proportionally to [lineHeight] + * so the glow scales with the divider's length. Tune to match Figma. + */ +@Suppress("MagicNumber") +private fun canvasDefaultDots(lineHeight: Dp): List { + val step = lineHeight / 5 + return listOf( + CanvasGlowDot(TangemColorPalette.Violet.`40`, DpOffset(x = 0.5.dp, y = (-3).dp), height = step), // purple + CanvasGlowDot(TangemColorPalette.Green.`40`, DpOffset(x = 4.dp, y = step * 1.7f), height = step), // green + CanvasGlowDot(TangemColorPalette.Blue.`40`, DpOffset(x = (-2.5).dp, y = step * 2.2f), height = step), // blue + CanvasGlowDot(TangemColorPalette.Orange.`40`, DpOffset(x = (-3.5).dp, y = step * 3), height = step + step / 2), + CanvasGlowDot(TangemColorPalette.Violet.`40`, 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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt new file mode 100644 index 0000000000..eb82845a2e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt @@ -0,0 +1,513 @@ +package com.tangem.core.ui.ds2.for_you_temp + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.TextAutoSize +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +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.graphics.Brush +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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupPositionProvider +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds2.for_you_temp.models.AiInsightState +import com.tangem.core.ui.ds2.for_you_temp.models.DonutChartState +import com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment +import com.tangem.core.ui.ds2.for_you_temp.models.MarketChartState +import com.tangem.core.ui.ds2.surface.TangemSurface +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.Int +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +@Composable +fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) { + val hazeState = LocalHazeState.current + // Card bounds in window px — gates the tooltip's "flip to the side" fallback (see DonutChartBlock). + var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) } + + TangemSurface( + modifier = modifier + .hazeSourceTangem(hazeState) + .onGloballyPositioned { cardBoundsInWindow = it.boundsInWindow() }, + color = TangemTheme.colors3.bg.secondary, + ) { + Column { + DonutChartBlock(marketChartState.donutChartState, cardBoundsInWindow) + Spacer(modifier = Modifier.height(16.dp)) + if (marketChartState is MarketChartState.Loaded) { + TopHoldingBlock( + assetCount = marketChartState.assetCount, + topHoldingPercent = marketChartState.topHoldingPercent, + ) + } else { + CantLoadDataBlock() + } + + Spacer(modifier = Modifier.height(16.dp)) + AiInsightContent(marketChartState.aiInsightState) + } + } +} + +@Composable +private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBoundsInWindow: Rect) { + var selectedIndex by remember { mutableStateOf(null) } + val segments = donutChartState.donutSegmentList + val scope = rememberCoroutineScope() + var dismissJob by remember { mutableStateOf(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 { + chartSize = it.size + chartWindowOffset = it.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 (donutChartState is DonutChartState.Loaded) { + Text( + text = donutChartState.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 = "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 = "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 + } + } + ) + + } +} + +@Composable +private fun DonutSegmentTooltipBlock( + selectedIndex: Int?, + segments: List, + chartSize: IntSize, + chartWindowOffset: Offset, + cardBoundsInWindow: Rect, + onDismissRequest: () -> Unit, +) { + val density = LocalDensity.current + val gapPx = with(density) { TooltipGap.roundToPx() } + val strokePx = with(density) { DonutStrokeWidth.toPx() } + + val selectedSegment = selectedIndex?.let(segments::getOrNull) + val positionProvider = remember( + selectedIndex, segments, chartSize, chartWindowOffset, cardBoundsInWindow, strokePx, gapPx, + ) { + segmentTooltipPositionProvider( + selectedIndex = selectedIndex, + segments = segments, + chartSize = chartSize, + chartWindowOffset = chartWindowOffset, + strokePx = strokePx, + cardBoundsInWindow = cardBoundsInWindow, + gapPx = gapPx, + ) + } + + DonutSegmentTooltip( + expanded = selectedSegment != null, + positionProvider = positionProvider, + title = selectedSegment?.title.orEmpty(), + fiatValue = selectedSegment?.fiatValue.orEmpty(), + percent = selectedSegment?.let { formatSegmentPercent(it.weight) }.orEmpty(), + onDismissRequest = onDismissRequest + ) +} + +@Suppress("MagicNumber") +private fun formatSegmentPercent(weight: Float): String { + val percent = weight.coerceIn(0f, 1f) * 100 + return if (percent % 1f == 0f) "${percent.toInt()}%" else "%.2f%%".format(percent) +} + +private val DonutStrokeWidth = 28.dp +private val DonutStartAngle = -90f +private val TooltipGap = 8.dp + +/** + * Builds the tooltip 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. + */ +@Suppress("MagicNumber") +private fun segmentTooltipPositionProvider( + selectedIndex: Int?, + segments: List, + chartSize: IntSize, + chartWindowOffset: Offset, + strokePx: 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 layout as DonutChart's drawing pass. + val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } + val endAngleDeg = DonutStartAngle + 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(), + ) +} + +@Composable +private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Float) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = "$assetCount assets", + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.heading.small, + ) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = "Top holding: ${formatSegmentPercent(topHoldingPercent)}", + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.heading.small, + ) +} + +@Composable +private fun ColumnScope.CantLoadDataBlock() { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = "Can't load data", + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.heading.small, + ) +} + +@Composable +private fun AiInsightContent(aiInsightState: AiInsightState) { + AnimatedContent( + targetState = aiInsightState, + transitionSpec = { fadeIn().togetherWith(fadeOut()) }, + ) { currentState -> + when (currentState) { + is AiInsightState.AskAiInsight -> { + SecondaryTangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + onClick = currentState.askAiInsightClick, + size = TangemButtonSize.X9, + text = stringReference("Ask for AI summary") + ) + } + is AiInsightState.Displayed -> { + Row( + modifier = Modifier + .height(IntrinsicSize.Min) + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + ) { + 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("Al Total: ") } // TODO add localization + append(currentState.text) + }, + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + ) + } + } + AiInsightState.Hide -> {} + } + } +} + +// region Previews + +@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "MarketChart • Light", showBackground = true) +@Composable +private fun PreviewMarketChart() { + TangemThemePreviewRedesign { + Box( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + ) { + MarketChart( + MarketChartState.Loaded( + topHoldingPercent = 0.41f, + aiInsightState = AiInsightState.Displayed( + "Your portfolio leans on a single asset – BTC is 42% of holdings. Stablecoins add 23% " + + "buffer. Consider trimmng concentration for a smoother ride", + ), + donutChartState = DonutChartState.Loaded( + totalAmount = "$10,123456.1333", + donutSegmentList = listOf( + DonutSegment( + weight = 0.55f, + color = TangemTheme.colors3.border.brand, + title = "Ethereum", + fiatValue = "$5,720.22", + ), + DonutSegment( + weight = 0.07f, + color = TangemTheme.colors3.border.accent.violet, + title = "Solana", + fiatValue = "$728.30", + ), + DonutSegment( + weight = 0.06f, + color = TangemTheme.colors3.border.accent.red, + title = "Polkadot", + fiatValue = "$624.26", + ), + DonutSegment( + weight = 0.05f, + color = TangemTheme.colors3.border.accent.green, + title = "Tether", + fiatValue = "$520.18", + ), + ), + ), + ), + + ) + } + } +} + +@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "MarketChart • Light", showBackground = true) +@Composable +private fun PreviewMarketChartAskAI() { + TangemThemePreviewRedesign { + Box( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + ) { + MarketChart( + MarketChartState.Loaded( + topHoldingPercent = 0.41f, + aiInsightState = AiInsightState.AskAiInsight(askAiInsightClick = {}), + donutChartState = DonutChartState.Loaded( + totalAmount = "$10,123456.1333", + donutSegmentList = listOf( + DonutSegment( + weight = 0.55f, + color = TangemTheme.colors3.border.brand, + title = "Ethereum", + fiatValue = "$5,720.22", + ), + DonutSegment( + weight = 0.07f, + color = TangemTheme.colors3.border.accent.violet, + title = "Solana", + fiatValue = "$728.30", + ), + DonutSegment( + weight = 0.06f, + color = TangemTheme.colors3.border.accent.red, + title = "Polkadot", + fiatValue = "$624.26", + ), + DonutSegment( + weight = 0.05f, + color = TangemTheme.colors3.border.accent.green, + title = "Tether", + fiatValue = "$520.18", + ), + ), + ), + ), + + ) + } + } +} + +@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "MarketChart • Light", showBackground = true) +@Composable +private fun PreviewMarketChartNoAi() { + TangemThemePreviewRedesign { + Box( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + ) { + MarketChart( + MarketChartState.Loaded( + topHoldingPercent = 0.41f, + aiInsightState = AiInsightState.Hide, + donutChartState = DonutChartState.Loaded( + totalAmount = "$10,12345678912.1333", + donutSegmentList = listOf( + DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand), + DonutSegment(weight = 0.45f, color = TangemTheme.colors3.border.accent.green), + ), + ), + ), + + ) + } + } +} + +@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "MarketChart • Light", showBackground = true) +@Composable +private fun PreviewMarketChartNoData() { + TangemThemePreviewRedesign { + Box( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + ) { + MarketChart( + MarketChartState.NoData, + ) + } + } +} + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt new file mode 100644 index 0000000000..139b17a378 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt @@ -0,0 +1,23 @@ +package com.tangem.core.ui.ds2.for_you_temp.models + +import androidx.compose.ui.graphics.Color + +/** + * One colored slice of a [com.tangem.core.ui.ds2.for_you_temp.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 Solid fill of the slice. + * @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. + */ +data class DonutSegment( + val weight: Float, + val color: Color, + val title: String = "", + val fiatValue: String = "", +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt new file mode 100644 index 0000000000..8306d36fef --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt @@ -0,0 +1,43 @@ +package com.tangem.core.ui.ds2.for_you_temp.models + +import kotlin.Float +import kotlin.collections.List + +sealed class MarketChartState( + open val donutChartState: DonutChartState, + open val aiInsightState: AiInsightState, +) { + data class Loaded( + override val donutChartState: DonutChartState.Loaded, + override val aiInsightState: AiInsightState = AiInsightState.Hide, + /* from 0 to 1 */ + val topHoldingPercent: Float, + ) : MarketChartState( + donutChartState = donutChartState, + aiInsightState = aiInsightState, + ) { + val assetCount: Int = donutChartState.donutSegmentList.size + } + + data object NoData : MarketChartState( + donutChartState = DonutChartState.NoData, + aiInsightState = AiInsightState.Hide, + ) +} + +sealed class DonutChartState( + open val donutSegmentList: List, +) { + data class Loaded( + val totalAmount: String, + override val donutSegmentList: List, + ) : DonutChartState(donutSegmentList = donutSegmentList) + + data object NoData : DonutChartState(donutSegmentList = emptyList()) +} + +sealed class AiInsightState { + data object Hide : AiInsightState() + data class AskAiInsight(val askAiInsightClick: () -> Unit): AiInsightState() + data class Displayed(val text: String) : AiInsightState() +} \ No newline at end of file From 2ba90c5afced9f1368a4b688df5c1f807e39428e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 17:29:13 +0200 Subject: [PATCH 02/12] Updated on 2026-08-14 --- features/for-you/impl/build.gradle.kts | 2 + .../foryou/impl/components}/DonutChart.kt | 22 +- .../impl/components}/DonutSegmentTooltip.kt | 73 +---- .../impl/components}/GradientDivider.kt | 51 +-- .../foryou/impl/components}/MarketChart.kt | 292 ++++++------------ .../components/SegmentTooltipPositioning.kt | 116 +++++++ .../impl/components/state}/DonutSegment.kt | 6 +- .../components/state}/MarketChartState.kt | 10 +- 8 files changed, 268 insertions(+), 304 deletions(-) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/DonutChart.kt (94%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/DonutSegmentTooltip.kt (72%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/GradientDivider.kt (84%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/MarketChart.kt (59%) create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state}/DonutSegment.kt (84%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state}/MarketChartState.kt (81%) diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index 93ffbfa390..05657670cc 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -22,8 +22,10 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.foundation) + implementation(deps.compose.animation) implementation(deps.lifecycle.compose) implementation(deps.compose.material3) + implementation(deps.compose.ui.tooling) /** DI */ implementation(deps.hilt.android) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt similarity index 94% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index 9731c91966..9129c054a8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.ds2.for_you_temp +package com.tangem.features.foryou.impl.components import android.content.res.Configuration import android.graphics.BlurMaskFilter @@ -38,10 +38,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.toSize import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.core.ui.res.generated.TangemColorPalette +import com.tangem.features.foryou.impl.components.state.DonutSegment import kotlin.math.min /** @@ -68,7 +67,7 @@ import kotlin.math.min * 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 [com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment.weight]. + * @param segments Slices, in priority order (index 0 is painted on top). See [DonutSegment.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` @@ -80,9 +79,9 @@ import kotlin.math.min * @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") +@Suppress("MagicNumber", "LongParameterList", "LongMethod", "NamedArguments") @Composable -fun DonutChart( +internal fun DonutChart( segments: List, modifier: Modifier = Modifier, selectedIndex: Int? = null, @@ -93,7 +92,6 @@ fun DonutChart( content: @Composable ColumnScope.() -> Unit, ) { val strokePx = with(LocalDensity.current) { strokeWidth.toPx() } - // Theme-adaptive dim overlay for non-selected slices (black@20% in dark, white@20% in light). val dimOverlayColor = TangemTheme.colors3.border.inverse.tertiary // Fade the dim in/out in step with the segment tooltip's pop-in (same spring as DonutSegmentTooltip). @@ -102,14 +100,10 @@ fun DonutChart( animationSpec = spring(dampingRatio = DIM_SPRING_DAMPING, stiffness = DIM_SPRING_STIFFNESS), label = "donutDim", ) - // Keep the previously selected slice bright while the dim fades back out (selectedIndex is already null - // by then, so we can't rely on it during the exit animation). + var highlightedIndex by remember { mutableStateOf(null) } if (selectedIndex != null) highlightedIndex = selectedIndex - // pointerInput below is set up once (its keys don't include selectedIndex/onSegmentClick), so the tap - // lambda would capture a STALE selectedIndex and re-fire for the already-selected slice. Read the latest - // values through rememberUpdatedState instead. val latestSelectedIndex by rememberUpdatedState(selectedIndex) val latestOnSegmentClick by rememberUpdatedState(onSegmentClick) @@ -327,8 +321,8 @@ private fun DrawScope.drawInnerShadowArc( } } -/** Inner shadow — Figma #FFFFFF at 24% opacity. */ -private val InnerShadowColor = TangemColorPalette.Base.white.copy(alpha = 0.24f) +/** 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 diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt similarity index 72% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt index 54475c3495..0748bbd462 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.ds2.for_you_temp +package com.tangem.features.foryou.impl.components import android.content.res.Configuration import androidx.compose.animation.core.MutableTransitionState @@ -11,7 +11,6 @@ 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -20,25 +19,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.tooling.preview.Preview -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.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.for_you_temp.models.DonutSegment import com.tangem.core.ui.ds2.surface.TangemSurface import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import kotlin.math.roundToInt /** * Frosted-glass selection tooltip for a [DonutChart] slice. @@ -70,15 +60,16 @@ import kotlin.math.roundToInt * @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 -fun DonutSegmentTooltip( - modifier: Modifier = Modifier, +internal fun DonutSegmentTooltip( expanded: Boolean, title: String, fiatValue: String, percent: String, positionProvider: PopupPositionProvider, onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, ) { val expandedStates = remember { MutableTransitionState(false) } expandedStates.targetState = expanded @@ -177,62 +168,6 @@ private fun TooltipPill( } } -/** - * 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. - */ -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() - - // Base: bottom-center, gap above the anchor (screen-up). - 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) - } -} - // region Preview @Suppress("MagicNumber") diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt similarity index 84% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt index ff05156520..cf7bccbd88 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.ds2.for_you_temp +package com.tangem.features.foryou.impl.components import android.content.res.Configuration import android.graphics.BlurMaskFilter @@ -37,7 +37,6 @@ 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 -import com.tangem.core.ui.res.generated.TangemColorPalette /** * Canvas-based take on the glow divider: the line background and every blurred color blob are painted by @@ -56,24 +55,21 @@ import com.tangem.core.ui.res.generated.TangemColorPalette * @param dots Color blobs; [GlowDot.offset] is the blob center relative to the line's top-center, * [GlowDot.size] its diameter, [GlowDot.blur] the mask-blur radius. */ -data class CanvasGlowDot( +internal data class CanvasGlowDot( val color: Color, val offset: DpOffset, val height: Dp, val blur: Dp = 8.dp, ) -@Suppress("MagicNumber", "LongParameterList") +@Suppress("MagicNumber", "LongParameterList", "LongMethod") @Composable -fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) { +internal fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) { val shape = RoundedCornerShape(size = 100.dp) - - // Gentle "breathing" glow: pulse the drop-shadow alpha between MIN and MAX. Designer hasn't - // provided timing yet, so 1600ms per direction reads as a calm, non-distracting pulse. val infiniteTransition = rememberInfiniteTransition(label = "GlowDividerShadow") val shadowAlpha by infiniteTransition.animateFloat( - initialValue = GlowMinAlpha, - targetValue = GlowMaxAlpha, + initialValue = GLOW_MIN_ALPHA, + targetValue = GLOW_MAX_ALPHA, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1600, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse, @@ -81,11 +77,19 @@ fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) { 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()) + val resolvedDots = canvasDefaultDots(size.height.toDp(), dotColors) val cornerPx = size.width / 2f val capsule = Path().apply { addRoundRect( @@ -100,7 +104,6 @@ fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) { } clipPath(capsule) { - // Base background. drawRect(color = LineColor) // Blurred color blobs, drawn with a native BlurMaskFilter paint. @@ -143,8 +146,8 @@ fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) { } /** Glow pulse bounds for the animated drop shadow alpha. */ -private const val GlowMinAlpha = 0.3f -private const val GlowMaxAlpha = 0.65f +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) @@ -152,19 +155,27 @@ 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, +) + /** * Placeholder snake-scatter of the four Figma "selection colors", sized proportionally to [lineHeight] * so the glow scales with the divider's length. Tune to match Figma. */ @Suppress("MagicNumber") -private fun canvasDefaultDots(lineHeight: Dp): List { +private fun canvasDefaultDots(lineHeight: Dp, colors: GlowDotColors): List { val step = lineHeight / 5 return listOf( - CanvasGlowDot(TangemColorPalette.Violet.`40`, DpOffset(x = 0.5.dp, y = (-3).dp), height = step), // purple - CanvasGlowDot(TangemColorPalette.Green.`40`, DpOffset(x = 4.dp, y = step * 1.7f), height = step), // green - CanvasGlowDot(TangemColorPalette.Blue.`40`, DpOffset(x = (-2.5).dp, y = step * 2.2f), height = step), // blue - CanvasGlowDot(TangemColorPalette.Orange.`40`, DpOffset(x = (-3.5).dp, y = step * 3), height = step + step / 2), - CanvasGlowDot(TangemColorPalette.Violet.`40`, DpOffset(x = 0.5.dp, y = step * 4 + 4.dp), height = step), + 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), ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt similarity index 59% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index eb82845a2e..b326db6fae 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.ds2.for_you_temp +package com.tangem.features.foryou.impl.components import android.content.res.Configuration import androidx.compose.animation.AnimatedContent @@ -42,33 +42,30 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle 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 androidx.compose.ui.window.PopupPositionProvider import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.ds.button.SecondaryTangemButton import com.tangem.core.ui.ds.button.TangemButtonSize -import com.tangem.core.ui.ds2.for_you_temp.models.AiInsightState -import com.tangem.core.ui.ds2.for_you_temp.models.DonutChartState -import com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment -import com.tangem.core.ui.ds2.for_you_temp.models.MarketChartState import com.tangem.core.ui.ds2.surface.TangemSurface import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.foryou.impl.components.state.AiInsightState +import com.tangem.features.foryou.impl.components.state.DonutChartState +import com.tangem.features.foryou.impl.components.state.DonutSegment +import com.tangem.features.foryou.impl.components.state.MarketChartState import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlin.Int -import kotlin.math.cos -import kotlin.math.min -import kotlin.math.sin @Composable -fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) { +internal fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) { val hazeState = LocalHazeState.current - // Card bounds in window px — gates the tooltip's "flip to the side" fallback (see DonutChartBlock). var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) } TangemSurface( @@ -95,6 +92,7 @@ fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifie } } +@Suppress("LongMethod") @Composable private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBoundsInWindow: Rect) { var selectedIndex by remember { mutableStateOf(null) } @@ -114,9 +112,9 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo DonutChart( modifier = Modifier .fillMaxSize() - .onGloballyPositioned { - chartSize = it.size - chartWindowOffset = it.localToWindow(Offset.Zero) + .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. @@ -180,12 +178,12 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo withFrameNanos { } selectedIndex = null } - } + }, ) - } } +@Suppress("LongParameterList") @Composable private fun DonutSegmentTooltipBlock( selectedIndex: Int?, @@ -201,7 +199,13 @@ private fun DonutSegmentTooltipBlock( val selectedSegment = selectedIndex?.let(segments::getOrNull) val positionProvider = remember( - selectedIndex, segments, chartSize, chartWindowOffset, cardBoundsInWindow, strokePx, gapPx, + selectedIndex, + segments, + chartSize, + chartWindowOffset, + cardBoundsInWindow, + strokePx, + gapPx, ) { segmentTooltipPositionProvider( selectedIndex = selectedIndex, @@ -209,6 +213,7 @@ private fun DonutSegmentTooltipBlock( chartSize = chartSize, chartWindowOffset = chartWindowOffset, strokePx = strokePx, + startAngle = DonutStartAngle, cardBoundsInWindow = cardBoundsInWindow, gapPx = gapPx, ) @@ -220,7 +225,7 @@ private fun DonutSegmentTooltipBlock( title = selectedSegment?.title.orEmpty(), fiatValue = selectedSegment?.fiatValue.orEmpty(), percent = selectedSegment?.let { formatSegmentPercent(it.weight) }.orEmpty(), - onDismissRequest = onDismissRequest + onDismissRequest = onDismissRequest, ) } @@ -231,52 +236,9 @@ private fun formatSegmentPercent(weight: Float): String { } private val DonutStrokeWidth = 28.dp -private val DonutStartAngle = -90f +private const val DonutStartAngle = -90f private val TooltipGap = 8.dp -/** - * Builds the tooltip 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. - */ -@Suppress("MagicNumber") -private fun segmentTooltipPositionProvider( - selectedIndex: Int?, - segments: List, - chartSize: IntSize, - chartWindowOffset: Offset, - strokePx: 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 layout as DonutChart's drawing pass. - val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } - val endAngleDeg = DonutStartAngle + 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(), - ) -} - @Composable private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Float) { Text( @@ -304,6 +266,8 @@ private fun ColumnScope.CantLoadDataBlock() { ) } +// IntrinsicSize.Min lets the gradient divider match the AI text height — heightIn wouldn't achieve that. +@Suppress("ModifierHeightWithText") @Composable private fun AiInsightContent(aiInsightState: AiInsightState) { AnimatedContent( @@ -318,7 +282,7 @@ private fun AiInsightContent(aiInsightState: AiInsightState) { .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), onClick = currentState.askAiInsightClick, size = TangemButtonSize.X9, - text = stringReference("Ask for AI summary") + text = stringReference("Ask for AI summary"), ) } is AiInsightState.Displayed -> { @@ -362,152 +326,94 @@ private fun AiInsightContent(aiInsightState: AiInsightState) { // region Previews +private enum class MarketChartPreviewScenario { DISPLAYED, ASK_AI, NO_AI, NO_DATA } + +private class MarketChartPreviewProvider : PreviewParameterProvider { + override val values: Sequence + 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 PreviewMarketChart() { +private fun MarketChart_Preview( + @PreviewParameter(MarketChartPreviewProvider::class) scenario: MarketChartPreviewScenario, +) { TangemThemePreviewRedesign { Box( modifier = Modifier .background(TangemTheme.colors3.bg.primary) .padding(16.dp), ) { - MarketChart( - MarketChartState.Loaded( - topHoldingPercent = 0.41f, - aiInsightState = AiInsightState.Displayed( - "Your portfolio leans on a single asset – BTC is 42% of holdings. Stablecoins add 23% " + - "buffer. Consider trimmng concentration for a smoother ride", - ), - donutChartState = DonutChartState.Loaded( - totalAmount = "$10,123456.1333", - donutSegmentList = listOf( - DonutSegment( - weight = 0.55f, - color = TangemTheme.colors3.border.brand, - title = "Ethereum", - fiatValue = "$5,720.22", - ), - DonutSegment( - weight = 0.07f, - color = TangemTheme.colors3.border.accent.violet, - title = "Solana", - fiatValue = "$728.30", - ), - DonutSegment( - weight = 0.06f, - color = TangemTheme.colors3.border.accent.red, - title = "Polkadot", - fiatValue = "$624.26", - ), - DonutSegment( - weight = 0.05f, - color = TangemTheme.colors3.border.accent.green, - title = "Tether", - fiatValue = "$520.18", - ), - ), - ), - ), - - ) + MarketChart(marketChartState = previewMarketChartState(scenario)) } } } -@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(name = "MarketChart • Light", showBackground = true) +/** + * Maps a [scenario] to the state shown. Built inside a `@Composable` (not the [PreviewParameterProvider]) + * because the segment colors come from [TangemTheme.colors3], which can only be read in composition. + */ +@Suppress("MagicNumber") @Composable -private fun PreviewMarketChartAskAI() { - TangemThemePreviewRedesign { - Box( - modifier = Modifier - .background(TangemTheme.colors3.bg.primary) - .padding(16.dp), - ) { - MarketChart( - MarketChartState.Loaded( - topHoldingPercent = 0.41f, - aiInsightState = AiInsightState.AskAiInsight(askAiInsightClick = {}), - donutChartState = DonutChartState.Loaded( - totalAmount = "$10,123456.1333", - donutSegmentList = listOf( - DonutSegment( - weight = 0.55f, - color = TangemTheme.colors3.border.brand, - title = "Ethereum", - fiatValue = "$5,720.22", - ), - DonutSegment( - weight = 0.07f, - color = TangemTheme.colors3.border.accent.violet, - title = "Solana", - fiatValue = "$728.30", - ), - DonutSegment( - weight = 0.06f, - color = TangemTheme.colors3.border.accent.red, - title = "Polkadot", - fiatValue = "$624.26", - ), - DonutSegment( - weight = 0.05f, - color = TangemTheme.colors3.border.accent.green, - title = "Tether", - fiatValue = "$520.18", - ), - ), - ), - ), - - ) - } - } +private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartState = when (scenario) { + MarketChartPreviewScenario.DISPLAYED -> MarketChartState.Loaded( + topHoldingPercent = 0.41f, + aiInsightState = AiInsightState.Displayed( + "Your portfolio leans on a single asset – BTC is 42% of holdings. Stablecoins add 23% " + + "buffer. Consider trimmng concentration for a smoother ride", + ), + donutChartState = previewLoadedDonut(), + ) + MarketChartPreviewScenario.ASK_AI -> MarketChartState.Loaded( + topHoldingPercent = 0.41f, + aiInsightState = AiInsightState.AskAiInsight(askAiInsightClick = {}), + donutChartState = previewLoadedDonut(), + ) + MarketChartPreviewScenario.NO_AI -> MarketChartState.Loaded( + topHoldingPercent = 0.41f, + aiInsightState = AiInsightState.Hide, + donutChartState = DonutChartState.Loaded( + totalAmount = "$10,12345678912.1333", + donutSegmentList = listOf( + DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand), + DonutSegment(weight = 0.45f, color = TangemTheme.colors3.border.accent.green), + ), + ), + ) + MarketChartPreviewScenario.NO_DATA -> MarketChartState.NoData } -@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(name = "MarketChart • Light", showBackground = true) +@Suppress("MagicNumber") @Composable -private fun PreviewMarketChartNoAi() { - TangemThemePreviewRedesign { - Box( - modifier = Modifier - .background(TangemTheme.colors3.bg.primary) - .padding(16.dp), - ) { - MarketChart( - MarketChartState.Loaded( - topHoldingPercent = 0.41f, - aiInsightState = AiInsightState.Hide, - donutChartState = DonutChartState.Loaded( - totalAmount = "$10,12345678912.1333", - donutSegmentList = listOf( - DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand), - DonutSegment(weight = 0.45f, color = TangemTheme.colors3.border.accent.green), - ), - ), - ), - - ) - } - } -} - -@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(name = "MarketChart • Light", showBackground = true) -@Composable -private fun PreviewMarketChartNoData() { - TangemThemePreviewRedesign { - Box( - modifier = Modifier - .background(TangemTheme.colors3.bg.primary) - .padding(16.dp), - ) { - MarketChart( - MarketChartState.NoData, - ) - } - } -} +private fun previewLoadedDonut(): DonutChartState.Loaded = DonutChartState.Loaded( + totalAmount = "$10,123456.1333", + donutSegmentList = listOf( + DonutSegment( + weight = 0.55f, + color = TangemTheme.colors3.border.brand, + title = "Ethereum", + fiatValue = "$5,720.22", + ), + DonutSegment( + weight = 0.07f, + color = TangemTheme.colors3.border.accent.violet, + title = "Solana", + fiatValue = "$728.30", + ), + DonutSegment( + weight = 0.06f, + color = TangemTheme.colors3.border.accent.red, + title = "Polkadot", + fiatValue = "$624.26", + ), + DonutSegment( + weight = 0.05f, + color = TangemTheme.colors3.border.accent.green, + title = "Tether", + fiatValue = "$520.18", + ), + ), +) // endregion \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt new file mode 100644 index 0000000000..2049ea8f9e --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt @@ -0,0 +1,116 @@ +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.DonutSegment +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, + 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 layout as DonutChart's drawing pass. + val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } + 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) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt similarity index 84% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt index 139b17a378..e37e432d5c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt @@ -1,9 +1,9 @@ -package com.tangem.core.ui.ds2.for_you_temp.models +package com.tangem.features.foryou.impl.components.state import androidx.compose.ui.graphics.Color /** - * One colored slice of a [com.tangem.core.ui.ds2.for_you_temp.DonutChart]. + * 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. @@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color * @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. */ -data class DonutSegment( +internal data class DonutSegment( val weight: Float, val color: Color, val title: String = "", diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt similarity index 81% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt index 8306d36fef..74977d9c31 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt @@ -1,9 +1,9 @@ -package com.tangem.core.ui.ds2.for_you_temp.models +package com.tangem.features.foryou.impl.components.state import kotlin.Float import kotlin.collections.List -sealed class MarketChartState( +internal sealed class MarketChartState( open val donutChartState: DonutChartState, open val aiInsightState: AiInsightState, ) { @@ -25,7 +25,7 @@ sealed class MarketChartState( ) } -sealed class DonutChartState( +internal sealed class DonutChartState( open val donutSegmentList: List, ) { data class Loaded( @@ -36,8 +36,8 @@ sealed class DonutChartState( data object NoData : DonutChartState(donutSegmentList = emptyList()) } -sealed class AiInsightState { +internal sealed class AiInsightState { data object Hide : AiInsightState() - data class AskAiInsight(val askAiInsightClick: () -> Unit): AiInsightState() + data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightState() data class Displayed(val text: String) : AiInsightState() } \ No newline at end of file From 43c7cbfc589d10cbe9ae59510abe14055e5d8bea Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 17:59:37 +0200 Subject: [PATCH 03/12] Updated on 2026-08-14 --- features/for-you/impl/build.gradle.kts | 6 +++++ .../foryou/impl/components/DonutChart.kt | 1 - .../foryou/impl/components/MarketChart.kt | 22 ++++++++++--------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index 05657670cc..d2009892cf 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -8,6 +8,12 @@ plugins { android { namespace = "com.tangem.features.foryou.impl" + + packaging { + resources { + merges += "paymentrequest.proto" + } + } } dependencies { diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index 9129c054a8..3ab9e0a3d1 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -111,7 +111,6 @@ internal fun DonutChart( Modifier.pointerInput(segments, startAngle, strokePx) { detectTapGestures { tap -> val clickedIndex = segmentIndexAt(tap, size.toSize(), strokePx, segments, startAngle) - if (latestSelectedIndex != clickedIndex) latestOnSegmentClick?.invoke(clickedIndex) } } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index b326db6fae..051835a162 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -194,12 +194,15 @@ private fun DonutSegmentTooltipBlock( onDismissRequest: () -> Unit, ) { val density = LocalDensity.current - val gapPx = with(density) { TooltipGap.roundToPx() } + val gapPx = with(density) { 8.dp.roundToPx() } val strokePx = with(density) { DonutStrokeWidth.toPx() } + var shownIndex by remember { mutableStateOf(null) } + if (selectedIndex != null) shownIndex = selectedIndex - val selectedSegment = selectedIndex?.let(segments::getOrNull) + val isExpanded = selectedIndex?.let(segments::getOrNull) != null + val shownSegment = shownIndex?.let(segments::getOrNull) val positionProvider = remember( - selectedIndex, + shownIndex, segments, chartSize, chartWindowOffset, @@ -208,7 +211,7 @@ private fun DonutSegmentTooltipBlock( gapPx, ) { segmentTooltipPositionProvider( - selectedIndex = selectedIndex, + selectedIndex = shownIndex, segments = segments, chartSize = chartSize, chartWindowOffset = chartWindowOffset, @@ -220,11 +223,11 @@ private fun DonutSegmentTooltipBlock( } DonutSegmentTooltip( - expanded = selectedSegment != null, + expanded = isExpanded, positionProvider = positionProvider, - title = selectedSegment?.title.orEmpty(), - fiatValue = selectedSegment?.fiatValue.orEmpty(), - percent = selectedSegment?.let { formatSegmentPercent(it.weight) }.orEmpty(), + title = shownSegment?.title.orEmpty(), + fiatValue = shownSegment?.fiatValue.orEmpty(), + percent = shownSegment?.let { formatSegmentPercent(it.weight) }.orEmpty(), onDismissRequest = onDismissRequest, ) } @@ -236,8 +239,7 @@ private fun formatSegmentPercent(weight: Float): String { } private val DonutStrokeWidth = 28.dp -private const val DonutStartAngle = -90f -private val TooltipGap = 8.dp +private val DonutStartAngle = -90f @Composable private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Float) { From ca85fd3a6d1f4069c7aa3ac3c38f8d6fbd5eea27 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 10:26:52 +0200 Subject: [PATCH 04/12] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 26 ++++++++++++++++- core/res/src/main/res/values-es/strings.xml | 1 + core/res/src/main/res/values-fr/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 1 + core/res/src/main/res/values-ru/strings.xml | 1 + .../src/main/res/values-uk-rUA/strings.xml | 1 + .../src/main/res/values-zh-rCN/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 29 +++++++++++++++---- .../foryou/impl/components/DonutChart.kt | 6 ++-- .../impl/components/DonutSegmentTooltip.kt | 2 +- .../foryou/impl/components/MarketChart.kt | 19 +++++++----- 11 files changed, 72 insertions(+), 16 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 525e778b71..bc30111006 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -106,6 +106,7 @@ Kontakt Name der Kontaktperson Adresse kopieren + Kontakt hinzugefügt Es konnte kein Kontakt hergestellt werden. Bitte versuchen Sie es später erneut. Dieser Kontakt wird aus all Ihren Adressbüchern gelöscht. Der Kontakt konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut. @@ -115,10 +116,17 @@ Adresse eingeben Ungültige Adresse Weiter bearbeiten + Sie können maximal 20 Adressen erstellen. Löschen Sie eine, um eine neue hinzuzufügen. + Neue Adresse kann nicht hinzugefügt werden + Der Name des Ansprechpartners ist erforderlich + Der Name des Ansprechpartners enthält ungültige Zeichen + Der Name des Ansprechpartners darf nicht länger als 50 Zeichen sein + Dieser Name ist bei dieser Wallet bereits vergeben. Neuer Kontakt Noch keine Kontakte Die von Ihnen hinzugefügten Kontakte werden hier angezeigt Adresse entfernen + In Wallet speichern Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft. Netzwerk auswählen Adressbuch @@ -348,10 +356,12 @@ Vom Von %s Adressen synchronisieren + Erhalten Erste Schritte Token erhalten Zum Anbieter gehen Zum Token + Zur Verifizierung gehen Verstanden Ausblenden Halten bis %s @@ -410,6 +420,7 @@ Datenschutzrichtlinie %1$s-%2$s %1$s — %2$s + Zinssatz Weiterlesen Empfangen Erhalten @@ -471,6 +482,7 @@ %d Token Transaktion fehlgeschlagen + Transaktions-ID Transaktionsstatus Transaktionen Überweisung @@ -566,7 +578,7 @@ Dynamische Adressen deaktivieren Dynamische Adressen deaktivieren Dynamische Adressen deaktiviert - Einige Adressen fehlen + Dynamische Adressen aktiviert Verwenden Sie für jede Transaktion eine neue Adresse, um die Rückverfolgbarkeit zu verringern und den Datenschutz in der Kette zu verbessern. Verbesserter Datenschutz Einfacher Geldempfang in UTXO-basierten Netzwerken mit automatischer Adressgenerierung - keine manuelle Adressverwaltung erforderlich. @@ -700,6 +712,8 @@ Feedback zu Tangem Eine Transaktion kann nicht gesendet werden Fehler in der Coinbeschreibung + Portfolio prüfen und Verdienstmöglichkeiten erkunden + Für dich Jetzt aktualisieren Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten Aktualisierung erforderlich @@ -731,6 +745,8 @@ Schlüsselgenerierung Alle kryptografischen Vorgänge finden innerhalb des sicheren Chips statt, der gegen Klonen und physische Manipulation zertifiziert ist. Sicherheit auf Hardwareebene + Das Netzwerk ist derzeit stark ausgelastet. Sie können jetzt fortfahren oder es später erneut versuchen, wenn die Gebühren möglicherweise niedriger sind. + Die Netzwerkgebühr ist höher als üblich Vorhandene Wallet hinzufügen Neues Wallet erstellen Karte oder Ring bestellen @@ -1215,6 +1231,7 @@ Der zu kaufende Betrag muss mindestens %s betragen Kumulierte Transaktionsbeträge über %1s können eine Identitätsüberprüfung mit %2s Kumulierte Transaktionsbeträge über dem Gegenwert von %1s können eine Identitätsprüfung mit %2s + Apple Pay-Transaktionen erfordern möglicherweise eine Identitätsprüfung mit %1s Indem du auf \"Bezahlen\" klicken, stimmen Sie %1s\'s %2s und %3szu. Keine verfügbaren Anbieter für diese Währung Schnellste Bearbeitung @@ -1401,6 +1418,7 @@ Memo Überprüfe deine Netzwerkverbindung Informationen zur Netzwerkgebühr nicht erreichbar + aus „ %1$s “ in %2$s Sie senden Von %s Grenzwert Gasgebühr @@ -1551,6 +1569,7 @@ Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal. Staking nicht verfügbar Staking ist in Ihrer Region nicht verfügbar. + Staking ist in Ihrer Region derzeit nicht verfügbar. Falls Sie ein VPN aktiviert haben, deaktivieren Sie es bitte. Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst. Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu. Gesperrt @@ -1846,6 +1865,10 @@ PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. Karte + Tarif wechseln + Kartenbezogen + Planbezogen + Aktueller Plan Limit von %s bis %s festlegen Limits festlegen Unzureichendes Guthaben @@ -2059,6 +2082,7 @@ Tippe auf die Doppelkarte oder Ring mit der Nummer %s und entferne sie erst am Ende des Vorgangs. Aufladen Aufgeladen + Du hast bezahlt Bitte versuche es später noch einmal. Sollte das Problem weiterhin bestehen, wende Dich bitte an den Support. Etwas ist schiefgelaufen! Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 3a5403aaec..303fe0d6c6 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1208,6 +1208,7 @@ La cantidad a comprar debe ser como mínimo %s El importe acumulado de la transacción superior a %1s puede requerir la verificación de la identidad con %2s El importe acumulado de la transacción superior al equivalente de %1s puede requerir la verificación de la identidad con %2s + Las transacciones de Apple Pay pueden requerir verificación de identidad con %1s Al hacer clic en Pagar, usted acepta %1s\'s %2s y %3s. No hay proveedores disponibles para esta moneda Procesamiento más rápido diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index c1d9e544ef..98fe3c378b 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1112,6 +1112,7 @@ Le montant à acheter doit être au moins %s Si le montant cumulé des transactions dépasse %1s, une vérification d\'identité via %2s pourrait être requise Si le montant cumulé des transactions dépasse l\'équivalent de %1s, une vérification d\'identité via %2s pourrait être requise + Les transactions Apple Pay peuvent nécessiter une vérification d\'identité avec %1s En appuyant sur Acheter, vous acceptez %1s %2s et %3s. Aucun fournisseur disponible pour cette devise Le plus rapide diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 1a9a334e93..95f8447cb8 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1191,6 +1191,7 @@ 買付金額は少なくとも%sである必要があります 累計取引額が%1sを超えると、%2sでの本人確認が必要になる場合があります。 累計取引額が%1s相当額を超えると、%2sでの本人確認が必要になる場合があります。 + Apple Payの取引では、%1sによる本人確認が必要になる場合があります 「支払う」をタップすると、%1sの%2sおよび%3sに同意したものとみなされます。 この通貨で利用可能なプロバイダーはありません 最短で処理 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 74baf3a874..e8dd470622 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1251,6 +1251,7 @@ Сумма покупки должна составлять минимум %s Общая сумма транзакций свыше %1s может потребовать верификации личности через %2s Общая сумма транзакций, превышающая эквивалент %1s, может потребовать верификации личности через %2s + Для транзакций через Apple Pay может потребоваться верификация личности в %1s Нажимая «Оплатить», вы соглашаетесь с %1s\'s %2s и %3s. Нет доступных провайдеров для выбранной валюты Самый быстрый diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 7924e3da11..bf61ef1442 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1251,6 +1251,7 @@ Сума покупки повинна бути не менше %s Загальна сума транзакцій понад %1s може вимагати верифікації особи через %2s Загальна сума транзакцій, що перевищує еквівалент %1s, може вимагати верифікації особи через %2s + Для транзакцій через Apple Pay може знадобитися верифікація особи в %1s Натискаючи «Оплатити», ви погоджуєтеся з %1s\'s %2s і %3s. Для данної валюти немає доступних провайдерів Найшвидший diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index e1ea9ae35f..a6c24d30c2 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1185,6 +1185,7 @@ 购买金额必须至少 %s 累计交易金额超过 %1s 时,可能需要通过 %2s进行身份验证 累计交易金额超过等值金额 %1s 可能需要通过 %2s进行身份验证 + Apple Pay 交易可能需要通过以下方式进行身份验证: %1s 点击“支付”即表示您同意 %1s的 %2s 和 %3s。 目前没有提供此货币的供应商 最快处理 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e315735ecf..ff0edfd0bf 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -98,6 +98,7 @@ Add address Add address and select network Add contact + This address is already saved as %1$s %d address %d addresses @@ -106,6 +107,7 @@ Contact Contact name Copy address + Contact added Couldn\'t create contact. Please try again later. This contact will be deleted from all your address books Couldn\'t delete contact. Please try again later. @@ -116,7 +118,8 @@ Invalid address Keep editing You can not create more than 20 addresses. Delete one to add new. - Can\'t add new address + Can\'t add new address + Memo / Destination Tag is a code separating transactions to a shared recipient in a crypto network.\n**Caution: Missing a memo may lead to fund loss.** Contact name is required Contact name contains invalid characters Contact name must not exceed 50 characters @@ -125,8 +128,10 @@ No contacts yet Contacts added will appear here Remove address + Save contact Save to Wallet This contact will be linked to this wallet’s address book. + No results found.\nTry another name Select network Address book Unsaved changes @@ -355,6 +360,7 @@ From From %s Synchronize addresses + Get Get started Get token Go to provider @@ -480,6 +486,7 @@ %d tokens Transaction failed + Transaction ID Transaction status Transactions Transfer @@ -880,6 +887,15 @@ The selected token is currently unavailable for actions within the crypto wallet. But worry not, you can express your interest by upvoting it. Upvote The wallet doesn\'t support more than one network + Al Total: + + %d asset + %d assets + + No data + Total value + Can’t load data + Top holding %s%% About coin To buy, exchange, or receive this asset, add it to your portfolio This asset is currently not supported in the wallet @@ -1229,6 +1245,7 @@ The amount to buy must be at least %s Cumulative transaction amount over %1s may require identity verification with %2s Cumulative transaction amount over equivalent of %1s may require identity verification with %2s + Apple Pay transactions may require identity verification with %1s By clicking Pay, you agree to %1s\'s %2s and %3s. No available providers for this currency Quickest processing @@ -1566,6 +1583,7 @@ No available validators at the moment. Please try again later. Staking Unavailable Staking is unavailable in your region + Staking is currently unavailable in your region. If you have VPN enabled – try disabling it. The network will charge a token approval fee to verify that you are authorizing the use of your token for the staking. By using staking functionality, you agree with provider’s %1$s and %2$s Locked @@ -1861,6 +1879,10 @@ Change PIN-code Come back to the app if you forget it. Card + Change plan + Card related + Plan related + Current plan Set a limit from %s to %s Set limits insufficient funds @@ -1951,10 +1973,6 @@ Pay Support Payment account Session expired - Current plan - Card related - Plan related - Change plan Invalid PIN: avoid sequences or repeats Replace card This generates a new set of card details. Your old details will stop working. You can\'t undo this. @@ -2078,6 +2096,7 @@ Tap the twin card with number %s and do not remove until the end of the operation Top up Topped up + You paid Please try again later. If the issue persists, please contact support. Something went wrong! We\'ve encountered an error. Error code: %s. Please contact our support. diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index 3ab9e0a3d1..50466990d9 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -38,10 +38,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.toSize import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +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.DonutSegment import kotlin.math.min +import com.tangem.features.foryou.impl.R /** * Ring (donut) chart drawn behind a center [content] slot. @@ -361,7 +363,7 @@ private fun PreviewDonutChart() { style = TangemTheme.typography3.heading.medium, ) Text( - text = "Total value", + text = stringResourceSafe(R.string.market_chart_buble_total_value), color = TangemTheme.colors3.text.secondary, style = TangemTheme.typography3.body.medium, ) @@ -387,7 +389,7 @@ private fun PreviewDonutChartEmpty() { segments = emptyList(), ) { Text( - text = "No data", + text = stringResourceSafe(R.string.market_chart_buble_no_data), color = TangemTheme.colors3.text.primary, style = TangemTheme.typography3.heading.medium, ) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt index 0748bbd462..c0592ae0ac 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt @@ -158,7 +158,7 @@ private fun TooltipPill( maxLines = 1, ) Text( - text = " • $percent", + text = " • $percent%", color = TangemTheme.colors3.text.tertiary, style = TangemTheme.typography3.caption.medium, maxLines = 1, diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index 051835a162..b525a71033 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -38,6 +38,7 @@ 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.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle @@ -51,7 +52,9 @@ import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.ds.button.SecondaryTangemButton import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds2.surface.TangemSurface +import com.tangem.core.ui.extensions.pluralStringResourceSafe import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -62,6 +65,7 @@ import com.tangem.features.foryou.impl.components.state.MarketChartState import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlin.Int +import com.tangem.features.foryou.impl.R @Composable internal fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) { @@ -145,7 +149,7 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo ), ) Text( - text = "Total value", + text = stringResourceSafe(R.string.market_chart_buble_total_value), color = TangemTheme.colors3.text.secondary, style = TangemTheme.typography3.caption.medium, maxLines = 1, @@ -155,7 +159,7 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo ) } else { Text( - text = "No data", + text = stringResourceSafe(R.string.market_chart_buble_no_data), color = TangemTheme.colors3.text.secondary, style = TangemTheme.typography3.body.medium, maxLines = 1, @@ -235,7 +239,7 @@ private fun DonutSegmentTooltipBlock( @Suppress("MagicNumber") private fun formatSegmentPercent(weight: Float): String { val percent = weight.coerceIn(0f, 1f) * 100 - return if (percent % 1f == 0f) "${percent.toInt()}%" else "%.2f%%".format(percent) + return if (percent % 1f == 0f) "${percent.toInt()}" else "%.2f%".format(percent) } private val DonutStrokeWidth = 28.dp @@ -245,14 +249,14 @@ private val DonutStartAngle = -90f private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Float) { Text( modifier = Modifier.padding(horizontal = 16.dp), - text = "$assetCount assets", + 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 = "Top holding: ${formatSegmentPercent(topHoldingPercent)}", + text = stringResourceSafe(R.string.market_chart_top_holding, formatSegmentPercent(topHoldingPercent)), color = TangemTheme.colors3.text.primary, style = TangemTheme.typography3.heading.small, ) @@ -262,7 +266,7 @@ private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Floa private fun ColumnScope.CantLoadDataBlock() { Text( modifier = Modifier.padding(horizontal = 16.dp), - text = "Can't load data", + text = stringResourceSafe(R.string.market_chart_can_not_load_data), color = TangemTheme.colors3.text.secondary, style = TangemTheme.typography3.heading.small, ) @@ -313,7 +317,8 @@ private fun AiInsightContent(aiInsightState: AiInsightState) { ), alpha = 1f, ), - ) { append("Al Total: ") } // TODO add localization + ) { append(stringResourceSafe(R.string.market_chart_ai_total)) } + append(" ") append(currentState.text) }, color = TangemTheme.colors3.text.secondary, From 956bc7cd753d6545af5c5658eb601897fa3191ca Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 12:07:39 +0200 Subject: [PATCH 05/12] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 9 ++--- .../foryou/impl/components/DonutChart.kt | 17 +++++----- .../impl/components/DonutSegmentTooltip.kt | 2 +- .../foryou/impl/components/GradientDivider.kt | 34 +++++++++---------- .../foryou/impl/components/MarketChart.kt | 20 +++++------ .../impl/components/state/MarketChartState.kt | 3 -- 6 files changed, 40 insertions(+), 45 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index ff0edfd0bf..31787d3bb7 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -98,6 +98,7 @@ Add address Add address and select network Add contact + Address copied This address is already saved as %1$s %d address @@ -119,7 +120,6 @@ Keep editing You can not create more than 20 addresses. Delete one to add new. Can\'t add new address - Memo / Destination Tag is a code separating transactions to a shared recipient in a crypto network.\n**Caution: Missing a memo may lead to fund loss.** Contact name is required Contact name contains invalid characters Contact name must not exceed 50 characters @@ -887,13 +887,14 @@ The selected token is currently unavailable for actions within the crypto wallet. But worry not, you can express your interest by upvoting it. Upvote The wallet doesn\'t support more than one network - Al Total: + AI Total: + Ask for AI summary %d asset %d assets - No data - Total value + No data + Total value Can’t load data Top holding %s%% About coin diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index 50466990d9..72e4d0d909 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -35,15 +35,17 @@ 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.toSize import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize 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.DonutSegment -import kotlin.math.min import com.tangem.features.foryou.impl.R +import com.tangem.features.foryou.impl.components.state.DonutSegment +import kotlin.math.atan2 +import kotlin.math.hypot +import kotlin.math.min /** * Ring (donut) chart drawn behind a center [content] slot. @@ -122,7 +124,6 @@ internal fun DonutChart( Box( modifier = modifier - .background(TangemTheme.colors3.bg.secondary) .then(clickModifier) .drawBehind { val arc = arcRect(strokePx) @@ -240,11 +241,11 @@ private fun segmentIndexAt( val outer = min(size.width, size.height) / 2f val inner = outer - strokePx val tolerance = strokePx * 0.4f - val dist = kotlin.math.hypot(dx, dy) + 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(kotlin.math.atan2(dy, dx).toDouble()).toFloat().mod(360f) + val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat().mod(360f) val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep } @@ -363,7 +364,7 @@ private fun PreviewDonutChart() { style = TangemTheme.typography3.heading.medium, ) Text( - text = stringResourceSafe(R.string.market_chart_buble_total_value), + text = stringResourceSafe(R.string.market_chart_bubble_total_value), color = TangemTheme.colors3.text.secondary, style = TangemTheme.typography3.body.medium, ) @@ -389,7 +390,7 @@ private fun PreviewDonutChartEmpty() { segments = emptyList(), ) { Text( - text = stringResourceSafe(R.string.market_chart_buble_no_data), + text = stringResourceSafe(R.string.market_chart_bubble_no_data), color = TangemTheme.colors3.text.primary, style = TangemTheme.typography3.heading.medium, ) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt index c0592ae0ac..9ed143b041 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt @@ -186,7 +186,7 @@ private fun PreviewDonutSegmentTooltip() { expandedStates = remember { MutableTransitionState(true) }, title = "Ethereum", fiatValue = "$5,720.22", - percent = "57.5%", + percent = "57.5", ) } } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt index cf7bccbd88..b7a2c85135 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt @@ -39,21 +39,12 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign /** - * Canvas-based take on the glow divider: the line background and every blurred color blob are painted by - * hand inside a single `Box(Modifier.drawBehind { … })`. + * One blurred color blob painted inside [CanvasGradientDivider]. * - * How it differs from [GlowDotsDivider] (which layers child `Box`es with [androidx.compose.ui.draw.blur]): - * here there are no child composables at all. Inside [androidx.compose.ui.draw.drawBehind] we clip to the - * capsule path, fill the base color, then draw each [GlowDot] as a circle 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. Lays out to [lineWidth]×[lineHeight]. - * @param lineWidth Width of the capsule. - * @param lineHeight Height of the capsule. - * @param lineColor Solid base fill of the line. - * @param dots Color blobs; [GlowDot.offset] is the blob center relative to the line's top-center, - * [GlowDot.size] its diameter, [GlowDot.blur] the mask-blur radius. + * @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, @@ -62,6 +53,17 @@ internal data class CanvasGlowDot( 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) { @@ -163,10 +165,6 @@ private data class GlowDotColors( val orange: Color, ) -/** - * Placeholder snake-scatter of the four Figma "selection colors", sized proportionally to [lineHeight] - * so the glow scales with the divider's length. Tune to match Figma. - */ @Suppress("MagicNumber") private fun canvasDefaultDots(lineHeight: Dp, colors: GlowDotColors): List { val step = lineHeight / 5 diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index b525a71033..54bccd77a2 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -38,7 +38,6 @@ 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.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle @@ -53,19 +52,18 @@ import com.tangem.core.ui.ds.button.SecondaryTangemButton import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds2.surface.TangemSurface import com.tangem.core.ui.extensions.pluralStringResourceSafe -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalHazeState 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.AiInsightState import com.tangem.features.foryou.impl.components.state.DonutChartState import com.tangem.features.foryou.impl.components.state.DonutSegment import com.tangem.features.foryou.impl.components.state.MarketChartState import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlin.Int -import com.tangem.features.foryou.impl.R @Composable internal fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) { @@ -149,7 +147,7 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo ), ) Text( - text = stringResourceSafe(R.string.market_chart_buble_total_value), + text = stringResourceSafe(R.string.market_chart_bubble_total_value), color = TangemTheme.colors3.text.secondary, style = TangemTheme.typography3.caption.medium, maxLines = 1, @@ -159,7 +157,7 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo ) } else { Text( - text = stringResourceSafe(R.string.market_chart_buble_no_data), + text = stringResourceSafe(R.string.market_chart_bubble_no_data), color = TangemTheme.colors3.text.secondary, style = TangemTheme.typography3.body.medium, maxLines = 1, @@ -239,7 +237,7 @@ private fun DonutSegmentTooltipBlock( @Suppress("MagicNumber") private fun formatSegmentPercent(weight: Float): String { val percent = weight.coerceIn(0f, 1f) * 100 - return if (percent % 1f == 0f) "${percent.toInt()}" else "%.2f%".format(percent) + return if (percent % 1f == 0f) "${percent.toInt()}" else "%.2f".format(percent) } private val DonutStrokeWidth = 28.dp @@ -288,7 +286,7 @@ private fun AiInsightContent(aiInsightState: AiInsightState) { .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), onClick = currentState.askAiInsightClick, size = TangemButtonSize.X9, - text = stringReference("Ask for AI summary"), + text = resourceReference(R.string.market_chart_ask_for_ai_summary_button), ) } is AiInsightState.Displayed -> { @@ -368,7 +366,7 @@ private fun previewMarketChartState(scenario: MarketChartPreviewScenario): Marke topHoldingPercent = 0.41f, aiInsightState = AiInsightState.Displayed( "Your portfolio leans on a single asset – BTC is 42% of holdings. Stablecoins add 23% " + - "buffer. Consider trimmng concentration for a smoother ride", + "buffer. Consider trimming concentration for a smoother ride", ), donutChartState = previewLoadedDonut(), ) @@ -403,13 +401,13 @@ private fun previewLoadedDonut(): DonutChartState.Loaded = DonutChartState.Loade fiatValue = "$5,720.22", ), DonutSegment( - weight = 0.07f, + weight = 0.077f, color = TangemTheme.colors3.border.accent.violet, title = "Solana", fiatValue = "$728.30", ), DonutSegment( - weight = 0.06f, + weight = 0.0666f, color = TangemTheme.colors3.border.accent.red, title = "Polkadot", fiatValue = "$624.26", diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt index 74977d9c31..1312449349 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt @@ -1,8 +1,5 @@ package com.tangem.features.foryou.impl.components.state -import kotlin.Float -import kotlin.collections.List - internal sealed class MarketChartState( open val donutChartState: DonutChartState, open val aiInsightState: AiInsightState, From 6eb6e5a433011726b378487f939ced7f325ed571 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 13:38:52 +0200 Subject: [PATCH 06/12] Updated on 2026-08-14 --- .../java/com/tangem/core/ui/ds2/surface/TangemSurface.kt | 8 +++++--- .../foryou/impl/components/DonutSegmentTooltip.kt | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt index 2b4f731dac..87249b47d9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/surface/TangemSurface.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.graphics.LinearGradientShader import androidx.compose.ui.graphics.Shader import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.haze.hazeEffectTangem @@ -70,6 +71,7 @@ fun TangemSurface( onClick: (() -> Unit)? = null, enabled: Boolean = true, interactionSource: MutableInteractionSource? = null, + shadowRadius: Dp = 40.dp, content: @Composable () -> Unit, ) { val resolvedInteractionSource = interactionSource ?: remember { MutableInteractionSource() } @@ -77,7 +79,7 @@ fun TangemSurface( val surface: @Composable () -> Unit = { Box( modifier = modifier - .conditionalCompose(isMaterial) { materialShadow(shape) } + .conditionalCompose(isMaterial) { materialShadow(shape, shadowRadius) } .conditionalCompose(border != null) { border(border!!, shape) } .conditionalCompose(isMaterial) { materialBorder(shape) } .clip(shape) @@ -115,8 +117,8 @@ fun TangemSurface( * `isAlphaContentClip`) to avoid the dark blur bleeding through the surface. */ @Composable -private fun Modifier.materialShadow(shape: Shape): Modifier = softLayerShadow( - radius = 40.dp, +private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier = softLayerShadow( + radius = radius, color = Color.Black.copy(alpha = 0.12f), shape = shape, spread = 0.dp, diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt index 9ed143b041..ca7d13c287 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt @@ -138,6 +138,7 @@ private fun TooltipPill( }, isMaterial = true, shape = RoundedCornerShape(percent = 50), + shadowRadius = 10.dp ) { Column( modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), From 88783a797e6a283db865012b3988e7b30f55742d Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 13:48:16 +0200 Subject: [PATCH 07/12] Updated on 2026-08-14 --- .../features/foryou/impl/components/DonutSegmentTooltip.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt index ca7d13c287..4b404c1755 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt @@ -138,7 +138,7 @@ private fun TooltipPill( }, isMaterial = true, shape = RoundedCornerShape(percent = 50), - shadowRadius = 10.dp + shadowRadius = 10.dp, ) { Column( modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), From 54d1ea497364fc54822296212137a03b8c69f09e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 14:48:07 +0200 Subject: [PATCH 08/12] Updated on 2026-08-14 --- .../foryou/impl/components/DonutChart.kt | 16 ++-- .../foryou/impl/components/MarketChart.kt | 82 +++++++++---------- .../components/SegmentTooltipPositioning.kt | 4 +- .../{DonutSegment.kt => DonutSegmentUM.kt} | 2 +- .../impl/components/state/MarketChartState.kt | 40 --------- .../components/state/MarketChartStateUM.kt | 45 ++++++++++ 6 files changed, 96 insertions(+), 93 deletions(-) rename features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/{DonutSegment.kt => DonutSegmentUM.kt} (96%) delete mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index 72e4d0d909..c7de61cd5a 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -42,7 +42,7 @@ 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.DonutSegment +import com.tangem.features.foryou.impl.components.state.DonutSegmentUM import kotlin.math.atan2 import kotlin.math.hypot import kotlin.math.min @@ -71,7 +71,7 @@ import kotlin.math.min * 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 [DonutSegment.weight]. + * @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` @@ -86,7 +86,7 @@ import kotlin.math.min @Suppress("MagicNumber", "LongParameterList", "LongMethod", "NamedArguments") @Composable internal fun DonutChart( - segments: List, + segments: List, modifier: Modifier = Modifier, selectedIndex: Int? = null, onSegmentClick: ((index: Int?) -> Unit)? = null, @@ -230,7 +230,7 @@ private fun segmentIndexAt( tap: Offset, size: Size, strokePx: Float, - segments: List, + segments: List, startAngle: Float, ): Int? { val cx = size.width / 2f @@ -351,10 +351,10 @@ private fun PreviewDonutChart() { selectedIndex = selectedIndex, onSegmentClick = { index -> selectedIndex = index.takeIf { it != selectedIndex } }, segments = listOf( - DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand), - DonutSegment(weight = 0.07f, color = TangemTheme.colors3.border.accent.violet), - DonutSegment(weight = 0.06f, color = TangemTheme.colors3.border.accent.red), - DonutSegment(weight = 0.05f, color = TangemTheme.colors3.border.accent.green), + DonutSegmentUM(weight = 0.55f, color = TangemTheme.colors3.border.brand), + DonutSegmentUM(weight = 0.07f, color = TangemTheme.colors3.border.accent.violet), + DonutSegmentUM(weight = 0.06f, color = TangemTheme.colors3.border.accent.red), + DonutSegmentUM(weight = 0.05f, color = TangemTheme.colors3.border.accent.green), ), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index 54bccd77a2..63a272194e 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -54,51 +54,49 @@ import com.tangem.core.ui.ds2.surface.TangemSurface import com.tangem.core.ui.extensions.pluralStringResourceSafe import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.LocalHazeState 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.AiInsightState -import com.tangem.features.foryou.impl.components.state.DonutChartState -import com.tangem.features.foryou.impl.components.state.DonutSegment -import com.tangem.features.foryou.impl.components.state.MarketChartState +import com.tangem.features.foryou.impl.components.state.AiInsightUM +import com.tangem.features.foryou.impl.components.state.DonutChartUM +import com.tangem.features.foryou.impl.components.state.DonutSegmentUM +import com.tangem.features.foryou.impl.components.state.MarketChartUM import kotlinx.coroutines.Job import kotlinx.coroutines.launch @Composable -internal fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) { - val hazeState = LocalHazeState.current +internal fun MarketChart(modifier: Modifier = Modifier, marketChart: MarketChartUM) { var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) } TangemSurface( modifier = modifier - .hazeSourceTangem(hazeState) + .hazeSourceTangem() .onGloballyPositioned { cardBoundsInWindow = it.boundsInWindow() }, color = TangemTheme.colors3.bg.secondary, ) { Column { - DonutChartBlock(marketChartState.donutChartState, cardBoundsInWindow) + DonutChartBlock(marketChart.donutChart, cardBoundsInWindow) Spacer(modifier = Modifier.height(16.dp)) - if (marketChartState is MarketChartState.Loaded) { + if (marketChart is MarketChartUM.Loaded) { TopHoldingBlock( - assetCount = marketChartState.assetCount, - topHoldingPercent = marketChartState.topHoldingPercent, + assetCount = marketChart.assetCount, + topHoldingPercent = marketChart.topHoldingPercent, ) } else { CantLoadDataBlock() } Spacer(modifier = Modifier.height(16.dp)) - AiInsightContent(marketChartState.aiInsightState) + AiInsightContent(marketChart.aiInsight) } } } @Suppress("LongMethod") @Composable -private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBoundsInWindow: Rect) { +private fun ColumnScope.DonutChartBlock(donutChartUM: DonutChartUM, cardBoundsInWindow: Rect) { var selectedIndex by remember { mutableStateOf(null) } - val segments = donutChartState.donutSegmentList + val segments = donutChartUM.donutSegmentList val scope = rememberCoroutineScope() var dismissJob by remember { mutableStateOf(null) } var chartSize by remember { mutableStateOf(IntSize.Zero) } @@ -135,9 +133,9 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo }, segments = segments, ) { - if (donutChartState is DonutChartState.Loaded) { + if (donutChartUM is DonutChartUM.Loaded) { Text( - text = donutChartState.totalAmount, + text = donutChartUM.totalAmount, color = TangemTheme.colors3.text.primary, style = TangemTheme.typography3.body.medium, maxLines = 1, @@ -189,7 +187,7 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo @Composable private fun DonutSegmentTooltipBlock( selectedIndex: Int?, - segments: List, + segments: List, chartSize: IntSize, chartWindowOffset: Offset, cardBoundsInWindow: Rect, @@ -273,13 +271,13 @@ private fun ColumnScope.CantLoadDataBlock() { // IntrinsicSize.Min lets the gradient divider match the AI text height — heightIn wouldn't achieve that. @Suppress("ModifierHeightWithText") @Composable -private fun AiInsightContent(aiInsightState: AiInsightState) { +private fun AiInsightContent(aiInsightUM: AiInsightUM) { AnimatedContent( - targetState = aiInsightState, + targetState = aiInsightUM, transitionSpec = { fadeIn().togetherWith(fadeOut()) }, ) { currentState -> when (currentState) { - is AiInsightState.AskAiInsight -> { + is AiInsightUM.AskAiInsight -> { SecondaryTangemButton( modifier = Modifier .fillMaxWidth() @@ -289,7 +287,7 @@ private fun AiInsightContent(aiInsightState: AiInsightState) { text = resourceReference(R.string.market_chart_ask_for_ai_summary_button), ) } - is AiInsightState.Displayed -> { + is AiInsightUM.Displayed -> { Row( modifier = Modifier .height(IntrinsicSize.Min) @@ -324,7 +322,7 @@ private fun AiInsightContent(aiInsightState: AiInsightState) { ) } } - AiInsightState.Hide -> {} + AiInsightUM.Hide -> {} } } } @@ -350,7 +348,7 @@ private fun MarketChart_Preview( .background(TangemTheme.colors3.bg.primary) .padding(16.dp), ) { - MarketChart(marketChartState = previewMarketChartState(scenario)) + MarketChart(marketChart = previewMarketChartState(scenario)) } } } @@ -361,58 +359,58 @@ private fun MarketChart_Preview( */ @Suppress("MagicNumber") @Composable -private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartState = when (scenario) { - MarketChartPreviewScenario.DISPLAYED -> MarketChartState.Loaded( +private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartUM = when (scenario) { + MarketChartPreviewScenario.DISPLAYED -> MarketChartUM.Loaded( topHoldingPercent = 0.41f, - aiInsightState = AiInsightState.Displayed( + 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", ), - donutChartState = previewLoadedDonut(), + donutChart = previewLoadedDonut(), ) - MarketChartPreviewScenario.ASK_AI -> MarketChartState.Loaded( + MarketChartPreviewScenario.ASK_AI -> MarketChartUM.Loaded( topHoldingPercent = 0.41f, - aiInsightState = AiInsightState.AskAiInsight(askAiInsightClick = {}), - donutChartState = previewLoadedDonut(), + aiInsight = AiInsightUM.AskAiInsight(askAiInsightClick = {}), + donutChart = previewLoadedDonut(), ) - MarketChartPreviewScenario.NO_AI -> MarketChartState.Loaded( + MarketChartPreviewScenario.NO_AI -> MarketChartUM.Loaded( topHoldingPercent = 0.41f, - aiInsightState = AiInsightState.Hide, - donutChartState = DonutChartState.Loaded( + aiInsight = AiInsightUM.Hide, + donutChart = DonutChartUM.Loaded( totalAmount = "$10,12345678912.1333", donutSegmentList = listOf( - DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand), - DonutSegment(weight = 0.45f, color = TangemTheme.colors3.border.accent.green), + DonutSegmentUM(weight = 0.55f, color = TangemTheme.colors3.border.brand), + DonutSegmentUM(weight = 0.45f, color = TangemTheme.colors3.border.accent.green), ), ), ) - MarketChartPreviewScenario.NO_DATA -> MarketChartState.NoData + MarketChartPreviewScenario.NO_DATA -> MarketChartUM.NoData } @Suppress("MagicNumber") @Composable -private fun previewLoadedDonut(): DonutChartState.Loaded = DonutChartState.Loaded( +private fun previewLoadedDonut(): DonutChartUM.Loaded = DonutChartUM.Loaded( totalAmount = "$10,123456.1333", donutSegmentList = listOf( - DonutSegment( + DonutSegmentUM( weight = 0.55f, color = TangemTheme.colors3.border.brand, title = "Ethereum", fiatValue = "$5,720.22", ), - DonutSegment( + DonutSegmentUM( weight = 0.077f, color = TangemTheme.colors3.border.accent.violet, title = "Solana", fiatValue = "$728.30", ), - DonutSegment( + DonutSegmentUM( weight = 0.0666f, color = TangemTheme.colors3.border.accent.red, title = "Polkadot", fiatValue = "$624.26", ), - DonutSegment( + DonutSegmentUM( weight = 0.05f, color = TangemTheme.colors3.border.accent.green, title = "Tether", diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt index 2049ea8f9e..0ea9d87f53 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt @@ -7,7 +7,7 @@ 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.DonutSegment +import com.tangem.features.foryou.impl.components.state.DonutSegmentUM import kotlin.math.cos import kotlin.math.min import kotlin.math.roundToInt @@ -23,7 +23,7 @@ import kotlin.math.sin @Suppress("MagicNumber", "LongParameterList", "ComplexCondition") internal fun segmentTooltipPositionProvider( selectedIndex: Int?, - segments: List, + segments: List, chartSize: IntSize, chartWindowOffset: Offset, strokePx: Float, diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt similarity index 96% rename from features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt index e37e432d5c..cee1256f48 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color * @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. */ -internal data class DonutSegment( +internal data class DonutSegmentUM( val weight: Float, val color: Color, val title: String = "", diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt deleted file mode 100644 index 1312449349..0000000000 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.tangem.features.foryou.impl.components.state - -internal sealed class MarketChartState( - open val donutChartState: DonutChartState, - open val aiInsightState: AiInsightState, -) { - data class Loaded( - override val donutChartState: DonutChartState.Loaded, - override val aiInsightState: AiInsightState = AiInsightState.Hide, - /* from 0 to 1 */ - val topHoldingPercent: Float, - ) : MarketChartState( - donutChartState = donutChartState, - aiInsightState = aiInsightState, - ) { - val assetCount: Int = donutChartState.donutSegmentList.size - } - - data object NoData : MarketChartState( - donutChartState = DonutChartState.NoData, - aiInsightState = AiInsightState.Hide, - ) -} - -internal sealed class DonutChartState( - open val donutSegmentList: List, -) { - data class Loaded( - val totalAmount: String, - override val donutSegmentList: List, - ) : DonutChartState(donutSegmentList = donutSegmentList) - - data object NoData : DonutChartState(donutSegmentList = emptyList()) -} - -internal sealed class AiInsightState { - data object Hide : AiInsightState() - data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightState() - data class Displayed(val text: String) : AiInsightState() -} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt new file mode 100644 index 0000000000..3498165165 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt @@ -0,0 +1,45 @@ +package com.tangem.features.foryou.impl.components.state + +import androidx.compose.runtime.Immutable + +@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, + /* from 0 to 1 */ + val topHoldingPercent: Float, + ) : 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: List, +) { + data class Loaded( + val totalAmount: String, + override val donutSegmentList: List, + ) : DonutChartUM(donutSegmentList = donutSegmentList) + + data object NoData : DonutChartUM(donutSegmentList = emptyList()) +} + +@Immutable +internal sealed class AiInsightUM { + data object Hide : AiInsightUM() + data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightUM() + data class Displayed(val text: String) : AiInsightUM() +} \ No newline at end of file From aac5e5a4a9d385f24b078c8d53614dd8acd72f5c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 14:49:00 +0200 Subject: [PATCH 09/12] Updated on 2026-08-14 --- .../com/tangem/features/foryou/impl/components/MarketChart.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index 63a272194e..c06b8013a9 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -65,7 +65,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch @Composable -internal fun MarketChart(modifier: Modifier = Modifier, marketChart: MarketChartUM) { +internal fun MarketChart(marketChart: MarketChartUM, modifier: Modifier = Modifier) { var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) } TangemSurface( From 5e083c40d92fca98d368b547ad9cd8fdad9e9e1f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 15:39:20 +0200 Subject: [PATCH 10/12] Updated on 2026-08-14 --- .../foryou/impl/components/DonutChart.kt | 34 ++++++++-- .../impl/components/DonutSegmentTooltip.kt | 23 ++++--- .../foryou/impl/components/MarketChart.kt | 68 +++++++++++-------- .../components/SegmentTooltipPositioning.kt | 2 +- .../impl/components/state/DonutSegmentUM.kt | 8 ++- .../components/state/MarketChartStateUM.kt | 4 +- 6 files changed, 88 insertions(+), 51 deletions(-) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index c7de61cd5a..962d1c96cf 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -38,11 +38,13 @@ 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.DonutSegmentUM +import java.math.BigDecimal import kotlin.math.atan2 import kotlin.math.hypot import kotlin.math.min @@ -164,7 +166,7 @@ internal fun DonutChart( } // Precompute each slice's [start, sweep] once. - val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } + val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f } 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 @@ -247,7 +249,7 @@ private fun segmentIndexAt( // Degrees clockwise from 3 o'clock — same convention as Canvas.drawArc. val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat().mod(360f) - val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } + val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f } val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep } for (i in segments.indices) { if (sweeps[i] <= 0f) continue @@ -351,10 +353,30 @@ private fun PreviewDonutChart() { selectedIndex = selectedIndex, onSegmentClick = { index -> selectedIndex = index.takeIf { it != selectedIndex } }, segments = listOf( - DonutSegmentUM(weight = 0.55f, color = TangemTheme.colors3.border.brand), - DonutSegmentUM(weight = 0.07f, color = TangemTheme.colors3.border.accent.violet), - DonutSegmentUM(weight = 0.06f, color = TangemTheme.colors3.border.accent.red), - DonutSegmentUM(weight = 0.05f, color = TangemTheme.colors3.border.accent.green), + DonutSegmentUM( + weight = BigDecimal(0.55), + color = TangemTheme.colors3.border.brand, + title = stringReference("Ethereum"), + fiatValue = stringReference("$5,720.22"), + ), + DonutSegmentUM( + weight = BigDecimal(0.07), + color = TangemTheme.colors3.border.accent.violet, + title = stringReference("Solana"), + fiatValue = stringReference("$728.30"), + ), + DonutSegmentUM( + weight = BigDecimal(0.06), + color = TangemTheme.colors3.border.accent.red, + title = stringReference("Polkadot"), + fiatValue = stringReference("$624.26"), + ), + DonutSegmentUM( + weight = BigDecimal(0.05), + color = TangemTheme.colors3.border.accent.green, + title = stringReference("Tether"), + fiatValue = stringReference("$520.18"), + ), ), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt index 4b404c1755..5f4ec15898 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt @@ -27,6 +27,9 @@ 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 @@ -64,8 +67,8 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign @Composable internal fun DonutSegmentTooltip( expanded: Boolean, - title: String, - fiatValue: String, + title: TextReference, + fiatValue: TextReference, percent: String, positionProvider: PopupPositionProvider, onDismissRequest: () -> Unit, @@ -100,8 +103,8 @@ private const val DISMISSED_SCALE = 0.8f @Composable private fun TooltipPill( expandedStates: MutableTransitionState, - title: String, - fiatValue: String, + title: TextReference, + fiatValue: TextReference, percent: String, modifier: Modifier = Modifier, ) { @@ -145,7 +148,7 @@ private fun TooltipPill( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = title, + text = title.resolveReference(), color = TangemTheme.colors3.text.primary, style = TangemTheme.typography3.caption.medium, maxLines = 1, @@ -153,13 +156,13 @@ private fun TooltipPill( Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = fiatValue, + text = fiatValue.resolveReference(), color = TangemTheme.colors3.text.primary, style = TangemTheme.typography3.caption.medium, maxLines = 1, ) Text( - text = " • $percent%", + text = " • $percent", color = TangemTheme.colors3.text.tertiary, style = TangemTheme.typography3.caption.medium, maxLines = 1, @@ -185,9 +188,9 @@ private fun PreviewDonutSegmentTooltip() { ) { TooltipPill( expandedStates = remember { MutableTransitionState(true) }, - title = "Ethereum", - fiatValue = "$5,720.22", - percent = "57.5", + title = stringReference("Ethereum"), + fiatValue = stringReference("$5,720.22"), + percent = "57.5%", ) } } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index c06b8013a9..582c492702 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -51,9 +51,14 @@ import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.ds.button.SecondaryTangemButton import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds2.surface.TangemSurface +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe +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 @@ -63,6 +68,7 @@ import com.tangem.features.foryou.impl.components.state.DonutSegmentUM import com.tangem.features.foryou.impl.components.state.MarketChartUM import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import java.math.BigDecimal @Composable internal fun MarketChart(marketChart: MarketChartUM, modifier: Modifier = Modifier) { @@ -200,7 +206,7 @@ private fun DonutSegmentTooltipBlock( if (selectedIndex != null) shownIndex = selectedIndex val isExpanded = selectedIndex?.let(segments::getOrNull) != null - val shownSegment = shownIndex?.let(segments::getOrNull) + val shownSegment = shownIndex?.let(segments::getOrNull) ?: return val positionProvider = remember( shownIndex, segments, @@ -225,24 +231,18 @@ private fun DonutSegmentTooltipBlock( DonutSegmentTooltip( expanded = isExpanded, positionProvider = positionProvider, - title = shownSegment?.title.orEmpty(), - fiatValue = shownSegment?.fiatValue.orEmpty(), - percent = shownSegment?.let { formatSegmentPercent(it.weight) }.orEmpty(), + title = shownSegment.title, + fiatValue = shownSegment.fiatValue, + percent = shownSegment.weight.format { percent() }, onDismissRequest = onDismissRequest, ) } -@Suppress("MagicNumber") -private fun formatSegmentPercent(weight: Float): String { - val percent = weight.coerceIn(0f, 1f) * 100 - return if (percent % 1f == 0f) "${percent.toInt()}" else "%.2f".format(percent) -} - private val DonutStrokeWidth = 28.dp private val DonutStartAngle = -90f @Composable -private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Float) { +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), @@ -252,7 +252,7 @@ private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: Floa Text( modifier = Modifier.padding(horizontal = 16.dp), - text = stringResourceSafe(R.string.market_chart_top_holding, formatSegmentPercent(topHoldingPercent)), + text = topHoldingPercent.resolveReference(), color = TangemTheme.colors3.text.primary, style = TangemTheme.typography3.heading.small, ) @@ -361,7 +361,7 @@ private fun MarketChart_Preview( @Composable private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartUM = when (scenario) { MarketChartPreviewScenario.DISPLAYED -> MarketChartUM.Loaded( - topHoldingPercent = 0.41f, + 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", @@ -369,18 +369,28 @@ private fun previewMarketChartState(scenario: MarketChartPreviewScenario): Marke donutChart = previewLoadedDonut(), ) MarketChartPreviewScenario.ASK_AI -> MarketChartUM.Loaded( - topHoldingPercent = 0.41f, + topHoldingPercent = stringReference("Top holding 41%"), aiInsight = AiInsightUM.AskAiInsight(askAiInsightClick = {}), donutChart = previewLoadedDonut(), ) MarketChartPreviewScenario.NO_AI -> MarketChartUM.Loaded( - topHoldingPercent = 0.41f, + topHoldingPercent = stringReference("Top holding 41%"), aiInsight = AiInsightUM.Hide, donutChart = DonutChartUM.Loaded( totalAmount = "$10,12345678912.1333", donutSegmentList = listOf( - DonutSegmentUM(weight = 0.55f, color = TangemTheme.colors3.border.brand), - DonutSegmentUM(weight = 0.45f, color = TangemTheme.colors3.border.accent.green), + DonutSegmentUM( + weight = BigDecimal(0.55), + color = TangemTheme.colors3.border.brand, + title = stringReference("Ethereum"), + fiatValue = stringReference("$5,720.22"), + ), + DonutSegmentUM( + weight = BigDecimal(0.45), + color = TangemTheme.colors3.border.accent.green, + title = stringReference("Solana"), + fiatValue = stringReference("$728.30"), + ), ), ), ) @@ -393,28 +403,28 @@ private fun previewLoadedDonut(): DonutChartUM.Loaded = DonutChartUM.Loaded( totalAmount = "$10,123456.1333", donutSegmentList = listOf( DonutSegmentUM( - weight = 0.55f, + weight = BigDecimal(0.55), color = TangemTheme.colors3.border.brand, - title = "Ethereum", - fiatValue = "$5,720.22", + title = stringReference("Ethereum"), + fiatValue = stringReference("$5,720.22"), ), DonutSegmentUM( - weight = 0.077f, + weight = BigDecimal(0.077), color = TangemTheme.colors3.border.accent.violet, - title = "Solana", - fiatValue = "$728.30", + title = stringReference("Solana"), + fiatValue = stringReference("$728.30"), ), DonutSegmentUM( - weight = 0.0666f, + weight = BigDecimal(0.0666), color = TangemTheme.colors3.border.accent.red, - title = "Polkadot", - fiatValue = "$624.26", + title = stringReference("Polkadot"), + fiatValue = stringReference("$624.26"), ), DonutSegmentUM( - weight = 0.05f, + weight = BigDecimal(0.05), color = TangemTheme.colors3.border.accent.green, - title = "Tether", - fiatValue = "$520.18", + title = stringReference("Tether"), + fiatValue = stringReference("$520.18"), ), ), ) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt index 0ea9d87f53..bf03d8e6cf 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt @@ -42,7 +42,7 @@ internal fun segmentTooltipPositionProvider( val centerY = chartSize.height / 2f val innerRadius = diameter / 2f - strokePx / 2 // End angle of the selected slice (before its round cap) — same layout as DonutChart's drawing pass. - val sweeps = segments.map { it.weight.coerceIn(0f, 1f) * 360f } + val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f } val endAngleDeg = startAngle + sweeps.take(selectedIndex + 1).sum() val endAngleRad = Math.toRadians(endAngleDeg.toDouble()) val anchorLocal = Offset( diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt index cee1256f48..4fdb4e448f 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt @@ -1,6 +1,8 @@ package com.tangem.features.foryou.impl.components.state import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.extensions.TextReference +import java.math.BigDecimal /** * One colored slice of a [com.tangem.features.foryou.impl.components.DonutChart]. @@ -16,8 +18,8 @@ import androidx.compose.ui.graphics.Color * tooltip next to the share. Empty by default. */ internal data class DonutSegmentUM( - val weight: Float, val color: Color, - val title: String = "", - val fiatValue: String = "", + val weight: BigDecimal, + val title: TextReference, + val fiatValue: TextReference, ) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt index 3498165165..ce825b8336 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.foryou.impl.components.state import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference @Immutable internal sealed class MarketChartUM( @@ -10,8 +11,7 @@ internal sealed class MarketChartUM( data class Loaded( override val donutChart: DonutChartUM.Loaded, override val aiInsight: AiInsightUM = AiInsightUM.Hide, - /* from 0 to 1 */ - val topHoldingPercent: Float, + val topHoldingPercent: TextReference, ) : MarketChartUM( donutChart = donutChart, aiInsight = aiInsight, From 405e892af81077340bd99211577d4d7eb178ecb4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 15:46:35 +0200 Subject: [PATCH 11/12] Updated on 2026-08-14 --- .../features/foryou/impl/components/state/DonutSegmentUM.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt index 4fdb4e448f..df7e33719c 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegmentUM.kt @@ -1,5 +1,6 @@ package com.tangem.features.foryou.impl.components.state +import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import com.tangem.core.ui.extensions.TextReference import java.math.BigDecimal @@ -17,6 +18,8 @@ import java.math.BigDecimal * @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: Color, val weight: BigDecimal, From d47c9b8f8325c0df642c19d97cc72f463d1f4664 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 15:51:40 +0200 Subject: [PATCH 12/12] Updated on 2026-08-14 --- .../components/state/{MarketChartStateUM.kt => MarketChartUM.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/{MarketChartStateUM.kt => MarketChartUM.kt} (100%) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartUM.kt similarity index 100% rename from features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartStateUM.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartUM.kt