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

@ -22,8 +22,10 @@ dependencies {
implementation(deps.compose.ui)
implementation(deps.compose.foundation)
implementation(deps.compose.animation)
implementation(deps.lifecycle.compose)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
/** DI */
implementation(deps.hilt.android)

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.ds2.for_you_temp
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import android.graphics.BlurMaskFilter
@ -38,10 +38,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.TangemColorPalette
import com.tangem.features.foryou.impl.components.state.DonutSegment
import kotlin.math.min
/**
@ -68,7 +67,7 @@ import kotlin.math.min
* yet). Taps are hit-tested against the ring band only and reported via [onSegmentClick]; the chart is
* interactive only when [onSegmentClick] is set **and** [segments] is non-empty.
*
* @param segments Slices, in priority order (index 0 is painted on top). See [com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment.weight].
* @param segments Slices, in priority order (index 0 is painted on top). See [DonutSegment.weight].
* @param modifier Modifier; should carry the overall size (e.g. `Modifier.size(240.dp)`).
* @param selectedIndex Index of the currently selected slice, or `null` for no selection (nothing dimmed).
* @param onSegmentClick Invoked on every tap inside the chart: with the tapped slice index, or with `null`
@ -80,9 +79,9 @@ import kotlin.math.min
* @param startAngle Angle (degrees) where the first slice starts. `-90f` = 12 o'clock.
* @param content Centered content (e.g. total value + caption, or the "No data" label).
*/
@Suppress("MagicNumber", "LongParameterList")
@Suppress("MagicNumber", "LongParameterList", "LongMethod", "NamedArguments")
@Composable
fun DonutChart(
internal fun DonutChart(
segments: List<DonutSegment>,
modifier: Modifier = Modifier,
selectedIndex: Int? = null,
@ -93,7 +92,6 @@ fun DonutChart(
content: @Composable ColumnScope.() -> Unit,
) {
val strokePx = with(LocalDensity.current) { strokeWidth.toPx() }
// Theme-adaptive dim overlay for non-selected slices (black@20% in dark, white@20% in light).
val dimOverlayColor = TangemTheme.colors3.border.inverse.tertiary
// Fade the dim in/out in step with the segment tooltip's pop-in (same spring as DonutSegmentTooltip).
@ -102,14 +100,10 @@ fun DonutChart(
animationSpec = spring(dampingRatio = DIM_SPRING_DAMPING, stiffness = DIM_SPRING_STIFFNESS),
label = "donutDim",
)
// Keep the previously selected slice bright while the dim fades back out (selectedIndex is already null
// by then, so we can't rely on it during the exit animation).
var highlightedIndex by remember { mutableStateOf<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)
@ -327,8 +321,8 @@ private fun DrawScope.drawInnerShadowArc(
}
}
/** Inner shadow — Figma #FFFFFF at 24% opacity. */
private val InnerShadowColor = TangemColorPalette.Base.white.copy(alpha = 0.24f)
/** Inner shadow — Figma #FFFFFF at 24% opacity (theme-independent). */
private val InnerShadowColor = Color.White.copy(alpha = 0.24f)
// Selection-dim spring, mirroring DonutSegmentTooltip's pop-in so the dim and the tooltip move together.
private const val DIM_SPRING_DAMPING = 0.82f

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.ds2.for_you_temp
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import androidx.compose.animation.core.MutableTransitionState
@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
@ -20,25 +19,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlin.math.roundToInt
/**
* Frosted-glass selection tooltip for a [DonutChart] slice.
@ -70,15 +60,16 @@ import kotlin.math.roundToInt
* @param positionProvider Where the pill is placed. Defaults to centered over the anchor; pass a
* [SegmentTooltipPositionProvider] to anchor it to the end of the selected slice.
*/
@Suppress("LongParameterList")
@Composable
fun DonutSegmentTooltip(
modifier: Modifier = Modifier,
internal fun DonutSegmentTooltip(
expanded: Boolean,
title: String,
fiatValue: String,
percent: String,
positionProvider: PopupPositionProvider,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
@ -177,62 +168,6 @@ private fun TooltipPill(
}
}
/**
* Positions the pill relative to the **end of the selected slice**, per the agreed spec.
*
* - **Base:** the pill's bottom-center sits [gapPx] above [anchorInWindow] (screen-up). [anchorInWindow] is
* the slice-end point on the ring's inner edge, in window coordinates.
* - **Card fallback:** if that placement would push the pill above the top of [cardBoundsInWindow] (the
* `MarketChart` card), it flips to a side placement the pill's start-center sits [gapPx] to the right
* of the anchor.
* - **Screen clamp:** the result is finally kept inside the window with a [gapPx] margin (shifted back by
* however much it overflowed).
*
* @param anchorInWindow Slice-end / inner-edge point in window px.
* @param cardBoundsInWindow `MarketChart` card bounds in window px (only the top edge gates the fallback).
* @param gapPx The 8dp gap, in px.
*/
class SegmentTooltipPositionProvider(
private val anchorInWindow: Offset,
private val cardBoundsInWindow: Rect,
private val gapPx: Int,
private val strokePx: Int = 0,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val w = popupContentSize.width
val h = popupContentSize.height
val ax = anchorInWindow.x.roundToInt()
val ay = anchorInWindow.y.roundToInt()
// Base: bottom-center, gap above the anchor (screen-up).
var x = ax - w / 2
var y = ay - h - gapPx
val isFlip = y < cardBoundsInWindow.top
if (isFlip) {
x = ax + gapPx + (strokePx / 2)
y = ay - h / 2 + (strokePx / 2)
}
// Keep the pill inside the card on every edge, shifting it back by however much it overflows
// (with a gap margin). The card sits within the screen, so this also keeps the pill on-screen.
val minX = cardBoundsInWindow.left.roundToInt() + gapPx
val minY = cardBoundsInWindow.top.roundToInt() + gapPx
val maxX = (cardBoundsInWindow.right.roundToInt() - w - gapPx).coerceAtLeast(minX)
val maxY = (cardBoundsInWindow.bottom.roundToInt() - h - gapPx).coerceAtLeast(minY)
val clampedX = x.coerceIn(minX, maxX)
val clampedY = y.coerceIn(minY, maxY)
return IntOffset(clampedX, clampedY)
}
}
// region Preview
@Suppress("MagicNumber")

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.ds2.for_you_temp
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import android.graphics.BlurMaskFilter
@ -37,7 +37,6 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.TangemColorPalette
/**
* Canvas-based take on the glow divider: the line background and every blurred color blob are painted by
@ -56,24 +55,21 @@ import com.tangem.core.ui.res.generated.TangemColorPalette
* @param dots Color blobs; [GlowDot.offset] is the blob center relative to the line's top-center,
* [GlowDot.size] its diameter, [GlowDot.blur] the mask-blur radius.
*/
data class CanvasGlowDot(
internal data class CanvasGlowDot(
val color: Color,
val offset: DpOffset,
val height: Dp,
val blur: Dp = 8.dp,
)
@Suppress("MagicNumber", "LongParameterList")
@Suppress("MagicNumber", "LongParameterList", "LongMethod")
@Composable
fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
internal fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
val shape = RoundedCornerShape(size = 100.dp)
// Gentle "breathing" glow: pulse the drop-shadow alpha between MIN and MAX. Designer hasn't
// provided timing yet, so 1600ms per direction reads as a calm, non-distracting pulse.
val infiniteTransition = rememberInfiniteTransition(label = "GlowDividerShadow")
val shadowAlpha by infiniteTransition.animateFloat(
initialValue = GlowMinAlpha,
targetValue = GlowMaxAlpha,
initialValue = GLOW_MIN_ALPHA,
targetValue = GLOW_MAX_ALPHA,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
@ -81,11 +77,19 @@ fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
label = "GlowDividerShadowAlpha",
)
val accent = TangemTheme.colors3.icon.accent
val dotColors = GlowDotColors(
violet = accent.violet,
green = accent.green,
blue = accent.blue,
orange = accent.orange,
)
Box(
modifier = modifier
.width(lineWidth)
.drawBehind {
val resolvedDots = canvasDefaultDots(size.height.toDp())
val resolvedDots = canvasDefaultDots(size.height.toDp(), dotColors)
val cornerPx = size.width / 2f
val capsule = Path().apply {
addRoundRect(
@ -100,7 +104,6 @@ fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
}
clipPath(capsule) {
// Base background.
drawRect(color = LineColor)
// Blurred color blobs, drawn with a native BlurMaskFilter paint.
@ -143,8 +146,8 @@ fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
}
/** Glow pulse bounds for the animated drop shadow alpha. */
private const val GlowMinAlpha = 0.3f
private const val GlowMaxAlpha = 0.65f
private const val GLOW_MIN_ALPHA = 0.3f
private const val GLOW_MAX_ALPHA = 0.65f
/** Line fill — Figma "Background color" #0000F9 at 56% opacity (alpha 0x8F). */
private val LineColor = Color(0x8F0000F9)
@ -152,19 +155,27 @@ private val LineColor = Color(0x8F0000F9)
/** Fixed blob width (the oval's horizontal diameter). */
private val DotWidth = 12.dp
/** Accent colors for the glow blobs, resolved from `colors3.icon.accent.*` in the composable. */
private data class GlowDotColors(
val violet: Color,
val green: Color,
val blue: Color,
val orange: Color,
)
/**
* Placeholder snake-scatter of the four Figma "selection colors", sized proportionally to [lineHeight]
* so the glow scales with the divider's length. Tune to match Figma.
*/
@Suppress("MagicNumber")
private fun canvasDefaultDots(lineHeight: Dp): List<CanvasGlowDot> {
private fun canvasDefaultDots(lineHeight: Dp, colors: GlowDotColors): 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),
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = (-3).dp), height = step), // purple
CanvasGlowDot(colors.green, DpOffset(x = 4.dp, y = step * 1.7f), height = step), // green
CanvasGlowDot(colors.blue, DpOffset(x = (-2.5).dp, y = step * 2.2f), height = step), // blue
CanvasGlowDot(colors.orange, DpOffset(x = (-3.5).dp, y = step * 3), height = step + step / 2),
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = step * 4 + 4.dp), height = step),
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.ds2.for_you_temp
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
@ -42,33 +42,30 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupPositionProvider
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds2.for_you_temp.models.AiInsightState
import com.tangem.core.ui.ds2.for_you_temp.models.DonutChartState
import com.tangem.core.ui.ds2.for_you_temp.models.DonutSegment
import com.tangem.core.ui.ds2.for_you_temp.models.MarketChartState
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.components.state.AiInsightState
import com.tangem.features.foryou.impl.components.state.DonutChartState
import com.tangem.features.foryou.impl.components.state.DonutSegment
import com.tangem.features.foryou.impl.components.state.MarketChartState
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.Int
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
@Composable
fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) {
internal fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) {
val hazeState = LocalHazeState.current
// Card bounds in window px — gates the tooltip's "flip to the side" fallback (see DonutChartBlock).
var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) }
TangemSurface(
@ -95,6 +92,7 @@ fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifie
}
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBoundsInWindow: Rect) {
var selectedIndex by remember { mutableStateOf<Int?>(null) }
@ -114,9 +112,9 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo
DonutChart(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
chartSize = it.size
chartWindowOffset = it.localToWindow(Offset.Zero)
.onGloballyPositioned { coordinates ->
chartSize = coordinates.size
chartWindowOffset = coordinates.localToWindow(Offset.Zero)
}
// A press on the chart means this tap is "on the chart", not "outside" — veto the pending
// outside-dismiss before it commits.
@ -180,12 +178,12 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo
withFrameNanos { }
selectedIndex = null
}
}
},
)
}
}
@Suppress("LongParameterList")
@Composable
private fun DonutSegmentTooltipBlock(
selectedIndex: Int?,
@ -201,7 +199,13 @@ private fun DonutSegmentTooltipBlock(
val selectedSegment = selectedIndex?.let(segments::getOrNull)
val positionProvider = remember(
selectedIndex, segments, chartSize, chartWindowOffset, cardBoundsInWindow, strokePx, gapPx,
selectedIndex,
segments,
chartSize,
chartWindowOffset,
cardBoundsInWindow,
strokePx,
gapPx,
) {
segmentTooltipPositionProvider(
selectedIndex = selectedIndex,
@ -209,6 +213,7 @@ private fun DonutSegmentTooltipBlock(
chartSize = chartSize,
chartWindowOffset = chartWindowOffset,
strokePx = strokePx,
startAngle = DonutStartAngle,
cardBoundsInWindow = cardBoundsInWindow,
gapPx = gapPx,
)
@ -220,7 +225,7 @@ private fun DonutSegmentTooltipBlock(
title = selectedSegment?.title.orEmpty(),
fiatValue = selectedSegment?.fiatValue.orEmpty(),
percent = selectedSegment?.let { formatSegmentPercent(it.weight) }.orEmpty(),
onDismissRequest = onDismissRequest
onDismissRequest = onDismissRequest,
)
}
@ -231,52 +236,9 @@ private fun formatSegmentPercent(weight: Float): String {
}
private val DonutStrokeWidth = 28.dp
private val DonutStartAngle = -90f
private const val DonutStartAngle = -90f
private val TooltipGap = 8.dp
/**
* Builds the tooltip position provider anchored to the end of the selected slice. Returns the centered
* fallback while the chart hasn't been measured yet or nothing is selected.
*/
@Suppress("MagicNumber")
private fun segmentTooltipPositionProvider(
selectedIndex: Int?,
segments: List<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(
@ -304,6 +266,8 @@ private fun ColumnScope.CantLoadDataBlock() {
)
}
// IntrinsicSize.Min lets the gradient divider match the AI text height — heightIn wouldn't achieve that.
@Suppress("ModifierHeightWithText")
@Composable
private fun AiInsightContent(aiInsightState: AiInsightState) {
AnimatedContent(
@ -318,7 +282,7 @@ private fun AiInsightContent(aiInsightState: AiInsightState) {
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
onClick = currentState.askAiInsightClick,
size = TangemButtonSize.X9,
text = stringReference("Ask for AI summary")
text = stringReference("Ask for AI summary"),
)
}
is AiInsightState.Displayed -> {
@ -362,152 +326,94 @@ private fun AiInsightContent(aiInsightState: AiInsightState) {
// region Previews
private enum class MarketChartPreviewScenario { DISPLAYED, ASK_AI, NO_AI, NO_DATA }
private class MarketChartPreviewProvider : PreviewParameterProvider<MarketChartPreviewScenario> {
override val values: Sequence<MarketChartPreviewScenario>
get() = MarketChartPreviewScenario.entries.asSequence()
}
@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "MarketChart • Light", showBackground = true)
@Composable
private fun PreviewMarketChart() {
private fun MarketChart_Preview(
@PreviewParameter(MarketChartPreviewProvider::class) scenario: MarketChartPreviewScenario,
) {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
MarketChart(
MarketChartState.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.Displayed(
"Your portfolio leans on a single asset BTC is 42% of holdings. Stablecoins add 23% " +
"buffer. Consider trimmng concentration for a smoother ride",
),
donutChartState = DonutChartState.Loaded(
totalAmount = "$10,123456.1333",
donutSegmentList = listOf(
DonutSegment(
weight = 0.55f,
color = TangemTheme.colors3.border.brand,
title = "Ethereum",
fiatValue = "$5,720.22",
),
DonutSegment(
weight = 0.07f,
color = TangemTheme.colors3.border.accent.violet,
title = "Solana",
fiatValue = "$728.30",
),
DonutSegment(
weight = 0.06f,
color = TangemTheme.colors3.border.accent.red,
title = "Polkadot",
fiatValue = "$624.26",
),
DonutSegment(
weight = 0.05f,
color = TangemTheme.colors3.border.accent.green,
title = "Tether",
fiatValue = "$520.18",
),
),
),
),
)
MarketChart(marketChartState = previewMarketChartState(scenario))
}
}
}
@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "MarketChart • Light", showBackground = true)
/**
* Maps a [scenario] to the state shown. Built inside a `@Composable` (not the [PreviewParameterProvider])
* because the segment colors come from [TangemTheme.colors3], which can only be read in composition.
*/
@Suppress("MagicNumber")
@Composable
private fun PreviewMarketChartAskAI() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
MarketChart(
MarketChartState.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.AskAiInsight(askAiInsightClick = {}),
donutChartState = DonutChartState.Loaded(
totalAmount = "$10,123456.1333",
donutSegmentList = listOf(
DonutSegment(
weight = 0.55f,
color = TangemTheme.colors3.border.brand,
title = "Ethereum",
fiatValue = "$5,720.22",
),
DonutSegment(
weight = 0.07f,
color = TangemTheme.colors3.border.accent.violet,
title = "Solana",
fiatValue = "$728.30",
),
DonutSegment(
weight = 0.06f,
color = TangemTheme.colors3.border.accent.red,
title = "Polkadot",
fiatValue = "$624.26",
),
DonutSegment(
weight = 0.05f,
color = TangemTheme.colors3.border.accent.green,
title = "Tether",
fiatValue = "$520.18",
),
),
),
),
)
}
}
private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartState = when (scenario) {
MarketChartPreviewScenario.DISPLAYED -> MarketChartState.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.Displayed(
"Your portfolio leans on a single asset BTC is 42% of holdings. Stablecoins add 23% " +
"buffer. Consider trimmng concentration for a smoother ride",
),
donutChartState = previewLoadedDonut(),
)
MarketChartPreviewScenario.ASK_AI -> MarketChartState.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.AskAiInsight(askAiInsightClick = {}),
donutChartState = previewLoadedDonut(),
)
MarketChartPreviewScenario.NO_AI -> MarketChartState.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.Hide,
donutChartState = DonutChartState.Loaded(
totalAmount = "$10,12345678912.1333",
donutSegmentList = listOf(
DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand),
DonutSegment(weight = 0.45f, color = TangemTheme.colors3.border.accent.green),
),
),
)
MarketChartPreviewScenario.NO_DATA -> MarketChartState.NoData
}
@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "MarketChart • Light", showBackground = true)
@Suppress("MagicNumber")
@Composable
private fun PreviewMarketChartNoAi() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
MarketChart(
MarketChartState.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.Hide,
donutChartState = DonutChartState.Loaded(
totalAmount = "$10,12345678912.1333",
donutSegmentList = listOf(
DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand),
DonutSegment(weight = 0.45f, color = TangemTheme.colors3.border.accent.green),
),
),
),
)
}
}
}
@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "MarketChart • Light", showBackground = true)
@Composable
private fun PreviewMarketChartNoData() {
TangemThemePreviewRedesign {
Box(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
MarketChart(
MarketChartState.NoData,
)
}
}
}
private fun previewLoadedDonut(): DonutChartState.Loaded = DonutChartState.Loaded(
totalAmount = "$10,123456.1333",
donutSegmentList = listOf(
DonutSegment(
weight = 0.55f,
color = TangemTheme.colors3.border.brand,
title = "Ethereum",
fiatValue = "$5,720.22",
),
DonutSegment(
weight = 0.07f,
color = TangemTheme.colors3.border.accent.violet,
title = "Solana",
fiatValue = "$728.30",
),
DonutSegment(
weight = 0.06f,
color = TangemTheme.colors3.border.accent.red,
title = "Polkadot",
fiatValue = "$624.26",
),
DonutSegment(
weight = 0.05f,
color = TangemTheme.colors3.border.accent.green,
title = "Tether",
fiatValue = "$520.18",
),
),
)
// endregion

