SettingsScreen.kt

33.2 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.WindowInsets26import androidx.compose.foundation.layout.asPaddingValues27import androidx.compose.foundation.layout.navigationBars28import androidx.compose.foundation.layout.fillMaxSize29import androidx.compose.foundation.layout.fillMaxWidth30import androidx.compose.foundation.layout.height31import androidx.compose.foundation.layout.offset32import androidx.compose.foundation.layout.padding33import androidx.compose.foundation.layout.size34import androidx.compose.foundation.layout.navigationBarsPadding35import androidx.compose.foundation.layout.statusBarsPadding36import androidx.compose.foundation.shape.RoundedCornerShape37import androidx.compose.foundation.rememberScrollState38import androidx.compose.foundation.verticalScroll39import androidx.compose.ui.input.nestedscroll.NestedScrollConnection40import androidx.compose.ui.input.nestedscroll.NestedScrollSource41import androidx.compose.ui.input.nestedscroll.nestedScroll42import androidx.compose.ui.unit.Velocity43import androidx.compose.runtime.Composable44import androidx.compose.runtime.LaunchedEffect45import androidx.compose.runtime.getValue46import androidx.compose.runtime.mutableStateOf47import androidx.compose.runtime.remember48import androidx.compose.runtime.rememberCoroutineScope49import androidx.compose.runtime.rememberUpdatedState50import androidx.compose.runtime.setValue51import androidx.compose.runtime.collectAsState52import androidx.compose.ui.Alignment53import androidx.compose.ui.Modifier54import androidx.compose.ui.draw.clip55import androidx.compose.ui.draw.drawWithCache56import androidx.compose.ui.draw.shadow57import androidx.compose.ui.draw.drawBehind58import androidx.compose.ui.geometry.Offset59import androidx.compose.ui.graphics.BlendMode60import androidx.compose.ui.graphics.Brush61import androidx.compose.ui.graphics.Color62import androidx.compose.ui.graphics.drawscope.DrawScope63import androidx.compose.ui.graphics.graphicsLayer64import androidx.compose.ui.input.pointer.pointerInput65import androidx.compose.ui.platform.LocalDensity66import androidx.compose.ui.unit.Dp67import androidx.compose.ui.text.font.FontStyle68import androidx.compose.ui.text.font.FontWeight69import androidx.compose.ui.text.style.TextOverflow70import androidx.compose.ui.unit.dp71import androidx.compose.ui.unit.em72import androidx.compose.ui.unit.sp73import androidx.compose.ui.zIndex74import androidx.hilt.navigation.compose.hiltViewModel75import androidx.compose.material3.Text76import androidx.compose.ui.text.style.TextAlign77import com.peachpasswords.BuildConfig78import com.peachpasswords.ui.components.PeachTopBar79import com.peachpasswords.ui.theme.LocalPeachAccent80import com.peachpasswords.ui.theme.LocalReduceMotion81import com.peachpasswords.ui.theme.PeachSpacing82import com.peachpasswords.ui.theme.peachColors83import com.peachpasswords.ui.theme.PeachMotion84import com.peachpasswords.ui.theme.PeachTheme85import com.peachpasswords.ui.theme.PeachStackColors86import com.peachpasswords.ui.theme.PeachType87import com.peachpasswords.ui.theme.ShellBackground88import kotlinx.coroutines.delay89import kotlinx.coroutines.launch9091// Deck geometry — ported verbatim from StackDeck.tsx constants. The active card92// sits at top:0; every other card is a thin tab fanned below it, ordered93// cyclically starting just after the active card so the stack reads as a loop.94private val EXPANDED_H = 258.dp95private val TAB_H = 46.dp96private val TAB_OVERLAP = 14.dp97private val TAB_CARD_H = TAB_H + 4.dp9899/**100 * Vertical room the build label needs at the bottom corner, excluding its101 * navigation-bar inset (measured live, since it varies with gesture vs102 * three-button nav): its bottom padding plus roughly one line of 9sp mono.103 */104private val VERSION_LABEL_FOOTPRINT = PeachSpacing.xs + 12.dp105106/** Clean gap left between the lowest fanned card and the build label. */107private val DECK_TO_VERSION_GAP = 1.dp108109/** Drag distance past which a stack card flips between focused and deck states. */110private val DRAG_THRESHOLD = 60.dp111112/** Outcome of releasing a vertical drag on a stack card. */113internal enum class DragOutcome { EXPAND, COLLAPSE, NONE }114115/**116 * Resolve a released drag on a stack card (brief Fix 7). Dragging a collapsed117 * (deck) card up past the threshold focuses it; dragging the focused card down118 * past the threshold returns to the deck. Anything short of the threshold is a no-op.119 */120internal fun resolveStackDrag(dragOffsetPx: Float, thresholdPx: Float, isExpanded: Boolean): DragOutcome = when {121    dragOffsetPx < -thresholdPx && !isExpanded -> DragOutcome.EXPAND122    dragOffsetPx > thresholdPx && isExpanded -> DragOutcome.COLLAPSE123    else -> DragOutcome.NONE124}125126/**127 * Fixed vertical stride between consecutive collapsed tabs — browser parity128 * (StackDeck.tsx: `EXPANDED_H - TAB_OVERLAP + distance * (TAB_H - TAB_OVERLAP)`).129 * The deck reads as a tight physical stack fanned just below the active card, not130 * spread to fill the whole screen. Each tab overlaps its neighbour by [TAB_OVERLAP].131 */132private val COLLAPSED_TAB_STRIDE = TAB_H - TAB_OVERLAP133134/**135 * Top offset (from the deck top) of a collapsed tab at the given cyclic [distance]136 * from the active card. Mirrors the browser's deck-open layout so positions match137 * one-to-one. The active card sits at 0; the first fanned tab sits just under the138 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].139 */140internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =141    expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance142143/**144 * The deck fills the available height down to just above the build label pinned145 * to the bottom corner. [navBarBottom] is the label's navigation-bar inset,146 * measured live by the caller (it varies with gesture vs three-button nav); here147 * we additionally reserve the label's own padding/line height148 * ([VERSION_LABEL_FOOTPRINT]) and a small visual gap ([DECK_TO_VERSION_GAP]) so149 * the lowest fanned card sits cleanly above the label on every screen — phone or150 * Fold. A previous fixed gutter ignored the nav inset, so it stranded a band of151 * dead space below the stack (and clipped the Pro card's copy/reveal buttons)152 * wherever the two didn't happen to line up.153 */154internal fun settingsDeckHeight(availableHeight: Dp, navBarBottom: Dp): Dp {155    val reserved = navBarBottom + VERSION_LABEL_FOOTPRINT + DECK_TO_VERSION_GAP156    return (availableHeight - reserved).coerceAtLeast(0.dp)157}158159@Composable160fun SettingsScreen(161    onBack: () -> Unit,162    onNavigate: (com.peachpasswords.ui.PeachNav) -> Unit = {},163    vm: SettingsViewModel = hiltViewModel(),164) {165    val entries by vm.entries.collectAsState()166    val settings by vm.settings.collectAsState()167    val isPro by vm.isPro.collectAsState()168    val showCelebration by vm.showCelebration.collectAsState()169    val reduceMotion = LocalReduceMotion.current170    val accent = LocalPeachAccent.current171    val scope = rememberCoroutineScope()172173    var active by remember { mutableStateOf(CardId.Pro) }174    var deckOpen by remember { mutableStateOf(true) }175    var fanning by remember { mutableStateOf(false) }176    var celebrating by remember { mutableStateOf<CardId?>(null) }177178    val activeIndex = STACK_CARDS.indexOfFirst { it.id == active }.coerceAtLeast(0)179180    fun handleBack() {181        if (!deckOpen) deckOpen = true else onBack()182    }183    BackHandler { handleBack() }184185    // Pro celebration: replay every card in a quick fan, land back on Pro, shake.186    fun celebrate() {187        if (fanning) return188        scope.launch {189            fanning = true190            active = CardId.Pro191            CELEBRATION_FAN.forEachIndexed { i, id ->192                delay(150L)193                active = id194            }195            delay(150L)196            active = CardId.Pro197            fanning = false198            celebrating = CardId.Pro199            // Matches browser's PRO_CELEBRATION_THANK_YOU_MS (Settings.tsx) — the200            // window the spark burst + "thank you" text stay mounted for.201            delay(3200L)202            celebrating = null203        }204    }205206    PeachTheme(accent = accent, reduceMotion = reduceMotion) {207    ShellBackground {208        Column(209            Modifier210                .fillMaxSize()211                .statusBarsPadding()212                .padding(horizontal = PeachSpacing.md),213        ) {214            SettingsTopBar(215                activeIndex = activeIndex,216                total = STACK_CARDS.size,217                onBack = { handleBack() },218            )219            Spacer(Modifier.height(PeachSpacing.sm))220221            BoxWithConstraints(Modifier.fillMaxSize()) {222                val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()223                val deckHeight = settingsDeckHeight(maxHeight, navBarBottom)224                val focusHeight = deckHeight - 8.dp225                // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal226                // browser-popup constant) for the active card's preview height,227                // leaving the same dead space below the fanned tabs on a phone228                // screen that settingsDeckHeight itself was just fixed for. Size it229                // so the active preview + all 6 fanned tabs together fill deckHeight,230                // never shrinking below the original browser-derived minimum.231                val expandedH = (232                    deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H233                ).coerceAtLeast(EXPANDED_H)234                Box(Modifier.fillMaxWidth().height(deckHeight)) {235                    STACK_CARDS.forEachIndexed { index, card ->236                        val isActive = card.id == active237                        val distance = when {238                            index == activeIndex -> -1239                            index > activeIndex -> index - activeIndex - 1240                            else -> STACK_CARDS.size - activeIndex + index - 1241                        }242                        val targetTop = when {243                            isActive -> 0.dp244                            !deckOpen -> focusHeight + 44.dp + (distance * 8).dp245                            else -> collapsedTabTop(distance, expandedH)246                        }247                        val targetHeight = when {248                            isActive && deckOpen -> expandedH249                            isActive -> focusHeight250                            else -> TAB_CARD_H251                        }252                        val concealed = !deckOpen && !isActive253254                        // The drag-to-focus/collapse gesture is a continuous, interruptible255                        // user interaction (not a one-shot navigation transition), so it gets256                        // real spring physics here rather than the fixed-duration tween used257                        // for the one-shot celebration fan — matching PeachExpandableCard and258                        // current M3 Expressive's physics-based spatial-spring motion model.259                        val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)260                        val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)261                        val top by animateDpAsState(262                            targetTop,263                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,264                            label = "top",265                        )266                        val cardH by animateDpAsState(267                            targetHeight,268                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,269                            label = "h",270                        )271                        val alpha by animateFloatAsState(272                            if (concealed) 0f else 1f,273                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,274                            label = "a",275                        )276                        val zIndex = if (isActive) 99f else (50 - distance).toFloat()277278                        val density = LocalDensity.current279                        StackCard(280                            spec = card,281                            active = isActive,282                            focused = isActive && !deckOpen,283                            lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),284                            showSparkle = card.id == CardId.Pro && isPro,285                            celebrating = celebrating == card.id,286                            onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,287                            body = {288                                CardBody(289                                    id = card.id,290                                    fg = card.fg,291                                    vm = vm,292                                    settings = settings,293                                    entriesCount = entries.count { it.deletedAt == null },294                                    onNavigate = onNavigate,295                                )296                            },297                            modifier = Modifier298                                .fillMaxWidth()299                                .align(Alignment.TopStart)300                                .zIndex(zIndex)301                                // Drag the deck like a physical stack: flick a tab up to focus302                                // it. Only wired for non-active tabs — the active card's own303                                // scrollable body handles its half of the gesture (expand from304                                // preview, collapse from focus) via StackCard's nested scroll305                                // connection below. Attaching this to the active card too used306                                // to fight that connection for the same touch: this pointerInput307                                // keys on `deckOpen`/`isActive`, which the nested-scroll actions308                                // mutate mid-gesture, restarting this detector with the finger309                                // still down and firing its own independent resolution — a310                                // visible flicker as two systems retargeted the spring in quick311                                // succession.312                                .then(313                                    if (!isActive) {314                                        Modifier.pointerInput(card.id, deckOpen) {315                                            var dragY = 0f316                                            val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }317                                            detectDragGestures(318                                                onDragStart = { dragY = 0f },319                                                onDrag = { change, amount -> change.consume(); dragY += amount.y },320                                                onDragEnd = {321                                                    if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {322                                                        active = card.id323                                                        deckOpen = false324                                                    }325                                                },326                                            )327                                        }328                                    } else {329                                        Modifier330                                    },331                                )332                                .graphicsLayer {333                                    translationY = top.toPx()334                                    this.alpha = alpha335                                }336                                .height(cardH),337                            onClick = {338                                // Tapping the active card is a no-op (browser StackDeck339                                // parity); flick it up to focus, a tab up to focus that one.340                                if (!isActive) {341                                    active = card.id342                                    deckOpen = false343                                }344                            },345                            onCollapseRequest = { deckOpen = true },346                            onExpandRequest = { active = card.id; deckOpen = false },347                            enabled = !concealed,348                        )349                    }350                }351352                // Browser parity (.stk-version-corner): the build, faintly, in the353                // gutter the deck already leaves below itself. Worth having on the354                // phone specifically — sync is a lockstep wire contract, so "which355                // build is actually on this device" is the first question when356                // pairing misbehaves.357                Text(358                    "v${BuildConfig.VERSION_NAME}",359                    color = peachColors.T2.copy(alpha = 0.5f),360                    fontFamily = PeachType.Mono,361                    fontSize = 9.sp,362                    textAlign = TextAlign.Center,363                    modifier = Modifier364                        .align(Alignment.BottomCenter)365                        .fillMaxWidth()366                        // The screen's Column only pads the status bar, so without367                        // this the label lands under the gesture pill.368                        .navigationBarsPadding()369                        .padding(bottom = PeachSpacing.xs),370                )371            }372        }373        if (showCelebration) {374            ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })375        }376    }377    }378}379380@Composable381private fun StackCard(382    spec: StackCardSpec,383    active: Boolean,384    focused: Boolean,385    lede: String,386    showSparkle: Boolean,387    celebrating: Boolean,388    onIconClick: (() -> Unit)?,389    body: @Composable () -> Unit,390    modifier: Modifier,391    onClick: () -> Unit,392    onCollapseRequest: () -> Unit,393    onExpandRequest: () -> Unit,394    enabled: Boolean,395) {396    val interaction = remember { MutableInteractionSource() }397    Box(398        modifier399            .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)400            .clip(RoundedCornerShape(PeachStackColors.CardRadius))401            .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }402            .clickable(403                interactionSource = interaction,404                indication = null,405                enabled = enabled && !active || (active && !focused),406            ) { onClick() }407            .padding(408                horizontal = if (active) 18.dp else 16.dp,409                vertical = if (active) 16.dp else 8.dp,410            ),411    ) {412        if (showSparkle) SparkleDust(Modifier.fillMaxSize())413        if (active) {414            Column(Modifier.fillMaxSize()) {415                Row(416                    Modifier.fillMaxWidth(),417                    horizontalArrangement = Arrangement.SpaceBetween,418                    verticalAlignment = Alignment.CenterVertically,419                ) {420                    Text(421                        spec.num,422                        color = spec.fg.copy(alpha = 0.7f),423                        fontFamily = PeachType.Mono,424                        fontSize = 8.5.sp,425                        letterSpacing = 0.26.em,426                    )427                    Box(contentAlignment = Alignment.Center) {428                        Box(429                            Modifier.clip(RoundedCornerShape(999.dp))430                                .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)431                                .padding(2.dp),432                        ) {433                            CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))434                        }435                        // Not clipped like the icon above it — sparks fly past the436                        // icon's own circular hit target (peach-pro-spark-fly, up437                        // to 54dp out from centre).438                        if (celebrating) ProSparkBurst(color = spec.fg)439                    }440                }441                Text(442                    spec.title,443                    modifier = Modifier.padding(top = 6.dp),444                    color = spec.fg,445                    fontFamily = PeachType.Display,446                    fontStyle = FontStyle.Italic,447                    fontSize = 26.sp,448                )449                Text(450                    lede,451                    modifier = Modifier.padding(top = 4.dp),452                    color = spec.fg.copy(alpha = 0.72f),453                    fontFamily = PeachType.Display,454                    fontStyle = FontStyle.Italic,455                    fontSize = 12.5.sp,456                )457                if (celebrating) ProThankYouFlash(color = spec.fg)458                // Body scrolls independently; only revealed at full height in459                // focus mode, but kept mounted so deck-mode shows a preview.460                // Two-way scroll gesture, mirrored: dragging up on a collapsed-461                // to-preview card expands it straight away (a cramped preview462                // has nothing worth scrolling, so intercept in onPreScroll463                // before its own verticalScroll ever sees the drag) — down-only464                // is deliberately excluded so the same downward motion that465                // just collapsed a card can't immediately re-open it. Dragging466                // a focused card down past its own scroll top hands the467                // leftover delta to the deck to collapse, instead of letting468                // it disappear as a no-op overscroll.469                val scrollState = rememberScrollState()470                val density = LocalDensity.current471                val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }472                // Read the latest focused/active through State rather than as473                // remember() keys, so the connection object (and its accumulators)474                // survives recompositions the gesture's own resolution triggers.475                val latestFocused = rememberUpdatedState(focused)476                val latestActive = rememberUpdatedState(active)477                val nestedScrollConnection = remember(scrollState) {478                    object : NestedScrollConnection {479                        var expandAccum = 0f480                        var collapseAccum = 0f481                        // Resolved once on release (onPreFling), not continuously482                        // mid-drag — matching resolveStackDrag's on-release convention483                        // above. Firing mid-drag flipped `focused` while the finger484                        // was still delivering deltas from the same physical touch,485                        // which the OTHER stack-tab pointerInput (keyed on `deckOpen`)486                        // would also observe and restart on, independently resolving487                        // the same gesture a second time — a visible flicker.488                        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {489                            if (!latestActive.value || latestFocused.value || source != NestedScrollSource.UserInput || available.y == 0f) {490                                return Offset.Zero491                            }492                            expandAccum += available.y493                            return available494                        }495                        override fun onPostScroll(496                            consumed: Offset,497                            available: Offset,498                            source: NestedScrollSource,499                        ): Offset {500                            if (!latestFocused.value || source != NestedScrollSource.UserInput || scrollState.value != 0 || available.y <= 0f) {501                                return Offset.Zero502                            }503                            collapseAccum += available.y504                            return Offset(0f, available.y)505                        }506                        override suspend fun onPreFling(available: Velocity): Velocity {507                            // Upward drags only (matches the tab-flick-up convention508                            // used elsewhere in this deck). A downward drag repeated509                            // right after a collapse — muscle memory from "scroll to510                            // top to collapse" — would otherwise re-satisfy this on511                            // the now-unfocused preview and flicker straight back open.512                            if (expandAccum <= -gestureThresholdPx) {513                                onExpandRequest()514                            } else if (collapseAccum >= gestureThresholdPx) {515                                onCollapseRequest()516                            }517                            expandAccum = 0f518                            collapseAccum = 0f519                            return Velocity.Zero520                        }521                    }522                }523                Column(524                    Modifier.fillMaxWidth().weight(1f)525                        .padding(top = 14.dp)526                        .nestedScroll(nestedScrollConnection)527                        .verticalScroll(scrollState),528                ) {529                    body()530                    // Trailing breathing room so bottom-of-card content (e.g. the531                    // Pro card's CTA button) never sits flush against the fanned532                    // tab stacked directly beneath it.533                    Spacer(Modifier.height(PeachSpacing.xl))534                }535            }536        } else {537            // Tab: just the title, single line, vertically centred.538            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {539                Text(540                    spec.title,541                    color = spec.fg,542                    fontFamily = PeachType.Display,543                    fontStyle = FontStyle.Italic,544                    fontWeight = FontWeight.Normal,545                    fontSize = 19.sp,546                    maxLines = 1,547                    overflow = TextOverflow.Ellipsis,548                )549            }550        }551    }552}553554@Composable555private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {556    PeachTopBar(557        title = "Settings",558        leading = {559            Box(560                Modifier561                    .size(40.dp)562                    .clip(RoundedCornerShape(999.dp))563                    .clickable(onClick = onBack),564                contentAlignment = Alignment.Center,565            ) {566                Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)567            }568        },569        actions = {570            Text(571                "${activeIndex + 1} / $total",572                color = peachColors.T2,573                fontFamily = PeachType.Mono,574                fontSize = 9.sp,575                letterSpacing = 0.22.em,576                modifier = Modifier.padding(horizontal = PeachSpacing.sm),577            )578        },579    )580}581582583/**584 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed585 * from the live draw size so the angle holds on any card height, unlike Compose's586 * default corner-to-corner linear gradient.587 */588private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {589    val a = Math.toRadians(155.0)590    val dx = kotlin.math.sin(a).toFloat()591    val dy = -kotlin.math.cos(a).toFloat()592    val cx = size.width / 2f593    val cy = size.height / 2f594    val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f595    return Brush.linearGradient(596        colors = listOf(start, end),597        start = Offset(cx - dx * half, cy - dy * half),598        end = Offset(cx + dx * half, cy + dy * half),599    )600}601602private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)603604// Exact positions/delays/glyphs from the browser's .peach-pro-spark spans605// (Settings.tsx) — an 8-point radial burst, not a uniform ring.606private val PRO_SPARKS = listOf(607    ProSpark(0f, -54f, 0, "✦"),608    ProSpark(38f, -38f, 50, "✦"),609    ProSpark(54f, 0f, 110, "·"),610    ProSpark(38f, 38f, 30, "✦"),611    ProSpark(0f, 54f, 80, "·"),612    ProSpark(-38f, 38f, 130, "✦"),613    ProSpark(-54f, 0f, 55, "·"),614    ProSpark(-38f, -38f, 95, "✦"),615)616617/**618 * The Pro icon's celebration burst — 8 glyphs flying outward from centre and619 * fading, each on its own delay. Ports `peach-pro-spark-fly` (1.4s ease-out):620 * opacity 0→1 over the first quarter, then 1→0 through to the end, while621 * position/scale interpolate linearly (eased) across the full duration.622 */623@Composable624private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {625    if (LocalReduceMotion.current) return626    Box(modifier) {627        PRO_SPARKS.forEach { spark ->628            val progress = remember(spark) { Animatable(0f) }629            LaunchedEffect(spark) {630                delay(spark.delayMs.toLong())631                progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))632            }633            val t = progress.value634            val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f635            val scale = 0.5f + 0.3f * t636            Text(637                spark.glyph,638                color = color.copy(alpha = alpha.coerceIn(0f, 1f)),639                fontSize = 9.sp,640                modifier = Modifier641                    .align(Alignment.Center)642                    .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)643                    .graphicsLayer { scaleX = scale; scaleY = scale },644            )645        }646    }647}648649/**650 * "thank you ✦" beneath the Pro card's lede while celebrating — ports651 * `peach-pro-thankyou-flash` (2.5s ease: fade in by 15%, hold, fade out652 * from 75%).653 */654@Composable655private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {656    val reduceMotion = LocalReduceMotion.current657    val alpha = remember { Animatable(0f) }658    LaunchedEffect(Unit) {659        if (reduceMotion) {660            alpha.snapTo(0.75f)661        } else {662            alpha.animateTo(663                0f,664                keyframes {665                    durationMillis = 2500666                    0f at 0667                    0.75f at 375668                    0.75f at 1875669                    0f at 2500670                },671            )672        }673    }674    Text(675        "thank you ✦",676        modifier = modifier.padding(top = 4.dp),677        color = color.copy(alpha = alpha.value),678        fontFamily = PeachType.Display,679        fontStyle = FontStyle.Italic,680        fontSize = 11.sp,681    )682}683684/**685 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly686 * translate diagonally and loop, screen-blended at low opacity — the Compose687 * analogue of the browser's `stk-pro-dust` keyframes.688 */689@Composable690private fun SparkleDust(modifier: Modifier = Modifier) {691    val reduceMotion = LocalReduceMotion.current692    val t = rememberInfiniteTransition(label = "dust")693    val drift by if (reduceMotion) {694        remember { mutableStateOf(0f) }695    } else {696        t.animateFloat(697            initialValue = 0f,698            targetValue = 1f,699            animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),700            label = "drift",701        )702    }703    Box(704        modifier.drawWithCache {705            val tile = 48.dp.toPx()706            val dx = drift * tile707            onDrawBehind {708                val cols = (size.width / tile).toInt() + 2709                val rows = (size.height / tile).toInt() + 2710                for (r in 0..rows) {711                    for (c in 0..cols) {712                        val baseX = c * tile + dx713                        val baseY = r * tile + dx714                        drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)715                        drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)716                    }717                }718            }719        },720    )721}