639970cb1815
replace hand-drawn pairing birds with the extension's finch and swallow, render SAS inline instead of over them
- time
- 2026-07-24 22:34 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- pairing birds
- parent
- 8dffe25307ef
↓ peach-android-639970cb.tar.gzatlas at this save
files changed3 files
app/src/main/java/com/peachpasswords/ui/components/BirdAnimation.kt+54 −90app/src/main/java/com/peachpasswords/ui/components/PairingBirds.kt+319 −0app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+41 −37app/src/main/java/com/peachpasswords/ui/components/BirdAnimation.kt+54 −90
@@ -1,7 +1,6 @@11 package com.peachpasswords.ui.components22 33 import androidx.compose.animation.core.FastOutSlowInEasing4-import androidx.compose.animation.core.LinearEasing54 import androidx.compose.animation.core.RepeatMode65 import androidx.compose.animation.core.animateFloat76 import androidx.compose.animation.core.animateFloatAsState@@ -10,32 +9,33 @@109 import androidx.compose.animation.core.spring1110 import androidx.compose.animation.core.tween1211 import androidx.compose.foundation.Canvas12+import androidx.compose.foundation.layout.Box13+import androidx.compose.foundation.layout.BoxWithConstraints14+import androidx.compose.foundation.layout.fillMaxSize15+import androidx.compose.foundation.layout.offset16+import androidx.compose.foundation.layout.size1317 import androidx.compose.runtime.Composable1418 import androidx.compose.runtime.getValue19+import androidx.compose.ui.Alignment1520 import androidx.compose.ui.Modifier1621 import androidx.compose.ui.geometry.Offset17-import androidx.compose.ui.geometry.Size1822 import androidx.compose.ui.graphics.Color19-import androidx.compose.ui.graphics.Path2023 import androidx.compose.ui.graphics.drawscope.DrawScope2124 import androidx.compose.ui.graphics.drawscope.Stroke25+import androidx.compose.ui.unit.dp2226 import com.peachpasswords.ui.theme.LocalPeachAccent2327 import kotlin.math.sin2428 2529 /** The three acts of the pairing animation, bound to the audio protocol. */2630 enum class PairingAnimState { WAITING, LISTENING, CONNECTED }2731 28-private val BodyHi = Color(0xFFFFB082)29-private val BodyLo = Color(0xFFE5805C)30-private val Beak = Color(0xFFF2B45A)31-private val Eye = Color(0xFF1F0A10)32-3332 /**34- * Two finches that drift toward each other as pairing progresses. Act 135- * (waiting) holds them apart with a slow breath-bob; Act 2 (listening) pulses36- * inaudible sound arcs between them; Act 3 (connected) resolves the arcs into a37- * glowing accent link and a synchronised flutter. Stylised from the browser38- * ChirpPairingFlow birds — same coral/apricot palette, simpler geometry.33+ * The two pairing birds drifting toward each other as pairing progresses. Act 134+ * (waiting) holds them apart; act 2 (listening) pulses sound arcs between them35+ * while the finch calls and the swallow listens; act 3 (connected) resolves the36+ * arcs into a glowing accent link and both birds go happy.37+ *38+ * The birds themselves are the extension's own artwork — see [PairingBird].3939 */4040 @Composable4141 fun BirdAnimation(@@ -48,7 +48,7 @@4848 targetValue = when (state) {4949 PairingAnimState.WAITING -> 0.16f5050 PairingAnimState.LISTENING -> 0.22f51- PairingAnimState.CONNECTED -> 0.39f51+ PairingAnimState.CONNECTED -> 0.34f5252 },5353 animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),5454 label = "leftX",@@ -57,18 +57,13 @@5757 targetValue = when (state) {5858 PairingAnimState.WAITING -> 0.84f5959 PairingAnimState.LISTENING -> 0.78f60- PairingAnimState.CONNECTED -> 0.61f60+ PairingAnimState.CONNECTED -> 0.66f6161 },6262 animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),6363 label = "rightX",6464 )6565 6666 val transition = rememberInfiniteTransition(label = "bird")67- val bob by transition.animateFloat(68- initialValue = 0f, targetValue = (2f * Math.PI).toFloat(),69- animationSpec = infiniteRepeatable(tween(2200, easing = LinearEasing)),70- label = "bob",71- )7267 // Sound-arc sweep (LISTENING) and connection-glow pulse (CONNECTED).7368 val pulse by transition.animateFloat(7469 initialValue = 0f, targetValue = 1f,@@ -79,22 +74,46 @@7974 label = "pulse",8075 )8176 82- Canvas(modifier = modifier) {83- val h = size.height84- val cy = h * 0.52f85- val leftCenter = Offset(size.width * leftX, cy + sin(bob) * 4f)86- val rightCenter = Offset(size.width * rightX, cy + sin(bob + Math.PI.toFloat()) * 4f)87- val birdScale = (size.minDimension / 220f).coerceIn(0.5f, 1.4f)77+ // The extension holds the finch chirping and the swallow attentive on every78+ // pairing screen ("the bird stage — always animating"); only the distance79+ // between them changes. Happy on pairing is Android's own addition.80+ val finchPose = if (state == PairingAnimState.CONNECTED) BirdPose.HAPPY else BirdPose.CHIRPING81+ val swallowPose = if (state == PairingAnimState.CONNECTED) BirdPose.HAPPY else BirdPose.ATTENTIVE8882 89- if (state == PairingAnimState.LISTENING) {90- drawSoundArcs(leftCenter, rightCenter, pulse, accent)83+ BoxWithConstraints(modifier) {84+ val birdSize = (maxHeight * 0.92f).coerceAtMost(maxWidth * 0.38f)85+86+ Canvas(Modifier.fillMaxSize()) {87+ val cy = size.height * 0.48f88+ val left = Offset(size.width * leftX, cy)89+ val right = Offset(size.width * rightX, cy)90+ when (state) {91+ PairingAnimState.LISTENING -> drawSoundArcs(left, right, pulse, accent)92+ PairingAnimState.CONNECTED -> drawConnection(left, right, pulse, accent)93+ PairingAnimState.WAITING -> Unit94+ }9195 }92- if (state == PairingAnimState.CONNECTED) {93- drawConnection(leftCenter, rightCenter, pulse, accent)94- }9596 96- drawBird(leftCenter, birdScale, facingRight = true, listening = state != PairingAnimState.WAITING)97- drawBird(rightCenter, birdScale, facingRight = false, listening = state != PairingAnimState.WAITING)97+ // leftX/rightX are centre fractions, but a bird parked at 0.16 of a98+ // narrow stage hangs its tail off the edge — Canvas clips, so keep the99+ // whole box on screen rather than losing tail feathers.100+ val span = maxWidth - birdSize101+ Box(102+ Modifier103+ .align(Alignment.CenterStart)104+ .offset(x = (maxWidth * leftX - birdSize / 2f).coerceIn(0.dp, span))105+ .size(birdSize),106+ ) {107+ PairingBird(BirdKind.FINCH, finchPose, Modifier.fillMaxSize())108+ }109+ Box(110+ Modifier111+ .align(Alignment.CenterStart)112+ .offset(x = (maxWidth * rightX - birdSize / 2f).coerceIn(0.dp, span))113+ .size(birdSize),114+ ) {115+ PairingBird(BirdKind.SWALLOW, swallowPose, Modifier.fillMaxSize())116+ }98117 }99118 }100119 @@ -115,64 +134,9 @@115134 }116135 117136 private fun DrawScope.drawConnection(left: Offset, right: Offset, pulse: Float, accent: Color) {118- val beakL = Offset(left.x + 26f, left.y - 4f)119- val beakR = Offset(right.x - 26f, right.y - 4f)120- val mid = Offset((beakL.x + beakR.x) / 2f, (beakL.y + beakR.y) / 2f)121- drawLine(accent.copy(alpha = 0.5f), beakL, beakR, strokeWidth = 1.4f)137+ val mid = Offset((left.x + right.x) / 2f, (left.y + right.y) / 2f)138+ drawLine(accent.copy(alpha = 0.5f), left, right, strokeWidth = 1.4f)122139 val glow = 3f + sin(pulse * 2f * Math.PI.toFloat()) * 2f + 4f123140 drawCircle(accent.copy(alpha = 0.22f), radius = glow * 2.4f, center = mid)124141 drawCircle(accent, radius = glow, center = mid)125-}126-127-/** A compact finch: body blob, wing, head, beak, eye. */128-private fun DrawScope.drawBird(129- center: Offset,130- scale: Float,131- facingRight: Boolean,132- listening: Boolean,133-) {134- val dir = if (facingRight) 1f else -1f135- fun p(dx: Float, dy: Float) = Offset(center.x + dx * scale * dir, center.y + dy * scale)136-137- // Shadow138- drawOval(139- color = Color(0x33000000),140- topLeft = Offset(center.x - 30f * scale, center.y + 42f * scale),141- size = Size(60f * scale, 7f * scale),142- )143-144- // Body145- val body = Path().apply {146- moveTo(p(-30f, 6f).x, p(-30f, 6f).y)147- cubicTo(p(-36f, -22f).x, p(-36f, -22f).y, p(-8f, -44f).x, p(-8f, -44f).y, p(24f, -40f).x, p(24f, -40f).y)148- cubicTo(p(52f, -36f).x, p(52f, -36f).y, p(60f, -10f).x, p(60f, -10f).y, p(50f, 18f).x, p(50f, 18f).y)149- cubicTo(p(40f, 40f).x, p(40f, 40f).y, p(-2f, 44f).x, p(-2f, 44f).y, p(-26f, 32f).x, p(-26f, 32f).y)150- cubicTo(p(-34f, 24f).x, p(-34f, 24f).y, p(-32f, 14f).x, p(-32f, 14f).y, p(-30f, 6f).x, p(-30f, 6f).y)151- close()152- }153- drawPath(body, BodyHi)154-155- // Wing (lifts when listening)156- val wingLift = if (listening) -6f else 0f157- val wing = Path().apply {158- moveTo(p(-6f, -14f + wingLift).x, p(-6f, -14f + wingLift).y)159- cubicTo(p(14f, -22f + wingLift).x, p(14f, -22f + wingLift).y, p(40f, -10f).x, p(40f, -10f).y, p(34f, 6f).x, p(34f, 6f).y)160- cubicTo(p(20f, 14f).x, p(20f, 14f).y, p(-2f, 8f).x, p(-2f, 8f).y, p(-10f, -2f).x, p(-10f, -2f).y)161- close()162- }163- drawPath(wing, BodyLo)164-165- // Head166- drawCircle(BodyHi, radius = 22f * scale, center = p(34f, -30f))167- // Beak168- val beak = Path().apply {169- moveTo(p(52f, -30f).x, p(52f, -30f).y)170- lineTo(p(72f, -27f).x, p(72f, -27f).y)171- lineTo(p(53f, -22f).x, p(53f, -22f).y)172- close()173- }174- drawPath(beak, Beak)175- // Eye176- drawCircle(Eye, radius = 4.2f * scale, center = p(42f, -33f))177- drawCircle(Color(0xFFFFF0E1), radius = 1.4f * scale, center = p(43.5f, -34.5f))178142 }app/src/main/java/com/peachpasswords/ui/components/PairingBirds.kt+319 −0
@@ -0,0 +1,319 @@1+package com.peachpasswords.ui.components2+3+import androidx.compose.foundation.Canvas4+import androidx.compose.runtime.Composable5+import androidx.compose.runtime.LaunchedEffect6+import androidx.compose.runtime.State7+import androidx.compose.runtime.getValue8+import androidx.compose.runtime.mutableLongStateOf9+import androidx.compose.runtime.remember10+import androidx.compose.runtime.withFrameMillis11+import androidx.compose.ui.Modifier12+import androidx.compose.ui.geometry.Offset13+import androidx.compose.ui.geometry.Rect14+import androidx.compose.ui.geometry.Size15+import androidx.compose.ui.graphics.Brush16+import androidx.compose.ui.graphics.Color17+import androidx.compose.ui.graphics.Path18+import androidx.compose.ui.graphics.drawscope.DrawScope19+import androidx.compose.ui.graphics.drawscope.Stroke20+import androidx.compose.ui.graphics.drawscope.rotate21+import androidx.compose.ui.graphics.drawscope.scale22+import androidx.compose.ui.graphics.drawscope.translate23+import androidx.compose.ui.graphics.vector.PathParser24+import kotlin.math.sin25+26+/**27+ * The two pairing birds, ported path-for-path from the browser extension's28+ * `ChirpPairingFlow.tsx` (a finch and a swallow) rather than redrawn by eye.29+ *30+ * Android previously carried its own simplified blob-and-triangle bird, which31+ * read as a different, worse character next to the extension's. The SVG `d`32+ * strings below are copied verbatim from that file — keep them that way, and33+ * re-copy rather than nudge if the browser art changes. Everything is drawn in34+ * the source 160x150 viewBox and scaled to fit, so the coordinates stay35+ * directly comparable to the SVG.36+ */37+enum class BirdPose { IDLE, CHIRPING, ATTENTIVE, HAPPY, SAD }38+39+enum class BirdKind { FINCH, SWALLOW }40+41+private const val VB_W = 160f42+private const val VB_H = 150f43+44+private class BirdPalette(45+ val hi: Color,46+ val mid: Color,47+ val lo: Color,48+ val shade: Color,49+ val cream: Color,50+ val beak: Color,51+ val beakShade: Color,52+ val legs: Color,53+)54+55+private val Finch = BirdPalette(56+ hi = Color(0xFFF4A6B6), mid = Color(0xFFDC8294), lo = Color(0xFFA85870),57+ shade = Color(0xFF7A3E54), cream = Color(0xFFF5D8C8),58+ beak = Color(0xFFF5C764), beakShade = Color(0xFFC8932E), legs = Color(0xFF7A3E54),59+)60+61+private val Swallow = BirdPalette(62+ hi = Color(0xFFF4C8A0), mid = Color(0xFFDC9968), lo = Color(0xFFA86B3E),63+ shade = Color(0xFF7A4A26), cream = Color(0xFFF7E0C8),64+ beak = Color(0xFFF5C764), beakShade = Color(0xFFB87A22), legs = Color(0xFF5E3A1F),65+)66+67+private val EyeDark = Color(0xFF1F0A10)68+private val EyeGlint = Color(0xF2FFF0E1)69+private val GroundShadow = Color(0x73000000)70+71+/** Parsed once per path string — parsing is not cheap enough to do per frame. */72+private val pathCache = HashMap<String, Path>()73+74+private fun svg(d: String): Path = pathCache.getOrPut(d) { PathParser().parsePathString(d).toPath() }75+76+/** SVG gradients are in objectBoundingBox units; resolve against the shape's bounds. */77+private fun radial(b: Rect, cx: Float, cy: Float, r: Float, stops: Array<Pair<Float, Color>>) =78+ Brush.radialGradient(79+ colorStops = stops,80+ center = Offset(b.left + b.width * cx, b.top + b.height * cy),81+ radius = maxOf(b.width, b.height) * r,82+ )83+84+@Composable85+fun PairingBird(86+ kind: BirdKind,87+ pose: BirdPose,88+ modifier: Modifier = Modifier,89+) {90+ val elapsed by rememberFrameMillis()91+ Canvas(modifier) {92+ val s = minOf(size.width / VB_W, size.height / VB_H)93+ translate((size.width - VB_W * s) / 2f, (size.height - VB_H * s) / 2f) {94+ scale(s, s, pivot = Offset.Zero) {95+ // The swallow is drawn mirrored in the extension so the pair96+ // faces each other; mirroring here keeps its path data verbatim.97+ if (kind == BirdKind.SWALLOW) {98+ scale(-1f, 1f, pivot = Offset(VB_W / 2f, VB_H / 2f)) { drawBird(kind, pose, elapsed) }99+ } else {100+ drawBird(kind, pose, elapsed)101+ }102+ }103+ }104+ }105+}106+107+/**108+ * System animator scale can be zeroed (it is on our test emulator), which109+ * silently freezes `animateFloat`. The frame clock is not scaled.110+ */111+@Composable112+private fun rememberFrameMillis(): State<Long> {113+ val millis = remember { mutableLongStateOf(0L) }114+ LaunchedEffect(Unit) {115+ val start = withFrameMillis { it }116+ while (true) withFrameMillis { millis.longValue = it - start }117+ }118+ return millis119+}120+121+/** Position within a repeating cycle, 0..1. */122+private fun cycle(elapsed: Long, periodMs: Int): Float = (elapsed % periodMs) / periodMs.toFloat()123+124+/** Eased 0→1→0 over one cycle, matching the CSS keyframes' ease-in-out feel. */125+private fun swell(t: Float): Float = sin(t * Math.PI.toFloat())126+127+/**128+ * The chirp gesture fires late in its cycle (CSS: still at 70%, peak at 78%,129+ * settling by 86%). Returns 0 outside that window, 0..1..0 inside it.130+ */131+private fun chirpBeat(t: Float): Float = when {132+ t < 0.70f || t > 0.94f -> 0f133+ else -> swell((t - 0.70f) / 0.24f)134+}135+136+private fun DrawScope.drawBird(kind: BirdKind, pose: BirdPose, elapsed: Long) {137+ val p = if (kind == BirdKind.FINCH) Finch else Swallow138+ val chirping = pose == BirdPose.CHIRPING139+ val beat = if (chirping) chirpBeat(cycle(elapsed, 1700)) else 0f140+ val bob = -1.5f * swell(cycle(elapsed, 2400))141+142+ // Blink: a 5.2s cycle that is open except for one short squash.143+ val blinkT = cycle(elapsed, 5200)144+ val eyeOpen = if (blinkT > 0.92f && blinkT < 0.96f) 0.05f else 1f145+146+ if (kind == BirdKind.FINCH) {147+ drawOval(GroundShadow, topLeft = Offset(38f, 134.5f), size = Size(80f, 7f))148+ } else {149+ drawOval(GroundShadow, topLeft = Offset(52f, 137f), size = Size(68f, 6f))150+ }151+152+ if (beat > 0f) {153+ val aura = if (kind == BirdKind.FINCH) Rect(16f, 28f, 136f, 120f) else Rect(20f, 36f, 140f, 120f)154+ drawOval(155+ p.hi.copy(alpha = 0.18f * 0.8f * beat),156+ topLeft = Offset(aura.left, aura.top),157+ size = Size(aura.width, aura.height),158+ )159+ }160+161+ drawTail(kind, p, pose, beat, elapsed)162+ drawFeet(kind, p)163+164+ // Body bob is a translate + barely-there squash; the sad pose replaces it165+ // with a static droop.166+ val droop = pose == BirdPose.SAD167+ translate(0f, if (droop) 5f else bob) {168+ rotate(if (droop) -2f else 0f, pivot = Offset(VB_W / 2f, VB_H * 0.8f)) {169+ drawBody(kind, p)170+ drawWing(kind, p, pose, elapsed)171+ }172+ }173+174+ val headPivot = if (kind == BirdKind.FINCH) Offset(95f, 52f) else Offset(116f, 64f)175+ val headTilt = when (pose) {176+ BirdPose.ATTENTIVE -> -6f177+ BirdPose.CHIRPING -> -4f * beat178+ else -> 0f179+ }180+ translate(0f, bob - 3f * beat) {181+ rotate(headTilt, pivot = headPivot) {182+ drawHead(kind, p, pose, beat, eyeOpen)183+ }184+ }185+}186+187+private fun DrawScope.drawTail(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, elapsed: Long) {188+ val twitch = when (pose) {189+ BirdPose.CHIRPING -> -3f * beat190+ BirdPose.ATTENTIVE -> -4f191+ BirdPose.HAPPY -> 6f * sin(cycle(elapsed, 500) * 2f * Math.PI.toFloat())192+ else -> 0f193+ }194+ // CSS transform-origin 100% 50% — the tail hinges at the body end.195+ val pivot = if (kind == BirdKind.FINCH) Offset(32f, 95f) else Offset(38f, 80f)196+ rotate(twitch, pivot = pivot) {197+ if (kind == BirdKind.FINCH) {198+ drawPath(svg("M 32 92 Q 8 90, 6 80 Q 10 95, 28 100 Z"), p.lo)199+ drawPath(svg("M 32 95 Q 4 99, 4 92 Q 10 102, 28 102 Z"), p.shade, alpha = 0.85f)200+ drawPath(svg("M 32 98 Q 8 108, 12 110 Q 20 110, 30 105 Z"), p.lo, alpha = 0.9f)201+ } else {202+ drawPath(svg("M 38 80 Q 8 70, 0 60 Q 8 78, 28 88 Q 14 90, 6 96 Q 16 94, 32 92 L 38 84 Z"), p.lo)203+ drawPath(svg("M 30 84 Q 14 78, 6 70 Q 18 84, 30 88 Z"), p.shade, alpha = 0.7f)204+ }205+ }206+}207+208+private fun DrawScope.drawFeet(kind: BirdKind, p: BirdPalette) {209+ val leg = Stroke(width = if (kind == BirdKind.FINCH) 1.6f else 1.5f, cap = androidx.compose.ui.graphics.StrokeCap.Round)210+ val toe = Stroke(width = if (kind == BirdKind.FINCH) 1.2f else 1.1f, cap = androidx.compose.ui.graphics.StrokeCap.Round)211+ if (kind == BirdKind.FINCH) {212+ drawLine(p.legs, Offset(62f, 118f), Offset(60f, 134f), strokeWidth = leg.width, cap = leg.cap)213+ drawPath(svg("M 60 134 L 55 138 M 60 134 L 60 139 M 60 134 L 65 138"), p.legs, style = toe)214+ drawLine(p.legs, Offset(84f, 118f), Offset(86f, 134f), strokeWidth = leg.width, cap = leg.cap)215+ drawPath(svg("M 86 134 L 81 138 M 86 134 L 86 139 M 86 134 L 91 138"), p.legs, style = toe)216+ } else {217+ drawLine(p.legs, Offset(76f, 118f), Offset(74f, 136f), strokeWidth = leg.width, cap = leg.cap)218+ drawPath(svg("M 74 136 L 69 140 M 74 136 L 74 141 M 74 136 L 79 140"), p.legs, style = toe)219+ drawLine(p.legs, Offset(96f, 118f), Offset(98f, 136f), strokeWidth = leg.width, cap = leg.cap)220+ drawPath(svg("M 98 136 L 93 140 M 98 136 L 98 141 M 98 136 L 103 140"), p.legs, style = toe)221+ }222+}223+224+private fun DrawScope.drawBody(kind: BirdKind, p: BirdPalette) {225+ if (kind == BirdKind.FINCH) {226+ val body = svg("M 30 88 C 24 64, 40 38, 72 36 C 105 34, 124 56, 122 84 C 120 110, 100 122, 70 120 C 44 118, 32 108, 30 88 Z")227+ drawPath(body, radial(body.getBounds(), 0.42f, 0.32f, 0.72f, arrayOf(0f to p.hi, 0.55f to p.mid, 1f to p.lo)))228+ val belly = svg("M 40 82 C 42 100, 60 112, 80 110 C 100 108, 110 100, 112 86 C 100 102, 80 106, 60 102 C 50 100, 44 92, 40 82 Z")229+ val bb = belly.getBounds()230+ drawPath(231+ belly,232+ Brush.verticalGradient(233+ colorStops = arrayOf(234+ 0f to p.cream.copy(alpha = 0f),235+ 0.5f to p.cream.copy(alpha = 0.85f),236+ 1f to p.cream.copy(alpha = 0.5f),237+ ),238+ startY = bb.top, endY = bb.bottom,239+ ),240+ )241+ } else {242+ val body = svg("M 36 82 C 32 56, 56 42, 88 44 C 116 46, 134 60, 134 80 C 134 102, 116 118, 90 118 C 60 118, 40 104, 36 82 Z")243+ drawPath(body, radial(body.getBounds(), 0.55f, 0.35f, 0.75f, arrayOf(0f to p.hi, 0.5f to p.mid, 1f to p.lo)))244+ drawPath(245+ svg("M 50 64 C 60 52, 80 46, 100 48 C 116 50, 126 60, 128 70 C 120 60, 100 56, 80 60 C 64 64, 56 68, 50 64 Z"),246+ p.shade, alpha = 0.5f,247+ )248+ val throat = svg("M 64 82 C 66 100, 86 114, 102 112 C 116 110, 124 100, 126 88 C 116 102, 100 108, 84 104 C 74 102, 68 94, 64 82 Z")249+ val tb = throat.getBounds()250+ drawPath(251+ throat,252+ Brush.verticalGradient(253+ colorStops = arrayOf(0f to p.cream.copy(alpha = 0.95f), 1f to p.cream.copy(alpha = 0.2f)),254+ startY = tb.top, endY = tb.bottom,255+ ),256+ )257+ }258+}259+260+private fun DrawScope.drawWing(kind: BirdKind, p: BirdPalette, pose: BirdPose, elapsed: Long) {261+ val hair = Stroke(width = if (kind == BirdKind.FINCH) 0.7f else 0.6f, cap = androidx.compose.ui.graphics.StrokeCap.Round)262+ val flutter = if (pose == BirdPose.HAPPY) -16f * swell(cycle(elapsed, 320)) else 0f263+ val pivot = if (kind == BirdKind.FINCH) Offset(92f, 78f) else Offset(103f, 89f)264+ rotate(flutter, pivot = pivot) {265+ if (kind == BirdKind.FINCH) {266+ drawPath(svg("M 56 60 C 68 56, 88 60, 102 68 C 108 76, 106 86, 96 90 C 80 92, 64 86, 54 78 C 50 70, 50 64, 56 60 Z"), p.lo)267+ for (d in listOf("M 64 64 Q 72 70, 78 80", "M 74 62 Q 82 70, 88 82", "M 84 64 Q 90 72, 94 84")) {268+ drawPath(svg(d), p.shade, alpha = 0.55f, style = hair)269+ }270+ drawPath(svg("M 56 60 C 68 56, 88 60, 102 68"), p.shade, alpha = 0.4f, style = Stroke(width = 1f))271+ } else {272+ drawPath(svg("M 50 70 C 64 64, 84 66, 100 72 C 112 78, 112 90, 100 96 C 84 100, 64 96, 52 88 C 46 80, 46 74, 50 70 Z"), p.lo)273+ for (d in listOf("M 96 88 L 110 92 L 100 96 Z", "M 88 92 L 102 98 L 92 100 Z", "M 80 94 L 92 100 L 84 102 Z")) {274+ drawPath(svg(d), p.shade, alpha = 0.85f)275+ }276+ for (d in listOf("M 58 76 Q 70 82, 80 90", "M 68 72 Q 80 80, 90 88", "M 80 70 Q 90 78, 100 86")) {277+ drawPath(svg(d), p.shade, alpha = 0.5f, style = hair)278+ }279+ drawPath(svg("M 50 70 C 64 64, 84 66, 100 72"), p.shade, alpha = 0.55f, style = Stroke(width = 1f))280+ }281+ }282+}283+284+private fun DrawScope.drawHead(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, eyeOpen: Float) {285+ if (kind == BirdKind.FINCH) {286+ val perk = if (pose == BirdPose.HAPPY) -15f else 0f287+ rotate(perk, pivot = Offset(88f, 30f)) {288+ drawPath(svg("M 88 22 Q 86 12, 94 12 Q 94 20, 92 26 Z"), p.lo)289+ drawPath(svg("M 96 20 Q 96 10, 104 14 Q 102 22, 100 26 Z"), p.shade, alpha = 0.95f)290+ drawPath(svg("M 104 24 Q 108 16, 114 20 Q 110 28, 106 30 Z"), p.lo, alpha = 0.85f)291+ }292+ val head = Rect(72f, 22f, 124f, 74f)293+ drawCircle(radial(head, 0.5f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 26f, center = Offset(98f, 48f))294+ drawPath(svg("M 76 46 Q 80 30, 96 26 Q 86 32, 80 48 Z"), p.shade, alpha = 0.35f)295+ scale(1f, eyeOpen, pivot = Offset(108f, 42f)) {296+ drawCircle(EyeDark, radius = 4.5f, center = Offset(108f, 42f))297+ drawCircle(EyeGlint, radius = 1.4f, center = Offset(109.4f, 40.5f))298+ drawCircle(EyeGlint.copy(alpha = 0.6f), radius = 0.5f, center = Offset(106.5f, 44f))299+ }300+ rotate(-14f * beat, pivot = Offset(120f, 52f)) {301+ drawPath(svg("M 120 48 L 138 50 L 122 56 Z"), p.beak)302+ drawPath(svg("M 120 48 L 138 50 L 128 52 Z"), p.beakShade, alpha = 0.55f)303+ drawPath(svg("M 122 52 L 134 51"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.6f))304+ }305+ } else {306+ val head = Rect(98f, 44f, 138f, 84f)307+ drawCircle(radial(head, 0.45f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 20f, center = Offset(118f, 64f))308+ drawPath(svg("M 100 60 Q 108 46, 124 46 Q 134 50, 136 60 Q 124 54, 108 60 Z"), p.shade, alpha = 0.55f)309+ scale(1f, eyeOpen, pivot = Offset(126f, 60f)) {310+ drawCircle(EyeDark, radius = 4f, center = Offset(126f, 60f))311+ drawCircle(EyeGlint, radius = 1.2f, center = Offset(127.2f, 58.8f))312+ }313+ rotate(-14f * beat, pivot = Offset(136f, 64f)) {314+ drawPath(svg("M 136 60 L 154 62 L 138 66 Z"), p.beak)315+ drawPath(svg("M 136 60 L 154 62 L 145 63 Z"), p.beakShade, alpha = 0.5f)316+ drawPath(svg("M 138 63 L 152 63"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.5f))317+ }318+ }319+}app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+41 −37
@@ -93,21 +93,24 @@9393 )9494 }9595 96- Copy(phase, progress.peerDeviceName, progress.errorMessage)97-98- Spacer(Modifier.height(PeachSpacing.lg))99- PrimaryAction(phase, vm)96+ // SAS is a phase, not a modal — the browser keeps the birds on97+ // screen above it. Scrimming them behind a full-screen overlay made98+ // the code sit in the middle of a half-lit bird.99+ val code = sas100+ if (code != null) {101+ SasBody(102+ code = code,103+ accent = accent,104+ onConfirm = { if (vm.isDebug && phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },105+ onCancel = { vm.confirmSas(false); vm.reset() },106+ )107+ } else {108+ Copy(phase, progress.peerDeviceName, progress.errorMessage)109+ Spacer(Modifier.height(PeachSpacing.lg))110+ PrimaryAction(phase, vm)111+ }100112 Spacer(Modifier.height(PeachSpacing.md))101113 }102-103- if (sas != null) {104- SasOverlay(105- code = sas!!,106- accent = accent,107- onConfirm = { if (vm.isDebug && progress.phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },108- onCancel = { vm.confirmSas(false); vm.reset() },109- )110- }111114 }112115 }113116 @@ -209,30 +212,31 @@209212 }210213 211214 @Composable212-private fun SasOverlay(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {213- Box(214- modifier = Modifier.fillMaxSize().background(Color(0xCC0C0A06)),215- contentAlignment = Alignment.Center,215+private fun SasBody(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {216+ Column(217+ modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.md),218+ horizontalAlignment = Alignment.CenterHorizontally,216219 ) {217- Column(218- modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),219- horizontalAlignment = Alignment.CenterHorizontally,220- ) {221- Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)222- Spacer(Modifier.height(PeachSpacing.xs))223- Text("It must match on both devices.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)224- Spacer(Modifier.height(PeachSpacing.xl))225- Text(226- text = code,227- color = accent,228- fontFamily = PeachType.Mono,229- fontSize = 38.sp,230- letterSpacing = 6.sp,231- )232- Spacer(Modifier.height(PeachSpacing.xxl))233- PillButton("They match — pair", accent, filled = true, onClick = onConfirm)234- Spacer(Modifier.height(PeachSpacing.sm))235- PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)236- }220+ Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)221+ Spacer(Modifier.height(PeachSpacing.xs))222+ Text(223+ "Only continue if this code is identical on both screens. This is what proves you're talking to your own device.",224+ color = peachColors.T2,225+ fontFamily = PeachType.Body,226+ fontSize = 13.sp,227+ textAlign = TextAlign.Center,228+ )229+ Spacer(Modifier.height(PeachSpacing.lg))230+ Text(231+ text = code,232+ color = accent,233+ fontFamily = PeachType.Mono,234+ fontSize = 38.sp,235+ letterSpacing = 6.sp,236+ )237+ Spacer(Modifier.height(PeachSpacing.lg))238+ PillButton("They match — pair", accent, filled = true, onClick = onConfirm)239+ Spacer(Modifier.height(PeachSpacing.sm))240+ PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)237241 }238242 }