From 2ba90c5afced9f1368a4b688df5c1f807e39428e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 17:29:13 +0200 Subject: [PATCH] Updated on 2026-08-14 --- features/for-you/impl/build.gradle.kts | 2 + .../foryou/impl/components}/DonutChart.kt | 22 +- .../impl/components}/DonutSegmentTooltip.kt | 73 +---- .../impl/components}/GradientDivider.kt | 51 +-- .../foryou/impl/components}/MarketChart.kt | 292 ++++++------------ .../components/SegmentTooltipPositioning.kt | 116 +++++++ .../impl/components/state}/DonutSegment.kt | 6 +- .../components/state}/MarketChartState.kt | 10 +- 8 files changed, 268 insertions(+), 304 deletions(-) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/DonutChart.kt (94%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/DonutSegmentTooltip.kt (72%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/GradientDivider.kt (84%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components}/MarketChart.kt (59%) create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state}/DonutSegment.kt (84%) rename {core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models => features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state}/MarketChartState.kt (81%) diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index 93ffbfa390..05657670cc 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -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) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt similarity index 94% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index 9731c91966..9129c054a8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -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, 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(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 diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt similarity index 72% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt index 54475c3495..0748bbd462 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/DonutSegmentTooltip.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentTooltip.kt @@ -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") diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt similarity index 84% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt index ff05156520..cf7bccbd88 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/GradientDivider.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/GradientDivider.kt @@ -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 { +private fun canvasDefaultDots(lineHeight: Dp, colors: GlowDotColors): List { val step = lineHeight / 5 return listOf( - CanvasGlowDot(TangemColorPalette.Violet.`40`, DpOffset(x = 0.5.dp, y = (-3).dp), height = step), // purple - CanvasGlowDot(TangemColorPalette.Green.`40`, DpOffset(x = 4.dp, y = step * 1.7f), height = step), // green - CanvasGlowDot(TangemColorPalette.Blue.`40`, DpOffset(x = (-2.5).dp, y = step * 2.2f), height = step), // blue - CanvasGlowDot(TangemColorPalette.Orange.`40`, DpOffset(x = (-3.5).dp, y = step * 3), height = step + step / 2), - CanvasGlowDot(TangemColorPalette.Violet.`40`, DpOffset(x = 0.5.dp, y = step * 4 + 4.dp), height = step), + 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), ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt similarity index 59% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index eb82845a2e..b326db6fae 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -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(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, - 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 { + override val values: Sequence + 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 \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt new file mode 100644 index 0000000000..2049ea8f9e --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt @@ -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, + 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) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt similarity index 84% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt index 139b17a378..e37e432d5c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/DonutSegment.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/DonutSegment.kt @@ -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 = "", diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt similarity index 81% rename from core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt rename to features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt index 8306d36fef..74977d9c31 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds2/for_you_temp/models/MarketChartState.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/state/MarketChartState.kt @@ -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, ) { 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() } \ No newline at end of file