SettingsScreen.kt

32.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.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 DECK_BOTTOM_GUTTER = 36.dp9697/** Drag distance past which a stack card flips between focused and deck states. */98private val DRAG_THRESHOLD = 60.dp99100/** Outcome of releasing a vertical drag on a stack card. */101internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }102103/**104 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed105 * (deck) card up past the threshold focuses it; dragging the focused card down106 * past the threshold returns to the deck. Anything short of the threshold is a no-op.107 */108internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {109    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND110    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE111    else -> DragOutcome.NONE112}113114/**115 * Fixed vertical stride between consecutive collapsed tabs — browser parity116 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).117 * The deck reads as a tight physical stack fanned just below the active card, not118 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].119 */120private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP121122/**123 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]124 * from the active card. Mirrors the browser's deck-open layout so positions match125 * one-to-one. The active card sits at 0; the first fanned tab sits just under the126 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].127 */128internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =129    expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance130131/**132 * The deck fills the available screen height minus a bottom gutter on every133 * screen size; the gutter reserves room for the build label pinned to the134 * bottom corner. An earlier version capped this at a multiple of a constant135 * ported from the browser's small popup (`StackDeck.tsx`), but on tall panels136 * like the Galaxy Z Fold 7 the available height exceeded that cap — stranding137 * a band of dead space below the stack and clipping the Pro card's copy/reveal138 * buttons. Filling to the gutter is how phones already behave, so this just139 * makes tall screens consistent with them.140 */141internal fun settingsDeckHeight(availableHeight: Dp): Dp {142    val constrained = availableHeight - DECK_BOTTOM_GUTTER143    return if (constrained < 0.dp) 0.dp else constrained144}145146@Composable147fun SettingsScreen(148    onBack: () -> Unit,149    onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},150    vm: SettingsViewModel = hiltViewModel(),151) {152    val entries by vm.entries.collectAsState()153    val settings by vm.settings.collectAsState()154    val isPro by vm.isPro.collectAsState()155    val showCelebration by vm.showCelebration.collectAsState()156    val reduceMotion = LocalReduceMotion.current157    val accent = LocalPeachAccent.current158    val scope = rememberCoroutineScope()159160    var active by remember { mutableStateOf(CardId.Pro) }161    var deckOpen by remember { mutableStateOf(true) }162    var fanning by remember { mutableStateOf(false) }163    var celebrating by remember { mutableStateOf<CardId?>(null) }164165    val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)166167    fun handleBack() {168        if (!deckOpen) deckOpen = true else onBack()169    }170    BackHandler { handleBack() }171172    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.173    fun celebrate() {174        if (fanning) return175        scope.launch {176            fanning = true177            active = CardId.Pro178            CELEBRATION_FAN.forEachIndexed { i, id ->179                delay(150L)180                active = id181            }182            delay(150L)183            active = CardId.Pro184            fanning = false185            celebrating = CardId.Pro186            // Matches browser's PRO_CELEBRATION_THANK_YOU_MS (Settings.tsx) — the187            // window the spark burst + "thank you" text stay mounted for.188            delay(3200L)189            celebrating = null190        }191    }192193    PeachTheme(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                }337338                // Browser parity (.stk-version-corner): the build, faintly, in the339                // gutter the deck already leaves below itself. Worth having on the340                // phone specifically — sync is a lockstep wire contract, so "which341                // build is actually on this device" is the first question when342                // pairing misbehaves.343                Text(344                    "v${BuildConfig.VERSION_NAME}",345                    color = peachColors.T2.copy(alpha = 0.5f),346                    fontFamily = PeachType.Mono,347                    fontSize = 9.sp,348                    textAlign = TextAlign.Center,349                    modifier = Modifier350                        .align(Alignment.BottomCenter)351                        .fillMaxWidth()352                        // The screen's Column only pads the status bar, so without353                        // this the label lands under the gesture pill.354                        .navigationBarsPadding()355                        .padding(bottom = PeachSpacing.xs),356                )357            }358        }359        if (showCelebration) {360            ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })361        }362    }363    }364}365366@Composable367private fun StackCard(368    spec: StackCardSpec,369    active: Boolean,370    focused: Boolean,371    lede: String,372    showSparkle: Boolean,373    celebrating: Boolean,374    onIconClick: (() -> Unit)?,375    body: @Composable () -> Unit,376    modifier: Modifier,377    onClick: () -> Unit,378    onCollapseRequest: () -> Unit,379    onExpandRequest: () -> Unit,380    enabled: Boolean,381) {382    val interaction = remember { MutableInteractionSource() }383    Box(384        modifier385            .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)386            .clip(RoundedCornerShape(PeachStackColors.CardRadius))387            .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }388            .clickable(389                interactionSource = interaction,390                indication = null,391                enabled = enabled && !active || (active && !focused),392            ) { onClick() }393            .padding(394                horizontal = if (active) 18.dp else 16.dp,395                vertical = if (active) 16.dp else 12.dp,396            ),397    ) {398        if (showSparkle) SparkleDust(Modifier.fillMaxSize())399        if (active) {400            Column(Modifier.fillMaxSize()) {401                Row(402                    Modifier.fillMaxWidth(),403                    horizontalArrangement = Arrangement.SpaceBetween,404                    verticalAlignment = Alignment.CenterVertically,405                ) {406                    Text(407                        spec.num,408                        color = spec.fg.copy(alpha = 0.7f),409                        fontFamily = PeachType.Mono,410                        fontSize = 8.5.sp,411                        letterSpacing = 0.26.em,412                    )413                    Box(contentAlignment = Alignment.Center) {414                        Box(415                            Modifier.clip(RoundedCornerShape(999.dp))416                                .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)417                                .padding(2.dp),418                        ) {419                            CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))420                        }421                        // Not clipped like the icon above it — sparks fly past the422                        // icon's own circular hit target (peach-pro-spark-fly, up423                        // to 54dp out from centre).424                        if (celebrating) ProSparkBurst(color = spec.fg)425                    }426                }427                Text(428                    spec.title,429                    modifier = Modifier.padding(top = 6.dp),430                    color = spec.fg,431                    fontFamily = PeachType.Display,432                    fontStyle = FontStyle.Italic,433                    fontSize = 26.sp,434                )435                Text(436                    lede,437                    modifier = Modifier.padding(top = 4.dp),438                    color = spec.fg.copy(alpha = 0.72f),439                    fontFamily = PeachType.Display,440                    fontStyle = FontStyle.Italic,441                    fontSize = 12.5.sp,442                )443                if (celebrating) ProThankYouFlash(color = spec.fg)444                // Body scrolls independently; only revealed at full height in445                // focus mode, but kept mounted so deck-mode shows a preview.446                // Two-way scroll gesture, mirrored: dragging up on a collapsed-447                // to-preview card expands it straight away (a cramped preview448                // has nothing worth scrolling, so intercept in onPreScroll449                // before its own verticalScroll ever sees the drag) — down-only450                // is deliberately excluded so the same downward motion that451                // just collapsed a card can't immediately re-open it. Dragging452                // a focused card down past its own scroll top hands the453                // leftover delta to the deck to collapse, instead of letting454                // it disappear as a no-op overscroll.455                val scrollState = rememberScrollState()456                val density = LocalDensity.current457                val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }458                // Read the latest focused/active through State rather than as459                // remember() keys, so the connection object (and its accumulators)460                // survives recompositions the gesture's own resolution triggers.461                val latestFocused = rememberUpdatedState(focused)462                val latestActive = rememberUpdatedState(active)463                val nestedScrollConnection = remember(scrollState) {464                    object : NestedScrollConnection {465                        var expandAccum = 0f466                        var collapseAccum = 0f467                        // Resolved once on release (onPreFling), not continuously468                        // mid-drag — matching resolveStackDrag's on-release convention469                        // above. Firing mid-drag flipped `focused` while the finger470                        // was still delivering deltas from the same physical touch,471                        // which the OTHER stack-tab pointerInput (keyed on `deckOpen`)472                        // would also observe and restart on, independently resolving473                        // the same gesture a second time — a visible flicker.474                        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {475                            if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {476                                return Offset.Zero477                            }478                            expandAccum += available.y479                            return available480                        }481                        override fun onPostScroll(482                            consumed: Offset,483                            available: Offset,484                            source: NestedScrollSource,485                        ): Offset {486                            if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {487                                return Offset.Zero488                            }489                            collapseAccum += available.y490                            return Offset(0f, available.y)491                        }492                        override suspend fun onPreFling(available: Velocity): Velocity {493                            // Upward drags only (matches the tab-flick-up convention494                            // used elsewhere in this deck). A downward drag repeated495                            // right after a collapse — muscle memory from "scroll to496                            // top to collapse" — would otherwise re-satisfy this on497                            // the now-unfocused preview and flicker straight back open.498                            if (expandAccum <= -gestureThresholdPx) {499                                onExpandRequest()500                            } else if (collapseAccum >= gestureThresholdPx) {501                                onCollapseRequest()502                            }503                            expandAccum = 0f504                            collapseAccum = 0f505                            return Velocity.Zero506                        }507                    }508                }509                Column(510                    Modifier.fillMaxWidth().weight(1f)511                        .padding(top = 14.dp)512                        .nestedScroll(nestedScrollConnection)513                        .verticalScroll(scrollState),514                ) {515                    body()516                    // Trailing breathing room so bottom-of-card content (e.g. the517                    // Pro card's CTA button) never sits flush against the fanned518                    // tab stacked directly beneath it.519                    Spacer(Modifier.height(PeachSpacing.xl))520                }521            }522        } else {523            // Tab: just the title, single line, vertically centred.524            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {525                Text(526                    spec.title,527                    color = spec.fg,528                    fontFamily = PeachType.Display,529                    fontStyle = FontStyle.Italic,530                    fontWeight = FontWeight.Normal,531                    fontSize = 19.sp,532                    maxLines = 1,533                    overflow = TextOverflow.Ellipsis,534                )535            }536        }537    }538}539540@Composable541private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {542    PeachTopBar(543        title = "Settings",544        leading = {545            Box(546                Modifier547                    .size(40.dp)548                    .clip(RoundedCornerShape(999.dp))549                    .clickable(onClick = onBack),550                contentAlignment = Alignment.Center,551            ) {552                Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)553            }554        },555        actions = {556            Text(557                "${activeIndex + 1} / $total",558                color = peachColors.T2,559                fontFamily = PeachType.Mono,560                fontSize = 9.sp,561                letterSpacing = 0.22.em,562                modifier = Modifier.padding(horizontal = PeachSpacing.sm),563            )564        },565    )566}567568569/**570 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed571 * from the live draw size so the angle holds on any card height, unlike Compose's572 * default corner-to-corner linear gradient.573 */574private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {575    val a = Math.toRadians(155.0)576    val dx = kotlin.math.sin(a).toFloat()577    val dy = -kotlin.math.cos(a).toFloat()578    val cx = size.width / 2f579    val cy = size.height / 2f580    val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f581    return Brush.linearGradient(582        colors = listOf(start, end),583        start = Offset(cx - dx * half, cy - dy * half),584        end = Offset(cx + dx * half, cy + dy * half),585    )586}587588private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)589590// Exact positions/delays/glyphs from the browser's .peach-pro-spark spans591// (Settings.tsx) — an 8-point radial burst, not a uniform ring.592private val PRO_SPARKS = listOf(593    ProSpark(0f, -54f, 0, "✦"),594    ProSpark(38f, -38f, 50, "✦"),595    ProSpark(54f, 0f, 110, "·"),596    ProSpark(38f, 38f, 30, "✦"),597    ProSpark(0f, 54f, 80, "·"),598    ProSpark(-38f, 38f, 130, "✦"),599    ProSpark(-54f, 0f, 55, "·"),600    ProSpark(-38f, -38f, 95, "✦"),601)602603/**604 * The Pro icon's celebration burst — 8 glyphs flying outward from centre and605 * fading, each on its own delay. Ports `peach-pro-spark-fly` (1.4s ease-out):606 * opacity 0→1 over the first quarter, then 1→0 through to the end, while607 * position/scale interpolate linearly (eased) across the full duration.608 */609@Composable610private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {611    if (LocalReduceMotion.current) return612    Box(modifier) {613        PRO_SPARKS.forEach { spark ->614            val progress = remember(spark) { Animatable(0f) }615            LaunchedEffect(spark) {616                delay(spark.delayMs.toLong())617                progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))618            }619            val t = progress.value620            val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f621            val scale = 0.5f + 0.3f * t622            Text(623                spark.glyph,624                color = color.copy(alpha = alpha.coerceIn(0f, 1f)),625                fontSize = 9.sp,626                modifier = Modifier627                    .align(Alignment.Center)628                    .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)629                    .graphicsLayer { scaleX = scale; scaleY = scale },630            )631        }632    }633}634635/**636 * "thank you ✦" beneath the Pro card's lede while celebrating — ports637 * `peach-pro-thankyou-flash` (2.5s ease: fade in by 15%, hold, fade out638 * from 75%).639 */640@Composable641private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {642    val reduceMotion = LocalReduceMotion.current643    val alpha = remember { Animatable(0f) }644    LaunchedEffect(Unit) {645        if (reduceMotion) {646            alpha.snapTo(0.75f)647        } else {648            alpha.animateTo(649                0f,650                keyframes {651                    durationMillis = 2500652                    0f at 0653                    0.75f at 375654                    0.75f at 1875655                    0f at 2500656                },657            )658        }659    }660    Text(661        "thank you ✦",662        modifier = modifier.padding(top = 4.dp),663        color = color.copy(alpha = alpha.value),664        fontFamily = PeachType.Display,665        fontStyle = FontStyle.Italic,666        fontSize = 11.sp,667    )668}669670/**671 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly672 * translate diagonally and loop, screen-blended at low opacity — the Compose673 * analogue of the browser's `stk-pro-dust` keyframes.674 */675@Composable676private fun SparkleDust(modifier: Modifier = Modifier) {677    val reduceMotion = LocalReduceMotion.current678    val t = rememberInfiniteTransition(label = "dust")679    val drift by if (reduceMotion) {680        remember { mutableStateOf(0f) }681    } else {682        t.animateFloat(683            initialValue = 0f,684            targetValue = 1f,685            animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),686            label = "drift",687        )688    }689    Box(690        modifier.drawWithCache {691            val tile = 48.dp.toPx()692            val dx = drift * tile693            onDrawBehind {694                val cols = (size.width / tile).toInt() + 2695                val rows = (size.height / tile).toInt() + 2696                for (r in 0..rows) {697                    for (c in 0..cols) {698                        val baseX = c * tile + dx699                        val baseY = r * tile + dx700                        drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)701                        drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)702                    }703                }704            }705        },706    )707}