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