From 1ce53d8496ee002ad29cd77f2718d24d1fb9139c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 16:59:33 +0200 Subject: [PATCH] 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