SettingsScreen.kt

32.4 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.navigationBarsPadding32import androidx.compose.foundation.layout.statusBarsPadding33import androidx.compose.foundation.shape.RoundedCornerShape34import androidx.compose.foundation.rememberScrollState35import androidx.compose.foundation.verticalScroll36import androidx.compose.ui.input.nestedscroll.NestedScrollConnection37import androidx.compose.ui.input.nestedscroll.NestedScrollSource38import androidx.compose.ui.input.nestedscroll.nestedScroll39import androidx.compose.ui.unit.Velocity40import androidx.compose.runtime.Composable41import androidx.compose.runtime.LaunchedEffect42import androidx.compose.runtime.getValue43import androidx.compose.runtime.mutableStateOf44import androidx.compose.runtime.remember45import androidx.compose.runtime.rememberCoroutineScope46import androidx.compose.runtime.rememberUpdatedState47import androidx.compose.runtime.setValue48import androidx.compose.runtime.collectAsState49import androidx.compose.ui.Alignment50import androidx.compose.ui.Modifier51import androidx.compose.ui.draw.clip52import androidx.compose.ui.draw.drawWithCache53import androidx.compose.ui.draw.shadow54import androidx.compose.ui.draw.drawBehind55import androidx.compose.ui.geometry.Offset56import androidx.compose.ui.graphics.BlendMode57import androidx.compose.ui.graphics.Brush58import androidx.compose.ui.graphics.Color59import androidx.compose.ui.graphics.drawscope.DrawScope60import androidx.compose.ui.graphics.graphicsLayer61import androidx.compose.ui.input.pointer.pointerInput62import androidx.compose.ui.platform.LocalDensity63import androidx.compose.ui.unit.Dp64import androidx.compose.ui.text.font.FontStyle65import androidx.compose.ui.text.font.FontWeight66import androidx.compose.ui.text.style.TextOverflow67import androidx.compose.ui.unit.dp68import androidx.compose.ui.unit.em69import androidx.compose.ui.unit.sp70import androidx.compose.ui.zIndex71import androidx.hilt.navigation.compose.hiltViewModel72import androidx.compose.material3.Text73import androidx.compose.ui.text.style.TextAlign74import com.peachpasswords.BuildConfig75import com.peachpasswords.ui.components.PeachTopBar76import com.peachpasswords.ui.theme.LocalPeachAccent77import com.peachpasswords.ui.theme.LocalReduceMotion78import com.peachpasswords.ui.theme.PeachSpacing79import com.peachpasswords.ui.theme.peachColors80import com.peachpasswords.ui.theme.PeachMotion81import com.peachpasswords.ui.theme.PeachTheme82import com.peachpasswords.ui.theme.PeachStackColors83import com.peachpasswords.ui.theme.PeachType84import com.peachpasswords.ui.theme.ShellBackground85import kotlinx.coroutines.delay86import kotlinx.coroutines.launch8788// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card89// sits at top:0; every other card is a thin tab fanned below it, ordered90// cyclically starting just after the active card so the stack reads as a loop.91private val EXPANDED_H = 258.dp92private val TAB_H = 46.dp93private val TAB_OVERLAP = 14.dp94private val TAB_CARD_H = TAB_H + 4.dp95private val MAX_DECK_H = 462.dp96private val DECK_BOTTOM_GUTTER = 36.dp9798/** Drag distance past which a stack card flips between focused and deck states. */99private val DRAG_THRESHOLD = 60.dp100101/** Outcome of releasing a vertical drag on a stack card. */102internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }103104/**105 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed106 * (deck) card up past the threshold focuses it; dragging the focused card down107 * past the threshold returns to the deck. Anything short of the threshold is a no-op.108 */109internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {110    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND111    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE112    else -> DragOutcome.NONE113}114115/**116 * Fixed vertical stride between consecutive collapsed tabs — browser parity117 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).118 * The deck reads as a tight physical stack fanned just below the active card, not119 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].120 */121private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP122123/**124 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]125 * from the active card. Mirrors the browser's deck-open layout so positions match126 * one-to-one. The active card sits at 0; the first fanned tab sits just under the127 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].128 */129internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =130    expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance131132/**133 * The deck fills the available screen height (minus a bottom gutter) rather134 * than capping at [MAX_DECK_H] — that constant was ported verbatim from the135 * browser's small popup (`StackDeck.tsx`) and, left as a hard cap, stranded136 * a third of the screen as dead space below the stack on a phone-height137 * viewport. [MAX_DECK_H] still bounds the deck on very tall/tablet screens138 * so a focused card doesn't grow absurdly large.139 */140internal fun settingsDeckHeight(availableHeight: Dp): Dp {141    val constrained = availableHeight - DECK_BOTTOM_GUTTER142    return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)143}144145@Composable146fun SettingsScreen(147    onBack: () -> Unit,148    onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},149    vm: SettingsViewModel = hiltViewModel(),150) {151    val entries by vm.entries.collectAsState()152    val settings by vm.settings.collectAsState()153    val isPro by vm.isPro.collectAsState()154    val showCelebration by vm.showCelebration.collectAsState()155    val reduceMotion = LocalReduceMotion.current156    val accent = LocalPeachAccent.current157    val scope = rememberCoroutineScope()158159    var active by remember { mutableStateOf(CardId.Pro) }160    var deckOpen by remember { mutableStateOf(true) }161    var fanning by remember { mutableStateOf(false) }162    var celebrating by remember { mutableStateOf<CardId?>(null) }163164    val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)165166    fun handleBack() {167        if (!deckOpen) deckOpen = true else onBack()168    }169    BackHandler { handleBack() }170171    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.172    fun celebrate() {173        if (fanning) return174        scope.launch {175            fanning = true176            active = CardId.Pro177            CELEBRATION_FAN.forEachIndexed { i, id ->178                delay(150L)179                active = id180            }181            delay(150L)182            active = CardId.Pro183            fanning = false184            celebrating = CardId.Pro185            // Matches browser's PRO_CELEBRATION_THANK_YOU_MS (Settings.tsx) — the186            // window the spark burst + "thank you" text stay mounted for.187            delay(3200L)188            celebrating = null189        }190    }191192    PeachTheme(accent = accent, reduceMotion = reduceMotion) {193    ShellBackground {194        Column(195            Modifier196                .fillMaxSize()197                .statusBarsPadding()198                .padding(horizontal = PeachSpacing.md),199        ) {200            SettingsTopBar(201                activeIndex = activeIndex,202                total = STACK_CARDS.size,203                onBack = { handleBack() },204            )205            Spacer(Modifier.height(PeachSpacing.sm))206207            BoxWithConstraints(Modifier.fillMaxSize()) {208                val deckHeight = settingsDeckHeight(maxHeight)209                val focusHeight = deckHeight - 8.dp210                // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal211                // browser-popup constant) for the active card's preview height,212                // leaving the same dead space below the fanned tabs on a phone213                // screen that settingsDeckHeight itself was just fixed for. Size it214                // so the active preview + all 6 fanned tabs together fill deckHeight,215                // never shrinking below the original browser-derived minimum.216                val expandedH = (217                    deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H218                ).coerceAtLeast(EXPANDED_H)219                Box(Modifier.fillMaxWidth().height(deckHeight)) {220                    STACK_CARDS.forEachIndexed { index, card ->221                        val isActive = card.id == active222                        val distance = when {223                            index == activeIndex -> -1224                            index > activeIndex -> index - activeIndex - 1225                            else -> STACK_CARDS.size - activeIndex + index - 1226                        }227                        val targetTop = when {228                            isActive -> 0.dp229                            !deckOpen -> focusHeight + 44.dp + (distance * 8).dp230                            else -> collapsedTabTop(distance, expandedH)231                        }232                        val targetHeight = when {233                            isActive && deckOpen -> expandedH234                            isActive -> focusHeight235                            else -> TAB_CARD_H236                        }237                        val concealed = !deckOpen && !isActive238239                        // The drag-to-focus/collapse gesture is a continuous, interruptible240                        // user interaction (not a one-shot navigation transition), so it gets241                        // real spring physics here rather than the fixed-duration tween used242                        // for the one-shot celebration fan — matching PeachExpandableCard and243                        // current M3 Expressive's physics-based spatial-spring motion model.244                        val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)245                        val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)246                        val top by animateDpAsState(247                            targetTop,248                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,249                            label = "top",250                        )251                        val cardH by animateDpAsState(252                            targetHeight,253                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,254                            label = "h",255                        )256                        val alpha by animateFloatAsState(257                            if (concealed) 0f else 1f,258                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,259                            label = "a",260                        )261                        val zIndex = if (isActive) 99f else (50 - distance).toFloat()262263                        val density = LocalDensity.current264                        StackCard(265                            spec = card,266                            active = isActive,267                            focused = isActive && !deckOpen,268                            lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),269                            showSparkle = card.id == CardId.Pro && isPro,270                            celebrating = celebrating == card.id,271                            onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,272                            body = {273                                CardBody(274                                    id = card.id,275                                    fg = card.fg,276                                    vm = vm,277                                    settings = settings,278                                    entriesCount = entries.count { it.deletedAt == null },279                                    onNavigate = onNavigate,280                                )281                            },282                            modifier = Modifier283                                .fillMaxWidth()284                                .align(Alignment.TopStart)285                                .zIndex(zIndex)286                                // Drag the deck like a physical stack: flick a tab up to focus287                                // it. Only wired for non-active tabs — the active card's own288                                // scrollable body handles its half of the gesture (expand from289                                // preview, collapse from focus) via StackCard's nested scroll290                                // connection below. Attaching this to the active card too used291                                // to fight that connection for the same touch: this pointerInput292                                // keys on `deckOpen`/`isActive`, which the nested-scroll actions293                                // mutate mid-gesture, restarting this detector with the finger294                                // still down and firing its own independent resolution — a295                                // visible flicker as two systems retargeted the spring in quick296                                // succession.297                                .then(298                                    if (!isActive) {299                                        Modifier.pointerInput(card.id, deckOpen) {300                                            var dragY = 0f301                                            val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }302                                            detectDragGestures(303                                                onDragStart = { dragY = 0f },304                                                onDrag = { change, amount -> change.consume(); dragY += amount.y },305                                                onDragEnd = {306                                                    if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {307                                                        active = card.id308                                                        deckOpen = false309                                                    }310                                                },311                                            )312                                        }313                                    } else {314                                        Modifier315                                    },316                                )317                                .graphicsLayer {318                                    translationY = top.toPx()319                                    this.alpha = alpha320                                }321                                .height(cardH),322                            onClick = {323                                // Tapping the active card is a no-op (browser StackDeck324                                // parity); flick it up to focus, a tab up to focus that one.325                                if (!isActive) {326                                    active = card.id327                                    deckOpen = false328                                }329                            },330                            onCollapseRequest = { deckOpen = true },331                            onExpandRequest = { active = card.id; deckOpen = false },332                            enabled = !concealed,333                        )334                    }335                }336337                // Browser parity (.stk-version-corner): the build, faintly, in the338                // gutter the deck already leaves below itself. Worth having on the339                // phone specifically — sync is a lockstep wire contract, so "which340                // build is actually on this device" is the first question when341                // pairing misbehaves.342                Text(343                    "v${BuildConfig.VERSION_NAME}",344                    color = peachColors.T2.copy(alpha = 0.5f),345                    fontFamily = PeachType.Mono,346                    fontSize = 9.sp,347                    textAlign = TextAlign.Center,348                    modifier = Modifier349                        .align(Alignment.BottomCenter)350                        .fillMaxWidth()351                        // The screen's Column only pads the status bar, so without352                        // this the label lands under the gesture pill.353                        .navigationBarsPadding()354                        .padding(bottom = PeachSpacing.xs),355                )356            }357        }358        if (showCelebration) {359            ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })360        }361    }362    }363}364365@Composable366private fun StackCard(367    spec: StackCardSpec,368    active: Boolean,369    focused: Boolean,370    lede: String,371    showSparkle: Boolean,372    celebrating: Boolean,373    onIconClick: (() -> Unit)?,374    body: @Composable () -> Unit,375    modifier: Modifier,376    onClick: () -> Unit,377    onCollapseRequest: () -> Unit,378    onExpandRequest: () -> Unit,379    enabled: Boolean,380) {381    val interaction = remember { MutableInteractionSource() }382    Box(383        modifier384            .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)385            .clip(RoundedCornerShape(PeachStackColors.CardRadius))386            .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }387            .clickable(388                interactionSource = interaction,389                indication = null,390                enabled = enabled && !active || (active && !focused),391            ) { onClick() }392            .padding(393                horizontal = if (active) 18.dp else 16.dp,394                vertical = if (active) 16.dp else 12.dp,395            ),396    ) {397        if (showSparkle) SparkleDust(Modifier.fillMaxSize())398        if (active) {399            Column(Modifier.fillMaxSize()) {400                Row(401                    Modifier.fillMaxWidth(),402                    horizontalArrangement = Arrangement.SpaceBetween,403                    verticalAlignment = Alignment.CenterVertically,404                ) {405                    Text(406                        spec.num,407                        color = spec.fg.copy(alpha = 0.7f),408                        fontFamily = PeachType.Mono,409                        fontSize = 8.5.sp,410                        letterSpacing = 0.26.em,411                    )412                    Box(contentAlignment = Alignment.Center) {413                        Box(414                            Modifier.clip(RoundedCornerShape(999.dp))415                                .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)416                                .padding(2.dp),417                        ) {418                            CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))419                        }420                        // Not clipped like the icon above it — sparks fly past the421                        // icon's own circular hit target (peach-pro-spark-fly, up422                        // to 54dp out from centre).423                        if (celebrating) ProSparkBurst(color = spec.fg)424                    }425                }426                Text(427                    spec.title,428                    modifier = Modifier.padding(top = 6.dp),429                    color = spec.fg,430                    fontFamily = PeachType.Display,431                    fontStyle = FontStyle.Italic,432                    fontSize = 26.sp,433                )434                Text(435                    lede,436                    modifier = Modifier.padding(top = 4.dp),437                    color = spec.fg.copy(alpha = 0.72f),438                    fontFamily = PeachType.Display,439                    fontStyle = FontStyle.Italic,440                    fontSize = 12.5.sp,441                )442                if (celebrating) ProThankYouFlash(color = spec.fg)443                // Body scrolls independently; only revealed at full height in444                // focus mode, but kept mounted so deck-mode shows a preview.445                // Two-way scroll gesture, mirrored: dragging up on a collapsed-446                // to-preview card expands it straight away (a cramped preview447                // has nothing worth scrolling, so intercept in onPreScroll448                // before its own verticalScroll ever sees the drag) — down-only449                // is deliberately excluded so the same downward motion that450                // just collapsed a card can't immediately re-open it. Dragging451                // a focused card down past its own scroll top hands the452                // leftover delta to the deck to collapse, instead of letting453                // it disappear as a no-op overscroll.454                val scrollState = rememberScrollState()455                val density = LocalDensity.current456                val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }457                // Read the latest focused/active through State rather than as458                // remember() keys, so the connection object (and its accumulators)459                // survives recompositions the gesture's own resolution triggers.460                val latestFocused = rememberUpdatedState(focused)461                val latestActive = rememberUpdatedState(active)462                val nestedScrollConnection = remember(scrollState) {463                    object : NestedScrollConnection {464                        var expandAccum = 0f465                        var collapseAccum = 0f466                        // Resolved once on release (onPreFling), not continuously467                        // mid-drag — matching resolveStackDrag's on-release convention468                        // above. Firing mid-drag flipped `focused` while the finger469                        // was still delivering deltas from the same physical touch,470                        // which the OTHER stack-tab pointerInput (keyed on `deckOpen`)471                        // would also observe and restart on, independently resolving472                        // the same gesture a second time — a visible flicker.473                        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {474                            if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {475                                return Offset.Zero476                            }477                            expandAccum += available.y478                            return available479                        }480                        override fun onPostScroll(481                            consumed: Offset,482                            available: Offset,483                            source: NestedScrollSource,484                        ): Offset {485                            if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {486                                return Offset.Zero487                            }488                            collapseAccum += available.y489                            return Offset(0f, available.y)490                        }491                        override suspend fun onPreFling(available: Velocity): Velocity {492                            // Upward drags only (matches the tab-flick-up convention493                            // used elsewhere in this deck). A downward drag repeated494                            // right after a collapse — muscle memory from "scroll to495                            // top to collapse" — would otherwise re-satisfy this on496                            // the now-unfocused preview and flicker straight back open.497                            if (expandAccum <= -gestureThresholdPx) {498                                onExpandRequest()499                            } else if (collapseAccum >= gestureThresholdPx) {500                                onCollapseRequest()501                            }502                            expandAccum = 0f503                            collapseAccum = 0f504                            return Velocity.Zero505                        }506                    }507                }508                Column(509                    Modifier.fillMaxWidth().weight(1f)510                        .padding(top = 14.dp)511                        .nestedScroll(nestedScrollConnection)512                        .verticalScroll(scrollState),513                ) {514                    body()515                    // Trailing breathing room so bottom-of-card content (e.g. the516                    // Pro card's CTA button) never sits flush against the fanned517                    // tab stacked directly beneath it.518                    Spacer(Modifier.height(PeachSpacing.xl))519                }520            }521        } else {522            // Tab: just the title, single line, vertically centred.523            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {524                Text(525                    spec.title,526                    color = spec.fg,527                    fontFamily = PeachType.Display,528                    fontStyle = FontStyle.Italic,529                    fontWeight = FontWeight.Normal,530                    fontSize = 19.sp,531                    maxLines = 1,532                    overflow = TextOverflow.Ellipsis,533                )534            }535        }536    }537}538539@Composable540private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {541    PeachTopBar(542        title = "Settings",543        leading = {544            Box(545                Modifier546                    .size(40.dp)547                    .clip(RoundedCornerShape(999.dp))548                    .clickable(onClick = onBack),549                contentAlignment = Alignment.Center,550            ) {551                Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)552            }553        },554        actions = {555            Text(556                "${activeIndex + 1} / $total",557                color = peachColors.T2,558                fontFamily = PeachType.Mono,559                fontSize = 9.sp,560                letterSpacing = 0.22.em,561                modifier = Modifier.padding(horizontal = PeachSpacing.sm),562            )563        },564    )565}566567568/**569 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed570 * from the live draw size so the angle holds on any card height, unlike Compose's571 * default corner-to-corner linear gradient.572 */573private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {574    val a = Math.toRadians(155.0)575    val dx = kotlin.math.sin(a).toFloat()576    val dy = -kotlin.math.cos(a).toFloat()577    val cx = size.width / 2f578    val cy = size.height / 2f579    val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f580    return Brush.linearGradient(581        colors = listOf(start, end),582        start = Offset(cx - dx * half, cy - dy * half),583        end = Offset(cx + dx * half, cy + dy * half),584    )585}586587private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)588589// Exact positions/delays/glyphs from the browser's .peach-pro-spark spans590// (Settings.tsx) — an 8-point radial burst, not a uniform ring.591private val PRO_SPARKS = listOf(592    ProSpark(0f, -54f, 0, "✦"),593    ProSpark(38f, -38f, 50, "✦"),594    ProSpark(54f, 0f, 110, "·"),595    ProSpark(38f, 38f, 30, "✦"),596    ProSpark(0f, 54f, 80, "·"),597    ProSpark(-38f, 38f, 130, "✦"),598    ProSpark(-54f, 0f, 55, "·"),599    ProSpark(-38f, -38f, 95, "✦"),600)601602/**603 * The Pro icon's celebration burst — 8 glyphs flying outward from centre and604 * fading, each on its own delay. Ports `peach-pro-spark-fly` (1.4s ease-out):605 * opacity 0→1 over the first quarter, then 1→0 through to the end, while606 * position/scale interpolate linearly (eased) across the full duration.607 */608@Composable609private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {610    if (LocalReduceMotion.current) return611    Box(modifier) {612        PRO_SPARKS.forEach { spark ->613            val progress = remember(spark) { Animatable(0f) }614            LaunchedEffect(spark) {615                delay(spark.delayMs.toLong())616                progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))617            }618            val t = progress.value619            val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f620            val scale = 0.5f + 0.3f * t621            Text(622                spark.glyph,623                color = color.copy(alpha = alpha.coerceIn(0f, 1f)),624                fontSize = 9.sp,625                modifier = Modifier626                    .align(Alignment.Center)627                    .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)628                    .graphicsLayer { scaleX = scale; scaleY = scale },629            )630        }631    }632}633634/**635 * "thank you ✦" beneath the Pro card's lede while celebrating — ports636 * `peach-pro-thankyou-flash` (2.5s ease: fade in by 15%, hold, fade out637 * from 75%).638 */639@Composable640private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {641    val reduceMotion = LocalReduceMotion.current642    val alpha = remember { Animatable(0f) }643    LaunchedEffect(Unit) {644        if (reduceMotion) {645            alpha.snapTo(0.75f)646        } else {647            alpha.animateTo(648                0f,649                keyframes {650                    durationMillis = 2500651                    0f at 0652                    0.75f at 375653                    0.75f at 1875654                    0f at 2500655                },656            )657        }658    }659    Text(660        "thank you ✦",661        modifier = modifier.padding(top = 4.dp),662        color = color.copy(alpha = alpha.value),663        fontFamily = PeachType.Display,664        fontStyle = FontStyle.Italic,665        fontSize = 11.sp,666    )667}668669/**670 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly671 * translate diagonally and loop, screen-blended at low opacity — the Compose672 * analogue of the browser's `stk-pro-dust` keyframes.673 */674@Composable675private fun SparkleDust(modifier: Modifier = Modifier) {676    val reduceMotion = LocalReduceMotion.current677    val t = rememberInfiniteTransition(label = "dust")678    val drift by if (reduceMotion) {679        remember { mutableStateOf(0f) }680    } else {681        t.animateFloat(682            initialValue = 0f,683            targetValue = 1f,684            animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),685            label = "drift",686        )687    }688    Box(689        modifier.drawWithCache {690            val tile = 48.dp.toPx()691            val dx = drift * tile692            onDrawBehind {693                val cols = (size.width / tile).toInt() + 2694                val rows = (size.height / tile).toInt() + 2695                for (r in 0..rows) {696                    for (c in 0..cols) {697                        val baseX = c * tile + dx698                        val baseY = r * tile + dx699                        drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)700                        drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)701                    }702                }703            }704        },705    )706}