Updated on 2026-08-14
This commit is contained in:
parent
cebe0dcdf1
commit
2ba90c5afc
8 changed files with 268 additions and 304 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = "",
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue