SettingsScreen.kt

32.7 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    // The Settings stack always renders dark, regardless of the app theme193    // (spec §5.5/§8): force a dark PeachTheme around it — this also darkens the194    // ShellBackground base (which keys off MaterialTheme) — while keeping the195    // user's chosen accent and reduce-motion preference.196    PeachTheme(darkTheme = true, accent = accent, reduceMotion = reduceMotion) {197    ShellBackground {198        Column(199            Modifier200                .fillMaxSize()201                .statusBarsPadding()202                .padding(horizontal = PeachSpacing.md),203        ) {204            SettingsTopBar(205                activeIndex = activeIndex,206                total = STACK_CARDS.size,207                onBack = { handleBack() },208            )209            Spacer(Modifier.height(PeachSpacing.sm))210211            BoxWithConstraints(Modifier.fillMaxSize()) {212                val deckHeight = settingsDeckHeight(maxHeight)213                val focusHeight = deckHeight - 8.dp214                // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal215                // browser-popup constant) for the active card's preview height,216                // leaving the same dead space below the fanned tabs on a phone217                // screen that settingsDeckHeight itself was just fixed for. Size it218                // so the active preview + all 6 fanned tabs together fill deckHeight,219                // never shrinking below the original browser-derived minimum.220                val expandedH = (221                    deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H222                ).coerceAtLeast(EXPANDED_H)223                Box(Modifier.fillMaxWidth().height(deckHeight)) {224                    STACK_CARDS.forEachIndexed { index, card ->225                        val isActive = card.id == active226                        val distance = when {227                            index == activeIndex -> -1228                            index > activeIndex -> index - activeIndex - 1229                            else -> STACK_CARDS.size - activeIndex + index - 1230                        }231                        val targetTop = when {232                            isActive -> 0.dp233                            !deckOpen -> focusHeight + 44.dp + (distance * 8).dp234                            else -> collapsedTabTop(distance, expandedH)235                        }236                        val targetHeight = when {237                            isActive && deckOpen -> expandedH238                            isActive -> focusHeight239                            else -> TAB_CARD_H240                        }241                        val concealed = !deckOpen && !isActive242243                        // The drag-to-focus/collapse gesture is a continuous, interruptible244                        // user interaction (not a one-shot navigation transition), so it gets245                        // real spring physics here rather than the fixed-duration tween used246                        // for the one-shot celebration fan — matching PeachExpandableCard and247                        // current M3 Expressive's physics-based spatial-spring motion model.248                        val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)249                        val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)250                        val top by animateDpAsState(251                            targetTop,252                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,253                            label = "top",254                        )255                        val cardH by animateDpAsState(256                            targetHeight,257                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,258                            label = "h",259                        )260                        val alpha by animateFloatAsState(261                            if (concealed) 0f else 1f,262                            animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,263                            label = "a",264                        )265                        val zIndex = if (isActive) 99f else (50 - distance).toFloat()266267                        val density = LocalDensity.current268                        StackCard(269                            spec = card,270                            active = isActive,271                            focused = isActive && !deckOpen,272                            lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),273                            showSparkle = card.id == CardId.Pro && isPro,274                            celebrating = celebrating == card.id,275                            onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,276                            body = {277                                CardBody(278                                    id = card.id,279                                    fg = card.fg,280                                    vm = vm,281                                    settings = settings,282                                    entriesCount = entries.count { it.deletedAt == null },283                                    onNavigate = onNavigate,284                                )285                            },286                            modifier = Modifier287                                .fillMaxWidth()288                                .align(Alignment.TopStart)289                                .zIndex(zIndex)290                                // Drag the deck like a physical stack: flick a tab up to focus291                                // it. Only wired for non-active tabs — the active card's own292                                // scrollable body handles its half of the gesture (expand from293                                // preview, collapse from focus) via StackCard's nested scroll294                                // connection below. Attaching this to the active card too used295                                // to fight that connection for the same touch: this pointerInput296                                // keys on `deckOpen`/`isActive`, which the nested-scroll actions297                                // mutate mid-gesture, restarting this detector with the finger298                                // still down and firing its own independent resolution — a299                                // visible flicker as two systems retargeted the spring in quick300                                // succession.301                                .then(302                                    if (!isActive) {303                                        Modifier.pointerInput(card.id, deckOpen) {304                                            var dragY = 0f305                                            val thresholdPx = with(density) { DRAG_THRESHOLD.toPx() }306                                            detectDragGestures(307                                                onDragStart = { dragY = 0f },308                                                onDrag = { change, amount -> change.consume(); dragY += amount.y },309                                                onDragEnd = {310                                                    if (resolveStackDrag(dragY, thresholdPx, false) == DragOutcome.EXPAND) {311                                                        active = card.id312                                                        deckOpen = false313                                                    }314                                                },315                                            )316                                        }317                                    } else {318                                        Modifier319                                    },320                                )321                                .graphicsLayer {322                                    translationY = top.toPx()323                                    this.alpha = alpha324                                }325                                .height(cardH),326                            onClick = {327                                // Tapping the active card is a no-op (browser StackDeck328                                // parity); flick it up to focus, a tab up to focus that one.329                                if (!isActive) {330                                    active = card.id331                                    deckOpen = false332                                }333                            },334                            onCollapseRequest = { deckOpen = true },335                            onExpandRequest = { active = card.id; deckOpen = false },336                            enabled = !concealed,337                        )338                    }339                }340341                // Browser parity (.stk-version-corner): the build, faintly, in the342                // gutter the deck already leaves below itself. Worth having on the343                // phone specifically — sync is a lockstep wire contract, so "which344                // build is actually on this device" is the first question when345                // pairing misbehaves.346                Text(347                    "v${BuildConfig.VERSION_NAME}",348                    color = peachColors.T2.copy(alpha = 0.5f),349                    fontFamily = PeachType.Mono,350                    fontSize = 9.sp,351                    textAlign = TextAlign.Center,352                    modifier = Modifier353                        .align(Alignment.BottomCenter)354                        .fillMaxWidth()355                        // The screen's Column only pads the status bar, so without356                        // this the label lands under the gesture pill.357                        .navigationBarsPadding()358                        .padding(bottom = PeachSpacing.xs),359                )360            }361        }362        if (showCelebration) {363            ProCelebration(onDone = { vm.dismissCelebration(); celebrate() })364        }365    }366    }367}368369@Composable370private fun StackCard(371    spec: StackCardSpec,372    active: Boolean,373    focused: Boolean,374    lede: String,375    showSparkle: Boolean,376    celebrating: Boolean,377    onIconClick: (() -> Unit)?,378    body: @Composable () -> Unit,379    modifier: Modifier,380    onClick: () -> Unit,381    onCollapseRequest: () -> Unit,382    onExpandRequest: () -> Unit,383    enabled: Boolean,384) {385    val interaction = remember { MutableInteractionSource() }386    Box(387        modifier388            .shadow(if (active) 18.dp else 8.dp, RoundedCornerShape(PeachStackColors.CardRadius), clip = false)389            .clip(RoundedCornerShape(PeachStackColors.CardRadius))390            .drawBehind { drawRect(brush = cardGradient155(spec.bgStart, spec.bgEnd)) }391            .clickable(392                interactionSource = interaction,393                indication = null,394                enabled = enabled && !active || (active && !focused),395            ) { onClick() }396            .padding(397                horizontal = if (active) 18.dp else 16.dp,398                vertical = if (active) 16.dp else 12.dp,399            ),400    ) {401        if (showSparkle) SparkleDust(Modifier.fillMaxSize())402        if (active) {403            Column(Modifier.fillMaxSize()) {404                Row(405                    Modifier.fillMaxWidth(),406                    horizontalArrangement = Arrangement.SpaceBetween,407                    verticalAlignment = Alignment.CenterVertically,408                ) {409                    Text(410                        spec.num,411                        color = spec.fg.copy(alpha = 0.7f),412                        fontFamily = PeachType.Mono,413                        fontSize = 8.5.sp,414                        letterSpacing = 0.26.em,415                    )416                    Box(contentAlignment = Alignment.Center) {417                        Box(418                            Modifier.clip(RoundedCornerShape(999.dp))419                                .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)420                                .padding(2.dp),421                        ) {422                            CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))423                        }424                        // Not clipped like the icon above it — sparks fly past the425                        // icon's own circular hit target (peach-pro-spark-fly, up426                        // to 54dp out from centre).427                        if (celebrating) ProSparkBurst(color = spec.fg)428                    }429                }430                Text(431                    spec.title,432                    modifier = Modifier.padding(top = 6.dp),433                    color = spec.fg,434                    fontFamily = PeachType.Display,435                    fontStyle = FontStyle.Italic,436                    fontSize = 26.sp,437                )438                Text(439                    lede,440                    modifier = Modifier.padding(top = 4.dp),441                    color = spec.fg.copy(alpha = 0.72f),442                    fontFamily = PeachType.Display,443                    fontStyle = FontStyle.Italic,444                    fontSize = 12.5.sp,445                )446                if (celebrating) ProThankYouFlash(color = spec.fg)447                // Body scrolls independently; only revealed at full height in448                // focus mode, but kept mounted so deck-mode shows a preview.449                // Two-way scroll gesture, mirrored: dragging up on a collapsed-450                // to-preview card expands it straight away (a cramped preview451                // has nothing worth scrolling, so intercept in onPreScroll452                // before its own verticalScroll ever sees the drag) — down-only453                // is deliberately excluded so the same downward motion that454                // just collapsed a card can't immediately re-open it. Dragging455                // a focused card down past its own scroll top hands the456                // leftover delta to the deck to collapse, instead of letting457                // it disappear as a no-op overscroll.458                val scrollState = rememberScrollState()459                val density = LocalDensity.current460                val gestureThresholdPx = with(density) { DRAG_THRESHOLD.toPx() }461                // Read the latest focused/active through State rather than as462                // remember() keys, so the connection object (and its accumulators)463                // survives recompositions the gesture's own resolution triggers.464                val latestFocused = rememberUpdatedState(focused)465                val latestActive = rememberUpdatedState(active)466                val nestedScrollConnection = remember(scrollState) {467                    object : NestedScrollConnection {468                        var expandAccum = 0f469                        var collapseAccum = 0f470                        // Resolved once on release (onPreFling), not continuously471                        // mid-drag — matching resolveStackDrag's on-release convention472                        // above. Firing mid-drag flipped `focused` while the finger473                        // was still delivering deltas from the same physical touch,474                        // which the OTHER stack-tab pointerInput (keyed on `deckOpen`)475                        // would also observe and restart on, independently resolving476                        // the same gesture a second time — a visible flicker.477                        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {478                            if (!latestActive.value || latestFocused.value || source != NestedScrollSource.Drag || available.y == 0f) {479                                return Offset.Zero480                            }481                            expandAccum += available.y482                            return available483                        }484                        override fun onPostScroll(485                            consumed: Offset,486                            available: Offset,487                            source: NestedScrollSource,488                        ): Offset {489                            if (!latestFocused.value || source != NestedScrollSource.Drag || scrollState.value != 0 || available.y <= 0f) {490                                return Offset.Zero491                            }492                            collapseAccum += available.y493                            return Offset(0f, available.y)494                        }495                        override suspend fun onPreFling(available: Velocity): Velocity {496                            // Upward drags only (matches the tab-flick-up convention497                            // used elsewhere in this deck). A downward drag repeated498                            // right after a collapse — muscle memory from "scroll to499                            // top to collapse" — would otherwise re-satisfy this on500                            // the now-unfocused preview and flicker straight back open.501                            if (expandAccum <= -gestureThresholdPx) {502                                onExpandRequest()503                            } else if (collapseAccum >= gestureThresholdPx) {504                                onCollapseRequest()505                            }506                            expandAccum = 0f507                            collapseAccum = 0f508                            return Velocity.Zero509                        }510                    }511                }512                Column(513                    Modifier.fillMaxWidth().weight(1f)514                        .padding(top = 14.dp)515                        .nestedScroll(nestedScrollConnection)516                        .verticalScroll(scrollState),517                ) {518                    body()519                    // Trailing breathing room so bottom-of-card content (e.g. the520                    // Pro card's CTA button) never sits flush against the fanned521                    // tab stacked directly beneath it.522                    Spacer(Modifier.height(PeachSpacing.xl))523                }524            }525        } else {526            // Tab: just the title, single line, vertically centred.527            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {528                Text(529                    spec.title,530                    color = spec.fg,531                    fontFamily = PeachType.Display,532                    fontStyle = FontStyle.Italic,533                    fontWeight = FontWeight.Normal,534                    fontSize = 19.sp,535                    maxLines = 1,536                    overflow = TextOverflow.Ellipsis,537                )538            }539        }540    }541}542543@Composable544private fun SettingsTopBar(activeIndex: Int, total: Int, onBack: () -> Unit) {545    PeachTopBar(546        title = "Settings",547        leading = {548            Box(549                Modifier550                    .size(40.dp)551                    .clip(RoundedCornerShape(999.dp))552                    .clickable(onClick = onBack),553                contentAlignment = Alignment.Center,554            ) {555                Text("‹", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 18.sp)556            }557        },558        actions = {559            Text(560                "${activeIndex + 1} / $total",561                color = peachColors.T2,562                fontFamily = PeachType.Mono,563                fontSize = 9.sp,564                letterSpacing = 0.22.em,565                modifier = Modifier.padding(horizontal = PeachSpacing.sm),566            )567        },568    )569}570571572/**573 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computed574 * from the live draw size so the angle holds on any card height, unlike Compose's575 * default corner-to-corner linear gradient.576 */577private fun DrawScope.cardGradient155(start: Color, end: Color): Brush {578    val a = Math.toRadians(155.0)579    val dx = kotlin.math.sin(a).toFloat()580    val dy = -kotlin.math.cos(a).toFloat()581    val cx = size.width / 2f582    val cy = size.height / 2f583    val half = (kotlin.math.abs(dx) * size.width + kotlin.math.abs(dy) * size.height) / 2f584    return Brush.linearGradient(585        colors = listOf(start, end),586        start = Offset(cx - dx * half, cy - dy * half),587        end = Offset(cx + dx * half, cy + dy * half),588    )589}590591private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)592593// Exact positions/delays/glyphs from the browser's .peach-pro-spark spans594// (Settings.tsx) — an 8-point radial burst, not a uniform ring.595private val PRO_SPARKS = listOf(596    ProSpark(0f, -54f, 0, "✦"),597    ProSpark(38f, -38f, 50, "✦"),598    ProSpark(54f, 0f, 110, "·"),599    ProSpark(38f, 38f, 30, "✦"),600    ProSpark(0f, 54f, 80, "·"),601    ProSpark(-38f, 38f, 130, "✦"),602    ProSpark(-54f, 0f, 55, "·"),603    ProSpark(-38f, -38f, 95, "✦"),604)605606/**607 * The Pro icon's celebration burst — 8 glyphs flying outward from centre and608 * fading, each on its own delay. Ports `peach-pro-spark-fly` (1.4s ease-out):609 * opacity 0→1 over the first quarter, then 1→0 through to the end, while610 * position/scale interpolate linearly (eased) across the full duration.611 */612@Composable613private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {614    if (LocalReduceMotion.current) return615    Box(modifier) {616        PRO_SPARKS.forEach { spark ->617            val progress = remember(spark) { Animatable(0f) }618            LaunchedEffect(spark) {619                delay(spark.delayMs.toLong())620                progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))621            }622            val t = progress.value623            val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f624            val scale = 0.5f + 0.3f * t625            Text(626                spark.glyph,627                color = color.copy(alpha = alpha.coerceIn(0f, 1f)),628                fontSize = 9.sp,629                modifier = Modifier630                    .align(Alignment.Center)631                    .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)632                    .graphicsLayer { scaleX = scale; scaleY = scale },633            )634        }635    }636}637638/**639 * "thank you ✦" beneath the Pro card's lede while celebrating — ports640 * `peach-pro-thankyou-flash` (2.5s ease: fade in by 15%, hold, fade out641 * from 75%).642 */643@Composable644private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {645    val reduceMotion = LocalReduceMotion.current646    val alpha = remember { Animatable(0f) }647    LaunchedEffect(Unit) {648        if (reduceMotion) {649            alpha.snapTo(0.75f)650        } else {651            alpha.animateTo(652                0f,653                keyframes {654                    durationMillis = 2500655                    0f at 0656                    0.75f at 375657                    0.75f at 1875658                    0f at 2500659                },660            )661        }662    }663    Text(664        "thank you ✦",665        modifier = modifier.padding(top = 4.dp),666        color = color.copy(alpha = alpha.value),667        fontFamily = PeachType.Display,668        fontStyle = FontStyle.Italic,669        fontSize = 11.sp,670    )671}672673/**674 * Ambient drifting sparkle dust for the Pro card. Three offset dot grids slowly675 * translate diagonally and loop, screen-blended at low opacity — the Compose676 * analogue of the browser's `stk-pro-dust` keyframes.677 */678@Composable679private fun SparkleDust(modifier: Modifier = Modifier) {680    val reduceMotion = LocalReduceMotion.current681    val t = rememberInfiniteTransition(label = "dust")682    val drift by if (reduceMotion) {683        remember { mutableStateOf(0f) }684    } else {685        t.animateFloat(686            initialValue = 0f,687            targetValue = 1f,688            animationSpec = infiniteRepeatable(tween(14000, easing = androidx.compose.animation.core.LinearEasing)),689            label = "drift",690        )691    }692    Box(693        modifier.drawWithCache {694            val tile = 48.dp.toPx()695            val dx = drift * tile696            onDrawBehind {697                val cols = (size.width / tile).toInt() + 2698                val rows = (size.height / tile).toInt() + 2699                for (r in 0..rows) {700                    for (c in 0..cols) {701                        val baseX = c * tile + dx702                        val baseY = r * tile + dx703                        drawCircle(Color(0xFFFFF8DC), 0.6f, Offset(baseX % (size.width + tile), baseY % (size.height + tile)), alpha = 0.11f, blendMode = BlendMode.Screen)704                        drawCircle(Color(0xFFFFEEAA), 1f, Offset((baseX + 19f) % (size.width + tile), (baseY + 15f) % (size.height + tile)), alpha = 0.08f, blendMode = BlendMode.Screen)705                    }706                }707            }708        },709    )710}