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
↓ peach-android-6708bbf7.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/ui/settings/ProCelebration.kt+88 −44app/src/main/java/com/peachpasswords/ui/settings/SettingsScreen.kt+103 −27app/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 }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