6708bbf7f7ac

rewrite ProCelebration.kt and the settings-deck Pro icon-tap replay to match the browser's actual celebration effects (ProCelebration.tsx + popup.css), replacing two Android-invented effects that didn't exist in the source: full-screen overlay now uses two corner accent blooms + a glow pulse behind PeachMark + staggered wordmark/thankyou fade-in-from-below (was a single centered flat circle + a continuously orbiting 12-dot sparkle ring with no such browser equivalent), and the Pro-icon celebration replay now plays the exact 8-point radial spark burst (alternating sparkle/dot glyphs, individually staggered, matching browser's spark-x/spark-y/spark-delay values) plus a 'thank you ✦' text flash (was a physical shake/bob with no browser equivalent); also fixed the replay's hold duration from 900ms to 3200ms to match browser's PRO_CELEBRATION_THANK_YOU_MS. Live-verified: activated the real test license key, watched the full-screen celebration play through cleanly (no crash), then tapped the Pro card icon and captured the spark glyphs + thank-you text mid-animation via uiautomator dump

time
2026-07-24 18:02 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
match Pro celebration to browser
parent
0afcf4f6c6b4
2 files app/src/main/java/com/peachpasswords/ui/settings/ProCelebration.kt+88 −44app/src/main/java/com/peachpasswords/ui/settings/SettingsScreen.kt+103 −27
app/src/main/java/com/peachpasswords/ui/settings/ProCelebration.kt+88 −44
@@ -1,25 +1,28 @@11 package com.peachpasswords.ui.settings22 3-import androidx.compose.animation.core.LinearEasing4-import androidx.compose.animation.core.animateFloat5-import androidx.compose.animation.core.rememberInfiniteTransition6-import androidx.compose.animation.core.infiniteRepeatable3+import androidx.compose.animation.core.Animatable4+import androidx.compose.animation.core.CubicBezierEasing5+import androidx.compose.animation.core.keyframes76 import androidx.compose.animation.core.tween7+import androidx.compose.foundation.Canvas88 import androidx.compose.foundation.background99 import androidx.compose.foundation.clickable1010 import androidx.compose.foundation.layout.Box1111 import androidx.compose.foundation.layout.Column1212 import androidx.compose.foundation.layout.fillMaxSize13+import androidx.compose.foundation.layout.offset1314 import androidx.compose.foundation.layout.padding14-import androidx.compose.foundation.Canvas15+import androidx.compose.foundation.layout.size1516 import androidx.compose.material3.Text1617 import androidx.compose.runtime.Composable1718 import androidx.compose.runtime.LaunchedEffect1819 import androidx.compose.runtime.getValue20+import androidx.compose.runtime.remember1921 import androidx.compose.ui.Alignment2022 import androidx.compose.ui.Modifier2123 import androidx.compose.ui.geometry.Offset22-import androidx.compose.ui.graphics.Color24+import androidx.compose.ui.graphics.Brush25+import androidx.compose.ui.graphics.graphicsLayer2326 import androidx.compose.ui.text.TextStyle2427 import androidx.compose.ui.text.font.FontStyle2528 import androidx.compose.ui.text.style.TextAlign@@ -31,79 +34,120 @@3134 import com.peachpasswords.ui.theme.peachColors3235 import com.peachpasswords.ui.theme.PeachType3336 import kotlinx.coroutines.delay34-import kotlin.math.cos35-import kotlin.math.sin37+import kotlinx.coroutines.launch3638 39+// CSS cubic-bezier(0.16, 1, 0.3, 1) — the browser's bloom/wordmark-in easing40+// (a pronounced ease-out-expo feel).41+private val ExpoOut = CubicBezierEasing(0.16f, 1f, 0.3f, 1f)42+3743 /**38- * Full-screen Pro activation celebration. Auto-dismisses after ~1800ms (calls [onDone]);39- * a tap dismisses early. Accent bloom + PeachMark + a ring of staggered sparkle dots.44+ * Full-screen Pro activation celebration. Ports the browser's `.peach-pro-celebration`45+ * (ProCelebration.tsx + popup.css): two corner accent blooms behind a near-black46+ * shell, PeachMark with a glow pulse (`peach-pro-bloom`), then "peach pro" /47+ * "thank you." fading up in a stagger (`peach-pro-wordmark-in`). Holds ~1800ms,48+ * fades out over 400ms (`is-leaving`), then calls [onDone]. A tap skips straight49+ * to [onDone] without playing the exit fade.4050  */4151 @Composable4252 fun ProCelebration(onDone: () -> Unit) {4353     val accent = LocalPeachAccent.current4454     val reduceMotion = LocalReduceMotion.current4555 56+    val glow = remember { Animatable(0f) }57+    val wordmarkAlpha = remember { Animatable(0f) }58+    val wordmarkOffset = remember { Animatable(4f) }59+    val thankyouAlpha = remember { Animatable(0f) }60+    val thankyouOffset = remember { Animatable(4f) }61+    val exitAlpha = remember { Animatable(1f) }62+4663     LaunchedEffect(Unit) {64+        if (reduceMotion) {65+            wordmarkAlpha.snapTo(1f); wordmarkOffset.snapTo(0f)66+            thankyouAlpha.snapTo(1f); thankyouOffset.snapTo(0f)67+        } else {68+            launch {69+                // peach-pro-bloom: 0 -> peak at 40% -> 0, over 600ms.70+                glow.animateTo(1f, keyframes { durationMillis = 600; 0f at 0; 1f at 240; 0f at 600 })71+            }72+            launch {73+                delay(500)74+                launch { wordmarkAlpha.animateTo(1f, tween(400, easing = ExpoOut)) }75+                wordmarkOffset.animateTo(0f, tween(400, easing = ExpoOut))76+            }77+            launch {78+                delay(800)79+                launch { thankyouAlpha.animateTo(1f, tween(400, easing = ExpoOut)) }80+                thankyouOffset.animateTo(0f, tween(400, easing = ExpoOut))81+            }82+        }4783         delay(1800)84+        if (!reduceMotion) exitAlpha.animateTo(0f, tween(400))4885         onDone()4986     }5087 51-    val phase by if (reduceMotion) {52-        rememberInfiniteTransition(label = "spark").animateFloat(53-            0f, 0f, infiniteRepeatable(tween(1)), label = "sparkStatic",54-        )55-    } else {56-        rememberInfiniteTransition(label = "spark").animateFloat(57-            initialValue = 0f, targetValue = 1f,58-            animationSpec = infiniteRepeatable(tween(1800, easing = LinearEasing)),59-            label = "sparkPhase",60-        )61-    }62-6388     Box(6489         modifier = Modifier6590             .fillMaxSize()91+            .graphicsLayer { alpha = exitAlpha.value }6692             .background(peachColors.Bg)6793             .clickable(onClick = onDone),6894         contentAlignment = Alignment.Center,6995     ) {70-        // Accent bloom (higher opacity than the shell).96+        // Two corner blooms (radial-gradient(...at 0% 0%) / (...at 100% 100%)97+        // in popup.css), replacing the old single centred flat-alpha circle.7198         Canvas(Modifier.fillMaxSize()) {72-            drawCircle(73-                color = accent.copy(alpha = 0.18f),74-                radius = size.minDimension * 0.55f,75-                center = Offset(size.width / 2f, size.height * 0.42f),99+            drawRect(100+                brush = Brush.radialGradient(101+                    colors = listOf(accent.copy(alpha = 0.16f), accent.copy(alpha = 0f)),102+                    center = Offset.Zero,103+                    radius = size.maxDimension * 0.9f,104+                ),76105             )77-            // 12 sparkle dots radiating from centre, staggered fade.78-            val cx = size.width / 2f79-            val cy = size.height * 0.42f80-            val ringR = size.minDimension * 0.34f81-            for (i in 0 until 12) {82-                val ang = (i / 12f) * 2f * Math.PI.toFloat()83-                val local = ((phase + i / 12f) % 1f)84-                val a = (1f - kotlin.math.abs(local - 0.5f) * 2f).coerceIn(0f, 1f)85-                drawCircle(86-                    color = accent.copy(alpha = 0.7f * a),87-                    radius = 4f,88-                    center = Offset(cx + cos(ang) * ringR, cy + sin(ang) * ringR),89-                )90-            }106+            drawRect(107+                brush = Brush.radialGradient(108+                    colors = listOf(accent.copy(alpha = 0.12f), accent.copy(alpha = 0f)),109+                    center = Offset(size.width, size.height),110+                    radius = size.maxDimension * 0.9f,111+                ),112+            )91113         }92114         Column(horizontalAlignment = Alignment.CenterHorizontally) {93-            PeachMark(size = 80.dp)115+            Box(Modifier.size(180.dp), contentAlignment = Alignment.Center) {116+                // Soft glow behind the mark, standing in for the CSS drop-shadow117+                // pulse — a portable Compose equivalent that works below API 31118+                // (Modifier.blur's minimum), unlike a true blur filter.119+                Canvas(Modifier.fillMaxSize()) {120+                    if (glow.value > 0f) {121+                        drawCircle(122+                            brush = Brush.radialGradient(123+                                colors = listOf(accent.copy(alpha = 0.45f * glow.value), accent.copy(alpha = 0f)),124+                                radius = 90.dp.toPx(),125+                            ),126+                            radius = 90.dp.toPx(),127+                        )128+                    }129+                }130+                PeachMark(size = 80.dp)131+            }94132             Text(95133                 "peach pro",96134                 color = accent,97135                 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp),98136                 textAlign = TextAlign.Center,99-                modifier = Modifier.padding(top = 18.dp),137+                modifier = Modifier138+                    .padding(top = 18.dp)139+                    .offset(y = wordmarkOffset.value.dp)140+                    .graphicsLayer { alpha = wordmarkAlpha.value },100141             )101142             Text(102143                 "thank you.",103144                 color = peachColors.T1,104145                 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp),105146                 textAlign = TextAlign.Center,106-                modifier = Modifier.padding(top = 6.dp),147+                modifier = Modifier148+                    .padding(top = 6.dp)149+                    .offset(y = thankyouOffset.value.dp)150+                    .graphicsLayer { alpha = thankyouAlpha.value },107151             )108152         }109153     }

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsScreen.kt+103 −27
@@ -2,6 +2,7 @@22 33 import androidx.activity.compose.BackHandler44 import androidx.compose.animation.core.Animatable5+import androidx.compose.animation.core.LinearOutSlowInEasing56 import androidx.compose.animation.core.Spring67 import androidx.compose.animation.core.animateDpAsState78 import androidx.compose.animation.core.animateFloat@@ -24,6 +25,7 @@2425 import androidx.compose.foundation.layout.fillMaxSize2526 import androidx.compose.foundation.layout.fillMaxWidth2627 import androidx.compose.foundation.layout.height28+import androidx.compose.foundation.layout.offset2729 import androidx.compose.foundation.layout.padding2830 import androidx.compose.foundation.layout.size2931 import androidx.compose.foundation.layout.statusBarsPadding@@ -177,7 +179,9 @@177179             active = CardId.Pro178180             fanning = false179181             celebrating = CardId.Pro180-            delay(900L)182+            // Matches browser's PRO_CELEBRATION_THANK_YOU_MS (Settings.tsx) — the183+            // window the spark burst + "thank you" text stay mounted for.184+            delay(3200L)181185             celebrating = null182186         }183187     }@@ -257,24 +261,6 @@257261                         )258262                         val zIndex = if (isActive) 99f else (50 - distance).toFloat()259263 260-                        // Celebration shake — keyframed bob + tilt as the Pro card lands.261-                        val shakeDy = remember { Animatable(0f) }262-                        val shakeRot = remember { Animatable(0f) }263-                        LaunchedEffect(celebrating == card.id) {264-                            if (celebrating == card.id && !reduceMotion) {265-                                launch {266-                                    shakeRot.animateTo(0f, keyframes {267-                                        durationMillis = 880268-                                        0f at 0; -1.3f at 211; 0.9f at 422; -0.45f at 616; 0f at 757269-                                    })270-                                }271-                                shakeDy.animateTo(0f, keyframes {272-                                    durationMillis = 880273-                                    0f at 0; -10f at 211; 5f at 422; -4f at 616; 2f at 757274-                                })275-                            }276-                        }277-278264                         val density = LocalDensity.current279265                         StackCard(280266                             spec = card,@@ -282,6 +268,7 @@282268                             focused = isActive && !deckOpen,283269                             lede = vm.lede(card.id, entries.count { it.deletedAt == null }, settings),284270                             showSparkle = card.id == CardId.Pro && isPro,271+                            celebrating = celebrating == card.id,285272                             onIconClick = if (card.id == CardId.Pro && isPro) ({ celebrate() }) else null,286273                             body = {287274                                 CardBody(@@ -329,8 +316,7 @@329316                                     },330317                                 )331318                                 .graphicsLayer {332-                                    translationY = top.toPx() + with(density) { shakeDy.value.dp.toPx() }333-                                    rotationZ = shakeRot.value319+                                    translationY = top.toPx()334320                                     this.alpha = alpha335321                                 }336322                                 .height(cardH),@@ -364,6 +350,7 @@364350     focused: Boolean,365351     lede: String,366352     showSparkle: Boolean,353+    celebrating: Boolean,367354     onIconClick: (() -> Unit)?,368355     body: @Composable () -> Unit,369356     modifier: Modifier,@@ -403,12 +390,18 @@403390                         fontSize = 8.5.sp,404391                         letterSpacing = 0.26.em,405392                     )406-                    Box(407-                        Modifier.clip(RoundedCornerShape(999.dp))408-                            .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)409-                            .padding(2.dp),410-                    ) {411-                        CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))393+                    Box(contentAlignment = Alignment.Center) {394+                        Box(395+                            Modifier.clip(RoundedCornerShape(999.dp))396+                                .then(if (onIconClick != null) Modifier.clickable { onIconClick() } else Modifier)397+                                .padding(2.dp),398+                        ) {399+                            CardIcon(spec.icon, spec.fg.copy(alpha = 0.85f))400+                        }401+                        // Not clipped like the icon above it — sparks fly past the402+                        // icon's own circular hit target (peach-pro-spark-fly, up403+                        // to 54dp out from centre).404+                        if (celebrating) ProSparkBurst(color = spec.fg)412405                     }413406                 }414407                 Text(@@ -427,6 +420,7 @@427420                     fontStyle = FontStyle.Italic,428421                     fontSize = 12.5.sp,429422                 )423+                if (celebrating) ProThankYouFlash(color = spec.fg)430424                 // Body scrolls independently; only revealed at full height in431425                 // focus mode, but kept mounted so deck-mode shows a preview.432426                 // Two-way scroll gesture, mirrored: dragging up on a collapsed-@@ -568,6 +562,88 @@568562         colors = listOf(start, end),569563         start = Offset(cx - dx * half, cy - dy * half),570564         end = Offset(cx + dx * half, cy + dy * half),565+    )566+}567+568+private data class ProSpark(val x: Float, val y: Float, val delayMs: Int, val glyph: String)569+570+// Exact positions/delays/glyphs from the browser's .peach-pro-spark spans571+// (Settings.tsx) — an 8-point radial burst, not a uniform ring.572+private val PRO_SPARKS = listOf(573+    ProSpark(0f, -54f, 0, "✦"),574+    ProSpark(38f, -38f, 50, "✦"),575+    ProSpark(54f, 0f, 110, "·"),576+    ProSpark(38f, 38f, 30, "✦"),577+    ProSpark(0f, 54f, 80, "·"),578+    ProSpark(-38f, 38f, 130, "✦"),579+    ProSpark(-54f, 0f, 55, "·"),580+    ProSpark(-38f, -38f, 95, "✦"),581+)582+583+/**584+ * The Pro icon's celebration burst — 8 glyphs flying outward from centre and585+ * fading, each on its own delay. Ports `peach-pro-spark-fly` (1.4s ease-out):586+ * opacity 0→1 over the first quarter, then 1→0 through to the end, while587+ * position/scale interpolate linearly (eased) across the full duration.588+ */589+@Composable590+private fun ProSparkBurst(color: Color, modifier: Modifier = Modifier) {591+    if (LocalReduceMotion.current) return592+    Box(modifier) {593+        PRO_SPARKS.forEach { spark ->594+            val progress = remember(spark) { Animatable(0f) }595+            LaunchedEffect(spark) {596+                delay(spark.delayMs.toLong())597+                progress.animateTo(1f, tween(1400, easing = LinearOutSlowInEasing))598+            }599+            val t = progress.value600+            val alpha = if (t < 0.25f) t / 0.25f else 1f - (t - 0.25f) / 0.75f601+            val scale = 0.5f + 0.3f * t602+            Text(603+                spark.glyph,604+                color = color.copy(alpha = alpha.coerceIn(0f, 1f)),605+                fontSize = 9.sp,606+                modifier = Modifier607+                    .align(Alignment.Center)608+                    .offset(x = (spark.x * t).dp, y = (spark.y * t).dp)609+                    .graphicsLayer { scaleX = scale; scaleY = scale },610+            )611+        }612+    }613+}614+615+/**616+ * "thank you ✦" beneath the Pro card's lede while celebrating — ports617+ * `peach-pro-thankyou-flash` (2.5s ease: fade in by 15%, hold, fade out618+ * from 75%).619+ */620+@Composable621+private fun ProThankYouFlash(color: Color, modifier: Modifier = Modifier) {622+    val reduceMotion = LocalReduceMotion.current623+    val alpha = remember { Animatable(0f) }624+    LaunchedEffect(Unit) {625+        if (reduceMotion) {626+            alpha.snapTo(0.75f)627+        } else {628+            alpha.animateTo(629+                0f,630+                keyframes {631+                    durationMillis = 2500632+                    0f at 0633+                    0.75f at 375634+                    0.75f at 1875635+                    0f at 2500636+                },637+            )638+        }639+    }640+    Text(641+        "thank you ✦",642+        modifier = modifier.padding(top = 4.dp),643+        color = color.copy(alpha = alpha.value),644+        fontFamily = PeachType.Display,645+        fontStyle = FontStyle.Italic,646+        fontSize = 11.sp,571647     )572648 }573649 

view file