From af8431909292df8ec870727f58636d319e8829a3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 15:38:13 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .claude/rules/codestyle/design-system.md | 15 +- .../core/ui/ds2/glowring/TangemGlowRing.kt | 297 ++++++++++++++++++ .../ui/ds2/glowring/TangemGlowRingInternal.kt | 231 ++++++++++++++ .../com/tangem/core/ui/res/TangemTheme.kt | 2 + .../storybook/entity/StoryBookPage.kt | 20 ++ .../page/ds/DsComponentsListScreen.kt | 2 + .../storybook/page/ds/glowring/Build.kt | 30 ++ .../page/ds/glowring/TangemGlowRingStory.kt | 227 +++++++++++++ .../storybook/ui/StoryBookScreen.kt | 2 + 9 files changed, 821 insertions(+), 5 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt diff --git a/.claude/rules/codestyle/design-system.md b/.claude/rules/codestyle/design-system.md index 99b4534e6b..5872714e4f 100644 --- a/.claude/rules/codestyle/design-system.md +++ b/.claude/rules/codestyle/design-system.md @@ -19,10 +19,12 @@ generation a component belongs to is essential so you don't mix tokens or pull t |---|---|---|---|---|---| | **DS1** (legacy) | `core/ui/src/main/java/com/tangem/core/ui/components/` | `TangemTheme.colors` | `TangemTheme.typography` | `TangemTheme.dimens` | `TangemThemePreview` | | **DS2** (redesign) | `core/ui/src/main/java/com/tangem/core/ui/ds/` | `TangemTheme.colors2` | `TangemTheme.typography2` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | -| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | +| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | literal `.dp` (no token) | `TangemThemePreviewRedesign` | > Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**. > The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`). +> **DS3 has no dimension token** — `dimens2` is a DS2 token and must **not** be used in `ds2/` +> components. Express dimensions as literal `.dp` values (see rule 2 below). - **DS1** — the entire current app is built on it. Do **not** add new components here. - **DS2** — redesign components. A transitional generation; don't write new components in it, only @@ -49,9 +51,11 @@ Pattern rules: 1. **Package & location.** `com.tangem.core.ui.ds2.`, folder `core/ui/.../ds2//`. The component name is `Tangem`. -2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`, - dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors - are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`). +2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`. No + `colors` / `colors2` and no hardcoded colors outside `@Preview`. **Dimensions have no DS3 token** — + do **not** use `TangemTheme.dimens2.*` (it is a DS2 token); express dimensions as literal `.dp` + values and add `@Suppress("MagicNumber")` to the composable (or a `…Ext.kt` / `…Internal.kt` token + holder, as `TangemButtonInternal.kt` and `TangemCheckmark.kt` do). 3. **Signature.** `modifier: Modifier = Modifier` is mandatory (defaulting to `Modifier`, placed first among the optional params or right after the required ones). Express variants/sizes via a nested `enum` in `object Tangem` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags. @@ -156,7 +160,8 @@ Page layout guidelines live in - [ ] Component created under `core/ui/.../ds2//`, package `com.tangem.core.ui.ds2.`. - [ ] Named `Tangem`; first optional parameter is `modifier: Modifier = Modifier`. -- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`, `dimens2`. No hardcoded values outside previews. +- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`. No hardcoded colors outside previews. Dimensions + are literal `.dp` (DS3 has no dimension token — never use `dimens2`), with `@Suppress("MagicNumber")`. - [ ] Variants/sizes expressed as an `enum` inside `object Tangem` (not a set of boolean flags). - [ ] All public types (enums, statuses, constants) declared inside the `object Tangem`. - [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets). diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt new file mode 100644 index 0000000000..b8b5f4b822 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt @@ -0,0 +1,297 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.glowring + +import android.os.Build +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +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.TangemColors3 + +/** + * Design-system v2 (DS3) **Glow Ring** — an animated angular-gradient halo that runs around a + * rounded-rect outline, like lights chasing along the border. + * + * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=4933-126&m=dev) + * + * @param modifier Modifier for the whole component; also defines its size when there is no [content]. + * @param variant Color theme of the gradient — see [TangemGlowRing.Variant]. + * @param cornerRadius Corner radius of the ring; should match the radius of the wrapped surface. + * @param animated When `false`, the ring is rendered static (no rotation). + * @param quality Rendering strategy; defaults to [TangemGlowRing.Quality.Auto] (device-appropriate). + * Force [TangemGlowRing.Quality.LayeredStrokes] to preview the pre-Android-12 fallback on any device. + * @param contentDescription Accessibility label; pass a value when the ring conveys state (e.g. error), + * leave `null` when it is purely decorative. + * @param content Optional content drawn inside/over the ring. + */ +@Composable +fun TangemGlowRing( + modifier: Modifier = Modifier, + variant: TangemGlowRing.Variant = TangemGlowRing.Variant.Magic, + cornerRadius: Dp = 24.dp, + animated: Boolean = true, + quality: TangemGlowRing.Quality = TangemGlowRing.Quality.Auto, + contentDescription: String? = null, + content: @Composable BoxScope.() -> Unit = {}, +) { + val resolved = remember(quality) { resolveQuality(quality) } + val stops = rememberGlowRingStops(variant, animated) + val metrics = remember { + GlowRingMetrics(coreWidth = 2.dp, ringWidth = 4.dp, blurMid = 8.dp, blurBottom = 16.dp) + } + + val angle = if (animated) { + val transition = rememberInfiniteTransition(label = "glowRing") + val rotation by transition.animateFloat( + initialValue = GLOW_RING_START_ANGLE, + targetValue = 270f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 24_000, + easing = CubicBezierEasing(a = 0.1f, b = 0f, c = 0.9f, d = 1f), + ), + repeatMode = RepeatMode.Restart, + ), + label = "angle", + ) + rotation + } else { + GLOW_RING_START_ANGLE + } + + Box( + modifier = if (contentDescription != null) { + modifier.semantics { this.contentDescription = contentDescription } + } else { + modifier + }, + ) { + val ringModifier = Modifier.matchParentSize() + when (resolved) { + ResolvedGlowRingQuality.Blur -> BlurGlowRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + metrics = metrics, + modifier = ringModifier, + ) + ResolvedGlowRingQuality.LayeredStrokes -> LayeredStrokesGlowRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + metrics = metrics, + modifier = ringModifier, + ) + } + content() + } +} + +/** Sweep start angle, also reused as the static angle when [TangemGlowRing] is not animated (Figma: -90°). */ +private const val GLOW_RING_START_ANGLE = -90f + +/** + * Resolves the gradient stops for [variant] from the DS3 `colors3.glow` tokens. The + * [TangemGlowRing.Variant.Magic] variant continuously ping-pongs between gradient A (`glow.magic`) and + * gradient B (`glow.magicBlend`) while [animated] is `true`; every other variant has a single static + * gradient. + */ +@Composable +private fun rememberGlowRingStops(variant: TangemGlowRing.Variant, animated: Boolean): List> { + val glow = TangemTheme.colors3.glow + if (variant != TangemGlowRing.Variant.Magic || !animated) { + return variant.stops(glow) + } + val morphTransition = rememberInfiniteTransition(label = "glowRingMorph") + val mix by morphTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + // 6s A→B half-period; Reverse makes a 12s ping-pong (Figma morphDur = 12s). + animation = tween(durationMillis = 6_000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "morphMix", + ) + return morphedMagicStops(glow.magic.steps(), glow.magicBlend.steps(), mix) +} + +/** Public API surface of [TangemGlowRing]. */ +object TangemGlowRing { + + /** Color theme of the glow ring gradient. */ + enum class Variant { + /** + * Multi-color "magic" gradient that continuously auto-morphs (ping-pongs) between separated + * orange / blue / purple arcs and a continuous fully-saturated blend. + */ + Magic, + + /** Green success glow. */ + Success, + + /** Red error glow. */ + Error, + + /** Orange/amber warning glow. */ + Warning, + + /** Blue informational glow. */ + Info, + } + + /** + * Rendering strategy for the glow. + * + * [Auto] picks the best renderer for the current device — a real Gaussian blur on Android 12+ + * (API 31) and a layered-stroke approximation on older versions. The explicit values force one + * renderer regardless of API level; they exist mainly for previews / Storybook so the + * pre-Android-12 fallback can be inspected on a modern device. Product code should use [Auto]. + */ + enum class Quality { + /** Auto-detect the renderer from the device API level (recommended). */ + Auto, + + /** Force the Android 12+ real-blur renderer. */ + Blur, + + /** Force the pre-Android-12 layered-stroke fallback. */ + LayeredStrokes, + } +} + +/** + * Resolves [quality] to a concrete renderer. [TangemGlowRing.Quality.Auto] picks a real blur on + * Android 12+ (API 31) and falls back to stacked translucent strokes on older versions; the explicit + * values force their renderer regardless of API level. + */ +private fun resolveQuality(quality: TangemGlowRing.Quality): ResolvedGlowRingQuality = when (quality) { + TangemGlowRing.Quality.Blur -> ResolvedGlowRingQuality.Blur + TangemGlowRing.Quality.LayeredStrokes -> ResolvedGlowRingQuality.LayeredStrokes + TangemGlowRing.Quality.Auto -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ResolvedGlowRingQuality.Blur + } else { + ResolvedGlowRingQuality.LayeredStrokes + } +} + +/** Concrete rendering strategy chosen by [resolveQuality]. */ +private enum class ResolvedGlowRingQuality { Blur, LayeredStrokes } + +/** + * Builds the angular-gradient stops for [this] variant from its DS3 `colors3.glow` token group. Every + * variant token exposes the same 10 [steps] — solid arcs at steps 1/4/7, a faint arc at 9 and transparent + * gaps elsewhere — which [glowStops] lays out as evenly-spaced, seamlessly-looping stops. + */ +private fun TangemGlowRing.Variant.stops(glow: TangemColors3.Glow): List> = glowStops( + when (this) { + TangemGlowRing.Variant.Magic -> glow.magic.steps() + TangemGlowRing.Variant.Success -> glow.success.steps() + TangemGlowRing.Variant.Error -> glow.error.steps() + TangemGlowRing.Variant.Warning -> glow.warning.steps() + TangemGlowRing.Variant.Info -> glow.info.steps() + }, +) + +/** + * Blends the Magic gradients A ([magic] = `glow.magic`) and B ([magicBlend] = `glow.magicBlend`) at + * [mix] (`0` = A, `1` = B). Both token groups share the same stop positions, so the morph is a direct + * per-step color lerp. Mirrors the reference rig's auto-morph (ping-pong) between gradient A and B. + */ +private fun morphedMagicStops(magic: List, magicBlend: List, mix: Float): List> { + val m = mix.coerceIn(0f, 1f) + return glowStops(List(magic.size) { lerp(magic[it], magicBlend[it], m) }) +} + +/** + * Lays the glow [steps] out as an angular gradient: evenly spaced from `0`, with step 1 repeated at `1.0` + * so the rotation loops seamlessly. Transparent steps create the gaps between the glowing arcs. + */ +private fun glowStops(steps: List): List> { + val count = steps.size + return steps.mapIndexed { index, color -> index.toFloat() / count to color } + (1f to steps.first()) +} + +private fun TangemColors3.Glow.Magic.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.MagicBlend.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Success.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Error.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Warning.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Info.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemGlowRingPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Magic, + ) + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Success, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Error, + ) + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Warning, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Info, + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt new file mode 100644 index 0000000000..cb4f4f1ad5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt @@ -0,0 +1,231 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.glowring + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.Dp +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min + +/** + * Token-driven measurements shared by both renderers, mirroring the Figma component anatomy: + * a crisp [coreWidth] core line plus two wider, blurred glow bands ([ringWidth] stroked, blurred by + * [blurMid] and [blurBottom]). + */ +internal data class GlowRingMetrics( + val coreWidth: Dp, // crisp core stroke (top layer) + val ringWidth: Dp, // glow band stroke (mid + bottom layers) + val blurMid: Dp, // mid glow blur radius + val blurBottom: Dp, // widest glow blur radius +) + +/** + * Tier 1 — works on every API level, no blur or shader. Approximates the blurred glow by stacking the + * same breathing angular-gradient ring several times: progressively wider + fainter bands under a crisp + * core. Everything is clipped to the rounded box, so only the inner half of each band shows → inner glow. + */ +@Composable +internal fun LayeredStrokesGlowRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + metrics: GlowRingMetrics, + modifier: Modifier = Modifier, +) { + Box(modifier.clip(RoundedCornerShape(cornerRadius))) { + Canvas(Modifier.fillMaxSize()) { + val r = cornerRadius.toPx() + // widest & faintest first, crisp core last + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = (metrics.ringWidth + metrics.blurBottom).toPx(), + alpha = 0.06f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = (metrics.ringWidth + metrics.blurMid).toPx(), + alpha = 0.12f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = metrics.ringWidth.toPx(), + alpha = 0.30f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = metrics.coreWidth.toPx(), + alpha = 1.0f, + ) + } + } +} + +/** + * Tier 2 — Android 12+ (API 31). Reproduces the Figma anatomy directly: three stacked breathing + * angular-gradient rings with real blur (bottom widest, mid, top crisp). Each layer bleeds with + * [BlurredEdgeTreatment.Unbounded]; the surrounding [clip] to the rounded box keeps only the inner + * bloom, producing the inner glow. + */ +@Composable +internal fun BlurGlowRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + metrics: GlowRingMetrics, + modifier: Modifier = Modifier, +) { + Box(modifier.clip(RoundedCornerShape(cornerRadius))) { + // bottom — widest halo + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.ringWidth, + modifier = Modifier + .fillMaxSize() + .blur(radius = metrics.blurBottom, edgeTreatment = BlurredEdgeTreatment.Unbounded), + ) + // mid + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.ringWidth, + modifier = Modifier + .fillMaxSize() + .blur(radius = metrics.blurMid, edgeTreatment = BlurredEdgeTreatment.Unbounded), + ) + // top — crisp core + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.coreWidth, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun BreathingRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + strokeWidth: Dp, + modifier: Modifier = Modifier, +) { + Canvas(modifier) { + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = cornerRadius.toPx(), + strokePx = strokeWidth.toPx(), + alpha = 1f, + ) + } +} + +/** + * Draws one angular-gradient ring band clipped to the rounded-rect stroke outline. The gradient is a + * sweep whose colour seam is rotated by [angleDeg] (via [rotatedStops]) and whose vertical squish + * breathes between W/2 and W/8 over the rotation (`rxM = mid + amp·cos(2φ)`), reproducing the morphing + * arcs of the reference rig. + */ +private fun DrawScope.drawBreathingRing( + stops: List>, + angleDeg: Float, + cornerRadiusPx: Float, + strokePx: Float, + alpha: Float, +) { + val w = size.width + val h = size.height + if (w <= 0f || h <= 0f) return + val center = Offset(w / 2f, h / 2f) + + // Breathing horizontal radius of the gradient ellipse → vertical squish of the angle sampling. + val maxRx = w / 2f + val minRx = w / 8f + val mid = (maxRx + minRx) / 2f + val amp = max((maxRx - minRx) / 2f, 0f) + val phaseRad = Math.toRadians(angleDeg.toDouble()).toFloat() + val rxM = mid + amp * cos(2f * phaseRad) + val scaleY = h / 2f / max(rxM, 1f) + + val r = min(cornerRadiusPx, min(w, h) / 2f) + val o = strokePx / 2f + val ring = Path().apply { + fillType = PathFillType.EvenOdd + addRoundRect( + RoundRect(rect = Rect(Offset(-o, -o), Size(w + 2f * o, h + 2f * o)), cornerRadius = CornerRadius(r + o)), + ) + addRoundRect( + RoundRect( + rect = Rect(Offset(o, o), Size(w - 2f * o, h - 2f * o)), + cornerRadius = CornerRadius(max(r - o, 0f)), + ), + ) + } + + val brush = Brush.sweepGradient(colorStops = rotatedStops(stops, angleDeg), center = center) + val big = max(w, h) * 4f + clipPath(ring) { + withTransform({ scale(scaleX = 1f, scaleY = scaleY, pivot = center) }) { + drawRect( + brush = brush, + topLeft = Offset(center.x - big / 2f, center.y - big / 2f), + size = Size(big, big), + alpha = alpha, + ) + } + } +} + +/** + * Compose's [Brush.sweepGradient] has no start-angle parameter, so the colour seam is rotated by + * shifting every stop position by `deg/360` (wrapping around the loop) and re-anchoring boundary stops + * at 0 and 1 with the interpolated wrap colour. Mirrors `rotatedStops` from the reference rig. + */ +private fun rotatedStops(base: List>, deg: Float): Array> { + val d = (deg / 360f % 1f + 1f) % 1f + val uniq = base.dropLast(1) // drop the duplicate wrap stop at 1.0 + val shifted = uniq + .map { (p, c) -> ((p + d) % 1f + 1f) % 1f to c } + .sortedBy { it.first } + val first = shifted.first() + val last = shifted.last() + val span = first.first + 1f - last.first + val wrapFraction = if (span > 1e-6f) (1f - last.first) / span else 0f + val wrapColor = lerp(last.second, first.second, wrapFraction) + return (listOf(0f to wrapColor) + shifted + listOf(1f to wrapColor)).toTypedArray() +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index d95e93902a..7ea6cfb4c7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -190,11 +190,13 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemTypography3.current + @Deprecated("Use plain dp") val dimens: TangemDimens @Composable @ReadOnlyComposable get() = LocalTangemDimens.current + @Deprecated("Use plain dp") val dimens2: TangemDimens2 @Composable @ReadOnlyComposable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index 2e1b42692f..b380d317b6 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.ds2.badge.TangemBadge import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.fade.TangemFade +import com.tangem.core.ui.ds2.glowring.TangemGlowRing import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment @@ -351,6 +352,25 @@ internal data class TangemCheckmarkStory( val onEnabledToggle: () -> Unit, ) : DsStoryBookPage +internal data class TangemGlowRingStory( + val variant: TangemGlowRing.Variant, + val quality: TangemGlowRing.Quality, + val background: Background, + val isAnimated: Boolean, + val onVariantChange: (TangemGlowRing.Variant) -> Unit, + val onQualityChange: (TangemGlowRing.Quality) -> Unit, + val onBackgroundChange: (Background) -> Unit, + val onAnimatedToggle: () -> Unit, +) : DsStoryBookPage { + + /** Backdrop the glow-ring preview is rendered on top of. */ + enum class Background(val label: String) { + BgPrimary("bg.primary"), + BgSecondary("bg.secondary"), + BgInverse("bg.inverse"), + } +} + internal data class TangemBadgeV2Story( val variant: TangemBadge.Variant, val status: TangemBadge.Status, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 1083082fea..fc0a092152 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -20,6 +20,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemBut import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.tangemCheckboxV2StoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.tangemCheckmarkStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.tangemGlowRingStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory @@ -39,6 +40,7 @@ private fun buildDsStories() = listOf( DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory), DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory), DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory), + DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt new file mode 100644 index 0000000000..879601eb75 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt @@ -0,0 +1,30 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.glowring + +import com.tangem.core.ui.ds2.glowring.TangemGlowRing +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemGlowRingStory { + return TangemGlowRingStory( + variant = TangemGlowRing.Variant.Magic, + quality = TangemGlowRing.Quality.Auto, + background = TangemGlowRingStory.Background.BgPrimary, + isAnimated = true, + onVariantChange = { variant -> + updateStory { it.copy(variant = variant) } + }, + onQualityChange = { quality -> + updateStory { it.copy(quality = quality) } + }, + onBackgroundChange = { background -> + updateStory { it.copy(background = background) } + }, + onAnimatedToggle = { + updateStory { it.copy(isAnimated = !it.isAnimated) } + }, + ) +} + +internal val tangemGlowRingStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt new file mode 100644 index 0000000000..6e019d42e0 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt @@ -0,0 +1,227 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.glowring + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds2.glowring.TangemGlowRing +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory.Background + +@Composable +internal fun TangemGlowRingStory(state: TangemGlowRingStory, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Preview stays pinned at the top. + ComponentPreview(state = state) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + QualitySelector(selected = state.quality, onSelect = state.onQualityChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + Toggles(state = state) + } + } +} + +@Composable +private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) { + when (background) { + Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary)) + Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary)) + Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse)) + } +} + +@Composable +private fun ComponentPreview(state: TangemGlowRingStory) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)), + ) { + PreviewBackground( + background = state.background, + modifier = Modifier.matchParentSize(), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + ) { + TangemGlowRing( + modifier = Modifier.size(width = 200.dp, height = 120.dp), + variant = state.variant, + animated = state.isAnimated, + quality = state.quality, + ) + } + } +} + +@Composable +private fun VariantSelector(selected: TangemGlowRing.Variant, onSelect: (TangemGlowRing.Variant) -> Unit) { + Section(label = "Variant") { + ChipGrid( + items = TangemGlowRing.Variant.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun QualitySelector(selected: TangemGlowRing.Quality, onSelect: (TangemGlowRing.Quality) -> Unit) { + Section(label = "Quality (renderer)") { + ChipGrid( + items = TangemGlowRing.Quality.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) { + Section(label = "Background") { + ChipGrid( + items = Background.entries, + label = { it.label }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun Toggles(state: TangemGlowRingStory) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "animated", checked = state.isAnimated, onToggle = state.onAnimatedToggle) + } + } +} + +@Composable +private fun Section(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = label, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } +} + +@Composable +private fun ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TangemTheme.colors2.surface.level2) + .clickable(onClick = onToggle) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = if (checked) "ON" else "OFF", + style = TangemTheme.typography.caption2, + color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index fb4ee3a9a7..be9720bd99 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -42,6 +42,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemBut import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.TangemCheckboxV2Story import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.TangemCheckmarkStory import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory +import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.TangemGlowRingStory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory @@ -96,6 +97,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState) is TangemCheckboxV2Story -> TangemCheckboxV2Story(state = storyState) is TangemCheckmarkStory -> TangemCheckmarkStory(state = storyState) + is TangemGlowRingStory -> TangemGlowRingStory(state = storyState) is TangemRowStory -> TangemRowStory(state = storyState) is TangemSearchStory -> TangemSearchStory(state = storyState) is TangemShimmerStory -> TangemShimmerStory(state = storyState)