SettingsScreen.kt

31.5 KB · raw

1package com.peachpasswords.ui.settings23import androidx.activity.compose.BackHandler4import androidx.compose.animation.core.Animatable5import androidx.compose.animation.core.LinearOutSlowInEasing6import androidx.compose.animation.core.Spring7import androidx.compose.animation.core.animateDpAsState8import androidx.compose.animation.core.animateFloat9import androidx.compose.animation.core.animateFloatAsState10import androidx.compose.animation.core.infiniteRepeatable11import androidx.compose.animation.core.keyframes12import androidx.compose.animation.core.rememberInfiniteTransition13import androidx.compose.animation.core.spring14import androidx.compose.animation.core.tween15import androidx.compose.foundation.background16import androidx.compose.foundation.clickable17import androidx.compose.foundation.gestures.detectDragGestures18import androidx.compose.foundation.interaction.MutableInteractionSource19import androidx.compose.foundation.layout.Arrangement20import androidx.compose.foundation.layout.Box21import androidx.compose.foundation.layout.BoxWithConstraints22import androidx.compose.foundation.layout.Column23import androidx.compose.foundation.layout.Row24import androidx.compose.foundation.layout.Spacer25import androidx.compose.foundation.layout.fillMaxSize26import androidx.compose.foundation.layout.fillMaxWidth27import androidx.compose.foundation.layout.height28import androidx.compose.foundation.layout.offset29import androidx.compose.foundation.layout.padding30import androidx.compose.foundation.layout.size31import androidx.compose.foundation.layout.statusBarsPadding32import androidx.compose.foundation.shape.RoundedCornerShape33import androidx.compose.foundation.rememberScrollState34import androidx.compose.foundation.verticalScroll35import androidx.compose.ui.input.nestedscroll.NestedScrollConnection36import androidx.compose.ui.input.nestedscroll.NestedScrollSource37import androidx.compose.ui.input.nestedscroll.nestedScroll38import androidx.compose.ui.unit.Velocity39import androidx.compose.runtime.Composable40import androidx.compose.runtime.LaunchedEffect41import androidx.compose.runtime.getValue42import androidx.compose.runtime.mutableStateOf43import androidx.compose.runtime.remember44import androidx.compose.runtime.rememberCoroutineScope45import androidx.compose.runtime.rememberUpdatedState46import androidx.compose.runtime.setValue47import androidx.compose.runtime.collectAsState48import androidx.compose.ui.Alignment49import androidx.compose.ui.Modifier50import androidx.compose.ui.draw.clip51import androidx.compose.ui.draw.drawWithCache52import androidx.compose.ui.draw.shadow53import androidx.compose.ui.draw.drawBehind54import androidx.compose.ui.geometry.Offset55import androidx.compose.ui.graphics.BlendMode56import androidx.compose.ui.graphics.Brush57import androidx.compose.ui.graphics.Color58import androidx.compose.ui.graphics.drawscope.DrawScope59import androidx.compose.ui.graphics.graphicsLayer60import androidx.compose.ui.input.pointer.pointerInput61import androidx.compose.ui.platform.LocalDensity62import androidx.compose.ui.unit.Dp63import androidx.compose.ui.text.font.FontStyle64import androidx.compose.ui.text.font.FontWeight65import androidx.compose.ui.text.style.TextOverflow66import androidx.compose.ui.unit.dp67import androidx.compose.ui.unit.em68import androidx.compose.ui.unit.sp69import androidx.compose.ui.zIndex70import androidx.hilt.navigation.compose.hiltViewModel71import androidx.compose.material3.Text72import com.peachpasswords.ui.components.PeachTopBar73import com.peachpasswords.ui.theme.LocalPeachAccent74import com.peachpasswords.ui.theme.LocalReduceMotion75import com.peachpasswords.ui.theme.PeachSpacing76import com.peachpasswords.ui.theme.peachColors77import com.peachpasswords.ui.theme.PeachMotion78import com.peachpasswords.ui.theme.PeachTheme79import com.peachpasswords.ui.theme.PeachStackColors80import com.peachpasswords.ui.theme.PeachType81import com.peachpasswords.ui.theme.ShellBackground82import kotlinx.coroutines.delay83import kotlinx.coroutines.launch8485// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card86// sits at top:0; every other card is a thin tab fanned below it, ordered87// cyclically starting just after the active card so the stack reads as a loop.88private val EXPANDED_H = 258.dp89private val TAB_H = 46.dp90private val TAB_OVERLAP = 14.dp91private val TAB_CARD_H = TAB_H + 4.dp92private val MAX_DECK_H = 462.dp93private val DECK_BOTTOM_GUTTER = 36.dp9495/** Drag distance past which a stack card flips between focused and deck states. */96private val DRAG_THRESHOLD = 60.dp9798/** Outcome of releasing a vertical drag on a stack card. */99internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }100101/**102 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed103 * (deck) card up past the threshold focuses it; dragging the focused card down104 * past the threshold returns to the deck. Anything short of the threshold is a no-op.105 */106internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {107    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND108    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE109    else -> DragOutcome.NONE110}111112/**113 * Fixed vertical stride between consecutive collapsed tabs — browser parity114 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).115 * The deck reads as a tight physical stack fanned just below the active card, not116 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].117 */118private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP119120/**121 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]122 * from the active card. Mirrors the browser's deck-open layout so positions match123 * one-to-one. The active card sits at 0; the first fanned tab sits just under the124 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].125 */126internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =127    expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance128129/**130 * The deck fills the available screen height (minus a bottom gutter) rather131 * than capping at [MAX_DECK_H] — that constant was ported verbatim from the132 * browser's small popup (`StackDeck.tsx`) and, left as a hard cap, stranded133 * a third of the screen as dead space below the stack on a phone-height134 * viewport. [MAX_DECK_H] still bounds the deck on very tall/tablet screens135 * so a focused card doesn't grow absurdly large.136 */137internal fun settingsDeckHeight(availableHeight: Dp): Dp {138    val constrained = availableHeight - DECK_BOTTOM_GUTTER139    return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)140}141142@Composable143fun SettingsScreen(144    onBack: () -> Unit,145    onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},146    vm: SettingsViewModel = hiltViewModel(),147) {148    val entries by vm.entries.collectAsState()149    val settings by vm.settings.collectAsState()150    val isPro by vm.isPro.collectAsState()151    val showCelebration by vm.showCelebration.collectAsState()152    val reduceMotion = LocalReduceMotion.current153    val accent = LocalPeachAccent.current154    val scope = rememberCoroutineScope()155156    var active by remember { mutableStateOf(CardId.Pro) }157    var deckOpen by remember { mutableStateOf(true) }158    var fanning by remember { mutableStateOf(false) }159    var celebrating by remember { mutableStateOf<CardId?>(null) }160161    val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)162163    fun handleBack() {164        if (!deckOpen) deckOpen = true else onBack()165    }166    BackHandler { handleBack() }167168    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.169    fun celebrate() {170        if (fanning) return171        scope.launch {172            fanning = true173            active = CardId.Pro174            CELEBRATION_FAN.forEachIndexed { i, id ->175                delay(150L)176                active = id177            }178            delay(150L)179            active = CardId.Pro180            fanning = false181            celebrating = CardId.Pro182            // Matches browser's PRO_CELEBRATION_THANK_YOU_MS (Settings.tsx) — the183            // window the spark burst + "thank you" text stay mounted for.184            delay(3200L)185            celebrating = null186        }187    }188189    // The Settings stack always renders dark, regardless of the app theme190    // (spec §5.5/§8): force a dark PeachTheme around it — this also darkens the191    // ShellBackground base (which keys off MaterialTheme) — while keeping the192    // user's chosen accent and reduce-motion preference.193    PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {194    ShellBackground {195        Column(196            Modifier197                .fillMaxSize()198                .statusBarsPadding()199                .padding(horizontal = PeachSpacing.md),200        ) {201            SettingsTopBar(202                activeIndex = activeIndex,203                total = STACK_CARDS.size,204                onBack = { handleBack() },205            )206            Spacer(Modifier.height(PeachSpacing.sm))207208            BoxWithConstraints(Modifier.fillMaxSize()) {209                val deckHeight = settingsDeckHeight(maxHeight)210                val focusHeight = deckHeight - 8.dp211                // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal212                // browser-popup constant) for the active card's preview height,213                // leaving the same dead space below the fanned tabs on a phone214                // screen that settingsDeckHeight itself was just fixed for. Size it215                // so the active preview + all 6 fanned tabs together fill deckHeight,216                // never shrinking below the original browser-derived minimum.217                val expandedH = (218                    deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H219                ).coerceAtLeast(EXPANDED_H)220                Box(Modifier.fillMaxWidth().height(deckHeight)) {221                    STACK_CARDS.forEachIndexed { index, card ->222                        val isActive = card.id == active223                        val distance = when {224                            index == activeIndex -> -1225                            index > activeIndex -> index - activeIndex - 1226                            else -> STACK_CARDS.size - activeIndex + index - 1227                        }228                        val targetTop = when {229                            isActive -> 0.dp230                            !deckOpen -> focusHeight + 44.dp + (distance * 8).dp231                            else -> collapsedTabTop(distance, expandedH)232                        }233                        val targetHeight = when {234                            isActive && deckOpen -> expandedH235                            isActive -> focusHeight236                            else -> TAB_CARD_H237                        }238                        val concealed = !deckOpen && !isActive239240                        // The drag-to-focus/collapse gesture is a continuous, interruptible241                        // user interaction (not a one-shot navigation transition), so it gets242                        // real spring physics here rather than the fixed-duration tween used243                        // for the one-shot celebration fan — matching PeachExpandableCard and244                        // current M3 Expressive's physics-based spatial-spring motion model.245                        val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)246                        val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)247                        val top by animateDpAsState(248                            targetTop,249                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,250                            label = "top",251                        )252                        val cardH by animateDpAsState(253                            targetHeight,254                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,255                            label = "h",256                        )257                        val alpha by animateFloatAsState(258                            if (concealed) 0f else 1f,259                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,260                            label = "a",261                        )262                        val zIndex = if (isActive) 99f else (50 - distance).toFloat()263264                        val density = LocalDensity.current265                        StackCard(266                            spec = card,267                            active = isActive,268                            focused = isActive && !deckOpen,269                            lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),270                            showSparkle = card.id == CardId.Pro && isPro,271                            celebrating = celebrating == card.id,272                            onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,273                            body = {274                                CardBody(275                                    id = card.id,276                                    fg = card.fg,277                                    vm = vm,278                                    settings = settings,279                                    entriesCount = entries.count { it.deletedAt == null },280                                    onNavigate = onNavigate,281                                )282                            },283                            modifier = Modifier284                                .fillMaxWidth()285                                .align(Alignment.TopStart)286                                .zIndex(zIndex)287                                // Drag the deck like a physical stack: flick a tab up to focus288                                // it. Only wired for non-active tabs — the active card's own289                                // scrollable body handles its half of the gesture (expand from290                                // preview, collapse from focus) via StackCard's nested scroll291                                // connection below. Attaching this to the active card too used292                                // to fight that connection for the same touch: this pointerInput293                                // keys on `deckOpen`/`isActive`, which the nested-scroll actions294                                // mutate mid-gesture, restarting this detector with the finger295                                // still down and firing its own independent resolution — a296                                // visible flicker as two systems retargeted the spring in quick297                                // succession.298                                .then(299                                    if (!isActive) {300                                        Modifier.pointerInput(card.id, deckOpen) {301                                            var dragY = 0f302                                            val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }303                                            detectDragGestures(304                                                onDragStart = { dragY = 0f },305                                                onDrag = { change, amount -> change.consume(); dragY += amount.y },306                                                onDragEnd = {307                                                    if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {308                                                        active = card.id309                                                        deckOpen = false310                                                    }311                                                },312                                            )313                                        }314                                    } else {315                                        Modifier316                                    },317                                )318                                .graphicsLayer {319                                    translationY = top.toPx()320                                    this.alpha = alpha321                                }322                                .height(cardH),323                            onClick = {324                                // Tapping the active card is a no-op (browser StackDeck325                                // parity); flick it up to focus, a tab up to focus that one.326                                if (!isActive) {327                                    active = card.id328                                    deckOpen = false329                                }330                            },331                            onCollapseRequest = { deckOpen = true },332                            onExpandRequest = { active = card.id; deckOpen = false },333                            enabled = !concealed,334                        )335                    }336                }337            }338        }339        if (showCelebration) {340            ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })341        }342    }343    }344}345346@Composable347private fun StackCard(348    spec: StackCardSpec,349    active: Boolean,350    focused: Boolean,351    lede: String,352    showSparkle: Boolean,353    celebrating: Boolean,354    onIconClick: (() -> Unit)?,355    body: @Composable () -> Unit,356    modifier: Modifier,357    onClick: () -> Unit,358    onCollapseRequest: () -> Unit,359    onExpandRequest: () -> Unit,360    enabled: Boolean,361) {362    val interaction = remember { MutableInteractionSource() }363    Box(364        modifier365            .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)366            .clip(RoundedCornerShape(PeachStackColors.CardRadius))367            .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }368            .clickable(369                interactionSource = interaction,370                indication = null,371                enabled = enabled && !active || (active && !focused),372            ) { onClick() }373            .padding(374                horizontal = if (active) 18.dp else 16.dp,375                vertical = if (active) 16.dp else 12.dp,376            ),377    ) {378        if (showSparkle) SparkleDust(Modifier.fillMaxSize())379        if (active) {380            Column(Modifier.fillMaxSize()) {381                Row(382                    Modifier.fillMaxWidth(),383                    horizontalArrangement = Arrangement.SpaceBetween,384                    verticalAlignment = Alignment.CenterVertically,385                ) {386                    Text(387                        spec.num,388                        color = spec.fg.copy(alpha = 0.7f),389                        fontFamily = PeachType.Mono,390                        fontSize = 8.5.sp,391                        letterSpacing = 0.26.em,392                    )393                    Box(contentAlignment = Alignment.Center) {394                        Box(395                            Modifier.clip(RoundedCornerShape(999.dp))396                                .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)397                                .padding(2.dp),398                        ) {399                            CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))400                        }401                        // Not clipped like the icon above it — sparks fly past the402                        // icon's own circular hit target (peach-pro-spark-fly, up403                        // to 54dp out from centre).404                        if (celebrating) ProSparkBurst(color = spec.fg)405                    }406                }407                Text(408                    spec.title,409                    modifier = Modifier.padding(top = 6.dp),410                    color = spec.fg,411                    fontFamily = PeachType.Display,412                    fontStyle = FontStyle.Italic,413                    fontSize = 26.sp,414                )415                Text(416                    lede,417                    modifier = Modifier.padding(top = 4.dp),418                    color = spec.fg.copy(alpha = 0.72f),419                    fontFamily = PeachType.Display,420                    fontStyle = FontStyle.Italic,421                    fontSize = 12.5.sp,422                )423                if (celebrating) ProThankYouFlash(color = spec.fg)424                // Body scrolls independently; only revealed at full height in425                // focus mode, but kept mounted so deck-mode shows a preview.426                // Two-way scroll gesture, mirrored: dragging up on a collapsed-427                // to-preview card expands it straight away (a cramped preview428                // has nothing worth scrolling, so intercept in onPreScroll429                // before its own verticalScroll ever sees the drag) — down-only430                // is deliberately excluded so the same downward motion that431                // just collapsed a card can't immediately re-open it. Dragging432                // a focused card down past its own scroll top hands the433                // leftover delta to the deck to collapse, instead of letting434                // it disappear as a no-op overscroll.435                val scrollState = rememberScrollState()436                val density = LocalDensity.current437                val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }438                // Read the latest focused/active through State rather than as439                // remember() keys, so the connection object (and its accumulators)440                // survives recompositions the gesture's own resolution triggers.441                val latestFocused = rememberUpdatedState(focused)442                val latestActive = rememberUpdatedState(active)443                val nestedScrollConnection = remember(scrollState) {444                    object : NestedScrollConnection {445                        var expandAccum = 0f446                        var collapseAccum = 0f447                        // Resolved once on release (onPreFling), not continuously448                        // mid-drag — matching resolveStackDrag's on-release convention449                        // above. Firing mid-drag flipped `focused` while the finger450                        // was still delivering deltas from the same physical touch,451                        // which the OTHER stack-tab pointerInput (keyed on `deckOpen`)452                        // would also observe and restart on, independently resolving453                        // the same gesture a second time — a visible flicker.454                        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {455                            if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {456                                return Offset.Zero457                            }458                            expandAccum += available.y459                            return available460                        }461                        override fun onPostScroll(462                            consumed: Offset,463                            available: Offset,464                            source: NestedScrollSource,465                        ): Offset {466                            if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {467                                return Offset.Zero468                            }469                            collapseAccum += available.y470                            return Offset(0f, available.y)471                        }472                        override suspend fun onPreFling(available: Velocity): Velocity {473                            // Upward drags only (matches the tab-flick-up convention474                            // used elsewhere in this deck). A downward drag repeated475                            // right after a collapse — muscle memory from "scroll to476                            // top to collapse" — would otherwise re-satisfy this on477                            // the now-unfocused preview and flicker straight back open.478                            if (expandAccum <= -gestureThresholdPx) {479                                onExpandRequest()480                            } else if (collapseAccum >= gestureThresholdPx) {481                                onCollapseRequest()482                            }483                            expandAccum = 0f484                            collapseAccum = 0f485                            return Velocity.Zero486                        }487                    }488                }489                Column(490                    Modifier.fillMaxWidth().weight(1f)491                        .padding(top = 14.dp)492                        .nestedScroll(nestedScrollConnection)493                        .verticalScroll(scrollState),494                ) {495                    body()496                    // Trailing breathing room so bottom-of-card content (e.g. the497                    // Pro card's CTA button) never sits flush against the fanned498                    // tab stacked directly beneath it.499                    Spacer(Modifier.height(PeachSpacing.xl))500                }501            }502        } else {503            // Tab: just the title, single line, vertically centred.504            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {505                Text(506                    spec.title,507                    color = spec.fg,508                    fontFamily = PeachType.Display,509                    fontStyle = FontStyle.Italic,510                    fontWeight = FontWeight.Normal,511                    fontSize = 19.sp,512                    maxLines = 1,513                    overflow = TextOverflow.Ellipsis,514                )515            }516        }517    }518}519520@Composable521private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {522    PeachTopBar(523        title = "Settings",524        leading = {525            Box(526                Modifier527                    .size(40.dp)528                    .clip(RoundedCornerShape(999.dp))529                    .clickable(onClick = onBack),530                contentAlignment = Alignment.Center,531            ) {532                Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)533            }534        },535        actions = {536            Text(537                "${activeIndex + 1} / $total",538                color = peachColors.T2,539                fontFamily = PeachType.Mono,540                fontSize = 9.sp,541                letterSpacing = 0.22.em,542                modifier = Modifier.padding(horizontal = PeachSpacing.sm),543            )544        },545    )546}547548549/**550 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed551 * from the live draw size so the angle holds on any card height, unlike Compose's552 * default corner-to-corner linear gradient.553 */554private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {555    val a = Math.toRadians(155.0)556    val dx = kotlin.math.sin(a).toFloat()557    val dy = -kotlin.math.cos(a).toFloat()558    val cx = size.width / 2f559    val cy = size.height / 2f560    val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f561    return Brush.linearGradient(562        colors = listOf(start, end),563        start = Offset(cx - dx * half, cy - dy * half),564        end = Offset(cx + dx * half, cy + dy * half),565    )566}567568private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)569570// Exact positions/delays/glyphs from the browser's .peach-pro-spark spans571// (Settings.tsx) — an 8-point radial burst, not a uniform ring.572private val PRO_SPARKS = listOf(573    ProSpark(0f, -54f, 0, "✦"),574    ProSpark(38f, -38f, 50, "✦"),575    ProSpark(54f, 0f, 110, "·"),576    ProSpark(38f, 38f, 30, "✦"),577    ProSpark(0f, 54f, 80, "·"),578    ProSpark(-38f, 38f, 130, "✦"),579    ProSpark(-54f, 0f, 55, "·"),580    ProSpark(-38f, -38f, 95, "✦"),581)582583/**584 * The Pro icon's celebration burst — 8 glyphs flying outward from centre and585 * fading, each on its own delay. Ports `peach-pro-spark-fly` (1.4s ease-out):586 * opacity 0→1 over the first quarter, then 1→0 through to the end, while587 * position/scale interpolate linearly (eased) across the full duration.588 */589@Composable590private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {591    if (LocalReduceMotion.current) return592    Box(modifier) {593        PRO_SPARKS.forEach { spark ->594            val progress = remember(spark) { Animatable(0f) }595            LaunchedEffect(spark) {596                delay(spark.delayMs.toLong())597                progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))598            }599            val t = progress.value600            val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f601            val scale = 0.5f + 0.3f * t602            Text(603                spark.glyph,604                color = color.copy(alpha = alpha.coerceIn(0f, 1f)),605                fontSize = 9.sp,606                modifier = Modifier607                    .align(Alignment.Center)608                    .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)609                    .graphicsLayer { scaleX = scale; scaleY = scale },610            )611        }612    }613}614615/**616 * "thank you ✦" beneath the Pro card's lede while celebrating — ports617 * `peach-pro-thankyou-flash` (2.5s ease: fade in by 15%, hold, fade out618 * from 75%).619 */620@Composable621private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {622    val reduceMotion = LocalReduceMotion.current623    val alpha = remember { Animatable(0f) }624    LaunchedEffect(Unit) {625        if (reduceMotion) {626            alpha.snapTo(0.75f)627        } else {628            alpha.animateTo(629                0f,630                keyframes {631                    durationMillis = 2500632                    0f at 0633                    0.75f at 375634                    0.75f at 1875635                    0f at 2500636                },637            )638        }639    }640    Text(641        "thank you ✦",642        modifier = modifier.padding(top = 4.dp),643        color = color.copy(alpha = alpha.value),644        fontFamily = PeachType.Display,645        fontStyle = FontStyle.Italic,646        fontSize = 11.sp,647    )648}649650/**651 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly652 * translate diagonally and loop, screen-blended at low opacity — the Compose653 * analogue of the browser's `stk-pro-dust` keyframes.654 */655@Composable656private fun SparkleDust(modifier: Modifier = Modifier) {657    val reduceMotion = LocalReduceMotion.current658    val t = rememberInfiniteTransition(label = "dust")659    val drift by if (reduceMotion) {660        remember { mutableStateOf(0f) }661    } else {662        t.animateFloat(663            initialValue = 0f,664            targetValue = 1f,665            animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),666            label = "drift",667        )668    }669    Box(670        modifier.drawWithCache {671            val tile = 48.dp.toPx()672            val dx = drift * tile673            onDrawBehind {674                val cols = (size.width / tile).toInt() + 2675                val rows = (size.height / tile).toInt() + 2676                for (r in 0..rows) {677                    for (c in 0..cols) {678                        val baseX = c * tile + dx679                        val baseY = r * tile + dx680                        drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)681                        drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)682                    }683                }684            }685        },686    )687}