Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 17:29:13 +02:00
parent cebe0dcdf1
commit 2ba90c5afc
8 changed files with 268 additions and 304 deletions

View file

@ -1,406 +0,0 @@
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<DonutSegment>,
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<Int?>(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<DonutSegment>,
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<Int?>(null) }
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier.size(260.dp),
selectedIndex = selectedIndex,
onSegmentClick = { index -> selectedIndex = index.takeIf { it != selectedIndex } },
segments = 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

View file

@ -1,260 +0,0 @@
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<Boolean>,
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

View file

@ -1,210 +0,0 @@
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<CanvasGlowDot> {
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

View file

@ -1,513 +0,0 @@
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<Int?>(null) }
val segments = donutChartState.donutSegmentList
val scope = rememberCoroutineScope()
var dismissJob by remember { mutableStateOf<Job?>(null) }
var chartSize by remember { mutableStateOf(IntSize.Zero) }
var chartWindowOffset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.padding(32.dp)
.align(Alignment.CenterHorizontally)
.size(200.dp),
contentAlignment = Alignment.Center,
) {
DonutChart(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
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<DonutSegment>,
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<DonutSegment>,
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

View file

@ -1,23 +0,0 @@
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 = "",
)

View file

@ -1,43 +0,0 @@
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<DonutSegment>,
) {
data class Loaded(
val totalAmount: String,
override val donutSegmentList: List<DonutSegment>,
) : 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()
}