View file

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

View file

@ -1,9 +1,9 @@
package com.tangem.core.ui.ds2.for_you_temp.models
package com.tangem.features.foryou.impl.components.state
import androidx.compose.ui.graphics.Color
/**
* One colored slice of a [com.tangem.core.ui.ds2.for_you_temp.DonutChart].
* One colored slice of a [com.tangem.features.foryou.impl.components.DonutChart].
*
* @param weight Fraction of the full circle this slice occupies, in `0f..1f`. The slices are laid out
* contiguously; whatever is left after `sum(weight)` shows through as the track.
@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color
* @param fiatValue Pre-formatted fiat value of the slice (e.g. `"$5,720.22"`). Shown in the selection
* tooltip next to the share. Empty by default.
*/
data class DonutSegment(
internal data class DonutSegment(
val weight: Float,
val color: Color,
val title: String = "",

View file

@ -1,9 +1,9 @@
package com.tangem.core.ui.ds2.for_you_temp.models
package com.tangem.features.foryou.impl.components.state
import kotlin.Float
import kotlin.collections.List
sealed class MarketChartState(
internal sealed class MarketChartState(
open val donutChartState: DonutChartState,
open val aiInsightState: AiInsightState,
) {
@ -25,7 +25,7 @@ sealed class MarketChartState(
)
}
sealed class DonutChartState(
internal sealed class DonutChartState(
open val donutSegmentList: List<DonutSegment>,
) {
data class Loaded(
@ -36,8 +36,8 @@ sealed class DonutChartState(
data object NoData : DonutChartState(donutSegmentList = emptyList())
}
sealed class AiInsightState {
internal sealed class AiInsightState {
data object Hide : AiInsightState()
data class AskAiInsight(val askAiInsightClick: () -> Unit): AiInsightState()
data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightState()
data class Displayed(val text: String) : AiInsightState()
}