package com.peachpasswords.ui.components import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.vector.PathParser import kotlin.math.sin /** * The two pairing birds, ported path-for-path from the browser extension's * `PairDeviceFlow.tsx` (a finch and a swallow) rather than redrawn by eye. * * Android previously carried its own simplified blob-and-triangle bird, which * read as a different, worse character next to the extension's. The SVG `d` * strings below are copied verbatim from that file — keep them that way, and * re-copy rather than nudge if the browser art changes. Everything is drawn in * the source 160x150 viewBox and scaled to fit, so the coordinates stay * directly comparable to the SVG. */ enum class BirdPose { IDLE, CHIRPING, ATTENTIVE, HAPPY, SAD } enum class BirdKind { FINCH, SWALLOW } private const val VB_W = 160f private const val VB_H = 150f private class BirdPalette( val hi: Color, val mid: Color, val lo: Color, val shade: Color, val cream: Color, val beak: Color, val beakShade: Color, val legs: Color, ) private val Finch = BirdPalette( hi = Color(0xFFF4A6B6), mid = Color(0xFFDC8294), lo = Color(0xFFA85870), shade = Color(0xFF7A3E54), cream = Color(0xFFF5D8C8), beak = Color(0xFFF5C764), beakShade = Color(0xFFC8932E), legs = Color(0xFF7A3E54), ) private val Swallow = BirdPalette( hi = Color(0xFFF4C8A0), mid = Color(0xFFDC9968), lo = Color(0xFFA86B3E), shade = Color(0xFF7A4A26), cream = Color(0xFFF7E0C8), beak = Color(0xFFF5C764), beakShade = Color(0xFFB87A22), legs = Color(0xFF5E3A1F), ) private val EyeDark = Color(0xFF1F0A10) private val EyeGlint = Color(0xF2FFF0E1) private val GroundShadow = Color(0x73000000) /** Parsed once per path string — parsing is not cheap enough to do per frame. */ private val pathCache = HashMap() private fun svg(d: String): Path = pathCache.getOrPut(d) { PathParser().parsePathString(d).toPath() } /** SVG gradients are in objectBoundingBox units; resolve against the shape's bounds. */ private fun radial(b: Rect, cx: Float, cy: Float, r: Float, stops: Array>) = Brush.radialGradient( colorStops = stops, center = Offset(b.left + b.width * cx, b.top + b.height * cy), radius = maxOf(b.width, b.height) * r, ) @Composable fun PairingBird( kind: BirdKind, pose: BirdPose, modifier: Modifier = Modifier, ) { val elapsed by rememberFrameMillis() Canvas(modifier) { val s = minOf(size.width / VB_W, size.height / VB_H) translate((size.width - VB_W * s) / 2f, (size.height - VB_H * s) / 2f) { scale(s, s, pivot = Offset.Zero) { // The swallow is drawn mirrored in the extension so the pair // faces each other; mirroring here keeps its path data verbatim. if (kind == BirdKind.SWALLOW) { scale(-1f, 1f, pivot = Offset(VB_W / 2f, VB_H / 2f)) { drawBird(kind, pose, elapsed) } } else { drawBird(kind, pose, elapsed) } } } } } /** * System animator scale can be zeroed (it is on our test emulator), which * silently freezes `animateFloat`. The frame clock is not scaled. */ @Composable private fun rememberFrameMillis(): State { val millis = remember { mutableLongStateOf(0L) } LaunchedEffect(Unit) { val start = withFrameMillis { it } while (true) withFrameMillis { millis.longValue = it - start } } return millis } /** Position within a repeating cycle, 0..1. */ private fun cycle(elapsed: Long, periodMs: Int): Float = (elapsed % periodMs) / periodMs.toFloat() /** Eased 0→1→0 over one cycle, matching the CSS keyframes' ease-in-out feel. */ private fun swell(t: Float): Float = sin(t * Math.PI.toFloat()) /** * The chirp gesture fires late in its cycle (CSS: still at 70%, peak at 78%, * settling by 86%). Returns 0 outside that window, 0..1..0 inside it. */ private fun chirpBeat(t: Float): Float = when { t < 0.70f || t > 0.94f -> 0f else -> swell((t - 0.70f) / 0.24f) } private fun DrawScope.drawBird(kind: BirdKind, pose: BirdPose, elapsed: Long) { val p = if (kind == BirdKind.FINCH) Finch else Swallow val chirping = pose == BirdPose.CHIRPING val beat = if (chirping) chirpBeat(cycle(elapsed, 1700)) else 0f val bob = -1.5f * swell(cycle(elapsed, 2400)) // Blink: a 5.2s cycle that is open except for one short squash. val blinkT = cycle(elapsed, 5200) val eyeOpen = if (blinkT > 0.92f && blinkT < 0.96f) 0.05f else 1f if (kind == BirdKind.FINCH) { drawOval(GroundShadow, topLeft = Offset(38f, 134.5f), size = Size(80f, 7f)) } else { drawOval(GroundShadow, topLeft = Offset(52f, 137f), size = Size(68f, 6f)) } if (beat > 0f) { val aura = if (kind == BirdKind.FINCH) Rect(16f, 28f, 136f, 120f) else Rect(20f, 36f, 140f, 120f) drawOval( p.hi.copy(alpha = 0.18f * 0.8f * beat), topLeft = Offset(aura.left, aura.top), size = Size(aura.width, aura.height), ) } drawTail(kind, p, pose, beat, elapsed) drawFeet(kind, p) // Body bob is a translate + barely-there squash; the sad pose replaces it // with a static droop. val droop = pose == BirdPose.SAD translate(0f, if (droop) 5f else bob) { rotate(if (droop) -2f else 0f, pivot = Offset(VB_W / 2f, VB_H * 0.8f)) { drawBody(kind, p) drawWing(kind, p, pose, elapsed) } } val headPivot = if (kind == BirdKind.FINCH) Offset(95f, 52f) else Offset(116f, 64f) val headTilt = when (pose) { BirdPose.ATTENTIVE -> -6f BirdPose.CHIRPING -> -4f * beat else -> 0f } translate(0f, bob - 3f * beat) { rotate(headTilt, pivot = headPivot) { drawHead(kind, p, pose, beat, eyeOpen) } } } private fun DrawScope.drawTail(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, elapsed: Long) { val twitch = when (pose) { BirdPose.CHIRPING -> -3f * beat BirdPose.ATTENTIVE -> -4f BirdPose.HAPPY -> 6f * sin(cycle(elapsed, 500) * 2f * Math.PI.toFloat()) else -> 0f } // CSS transform-origin 100% 50% — the tail hinges at the body end. val pivot = if (kind == BirdKind.FINCH) Offset(32f, 95f) else Offset(38f, 80f) rotate(twitch, pivot = pivot) { if (kind == BirdKind.FINCH) { drawPath(svg("M 32 92 Q 8 90, 6 80 Q 10 95, 28 100 Z"), p.lo) drawPath(svg("M 32 95 Q 4 99, 4 92 Q 10 102, 28 102 Z"), p.shade, alpha = 0.85f) drawPath(svg("M 32 98 Q 8 108, 12 110 Q 20 110, 30 105 Z"), p.lo, alpha = 0.9f) } else { 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) drawPath(svg("M 30 84 Q 14 78, 6 70 Q 18 84, 30 88 Z"), p.shade, alpha = 0.7f) } } } private fun DrawScope.drawFeet(kind: BirdKind, p: BirdPalette) { val leg = Stroke(width = if (kind == BirdKind.FINCH) 1.6f else 1.5f, cap = androidx.compose.ui.graphics.StrokeCap.Round) val toe = Stroke(width = if (kind == BirdKind.FINCH) 1.2f else 1.1f, cap = androidx.compose.ui.graphics.StrokeCap.Round) if (kind == BirdKind.FINCH) { drawLine(p.legs, Offset(62f, 118f), Offset(60f, 134f), strokeWidth = leg.width, cap = leg.cap) drawPath(svg("M 60 134 L 55 138 M 60 134 L 60 139 M 60 134 L 65 138"), p.legs, style = toe) drawLine(p.legs, Offset(84f, 118f), Offset(86f, 134f), strokeWidth = leg.width, cap = leg.cap) drawPath(svg("M 86 134 L 81 138 M 86 134 L 86 139 M 86 134 L 91 138"), p.legs, style = toe) } else { drawLine(p.legs, Offset(76f, 118f), Offset(74f, 136f), strokeWidth = leg.width, cap = leg.cap) drawPath(svg("M 74 136 L 69 140 M 74 136 L 74 141 M 74 136 L 79 140"), p.legs, style = toe) drawLine(p.legs, Offset(96f, 118f), Offset(98f, 136f), strokeWidth = leg.width, cap = leg.cap) drawPath(svg("M 98 136 L 93 140 M 98 136 L 98 141 M 98 136 L 103 140"), p.legs, style = toe) } } private fun DrawScope.drawBody(kind: BirdKind, p: BirdPalette) { if (kind == BirdKind.FINCH) { 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") 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))) 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") val bb = belly.getBounds() drawPath( belly, Brush.verticalGradient( colorStops = arrayOf( 0f to p.cream.copy(alpha = 0f), 0.5f to p.cream.copy(alpha = 0.85f), 1f to p.cream.copy(alpha = 0.5f), ), startY = bb.top, endY = bb.bottom, ), ) } else { 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") 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))) drawPath( 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"), p.shade, alpha = 0.5f, ) 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") val tb = throat.getBounds() drawPath( throat, Brush.verticalGradient( colorStops = arrayOf(0f to p.cream.copy(alpha = 0.95f), 1f to p.cream.copy(alpha = 0.2f)), startY = tb.top, endY = tb.bottom, ), ) } } private fun DrawScope.drawWing(kind: BirdKind, p: BirdPalette, pose: BirdPose, elapsed: Long) { val hair = Stroke(width = if (kind == BirdKind.FINCH) 0.7f else 0.6f, cap = androidx.compose.ui.graphics.StrokeCap.Round) val flutter = if (pose == BirdPose.HAPPY) -16f * swell(cycle(elapsed, 320)) else 0f val pivot = if (kind == BirdKind.FINCH) Offset(92f, 78f) else Offset(103f, 89f) rotate(flutter, pivot = pivot) { if (kind == BirdKind.FINCH) { 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) 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")) { drawPath(svg(d), p.shade, alpha = 0.55f, style = hair) } drawPath(svg("M 56 60 C 68 56, 88 60, 102 68"), p.shade, alpha = 0.4f, style = Stroke(width = 1f)) } else { 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) 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")) { drawPath(svg(d), p.shade, alpha = 0.85f) } 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")) { drawPath(svg(d), p.shade, alpha = 0.5f, style = hair) } drawPath(svg("M 50 70 C 64 64, 84 66, 100 72"), p.shade, alpha = 0.55f, style = Stroke(width = 1f)) } } } private fun DrawScope.drawHead(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, eyeOpen: Float) { if (kind == BirdKind.FINCH) { val perk = if (pose == BirdPose.HAPPY) -15f else 0f rotate(perk, pivot = Offset(88f, 30f)) { drawPath(svg("M 88 22 Q 86 12, 94 12 Q 94 20, 92 26 Z"), p.lo) drawPath(svg("M 96 20 Q 96 10, 104 14 Q 102 22, 100 26 Z"), p.shade, alpha = 0.95f) drawPath(svg("M 104 24 Q 108 16, 114 20 Q 110 28, 106 30 Z"), p.lo, alpha = 0.85f) } val head = Rect(72f, 22f, 124f, 74f) drawCircle(radial(head, 0.5f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 26f, center = Offset(98f, 48f)) drawPath(svg("M 76 46 Q 80 30, 96 26 Q 86 32, 80 48 Z"), p.shade, alpha = 0.35f) scale(1f, eyeOpen, pivot = Offset(108f, 42f)) { drawCircle(EyeDark, radius = 4.5f, center = Offset(108f, 42f)) drawCircle(EyeGlint, radius = 1.4f, center = Offset(109.4f, 40.5f)) drawCircle(EyeGlint.copy(alpha = 0.6f), radius = 0.5f, center = Offset(106.5f, 44f)) } rotate(-14f * beat, pivot = Offset(120f, 52f)) { drawPath(svg("M 120 48 L 138 50 L 122 56 Z"), p.beak) drawPath(svg("M 120 48 L 138 50 L 128 52 Z"), p.beakShade, alpha = 0.55f) drawPath(svg("M 122 52 L 134 51"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.6f)) } } else { val head = Rect(98f, 44f, 138f, 84f) drawCircle(radial(head, 0.45f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 20f, center = Offset(118f, 64f)) 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) scale(1f, eyeOpen, pivot = Offset(126f, 60f)) { drawCircle(EyeDark, radius = 4f, center = Offset(126f, 60f)) drawCircle(EyeGlint, radius = 1.2f, center = Offset(127.2f, 58.8f)) } rotate(-14f * beat, pivot = Offset(136f, 64f)) { drawPath(svg("M 136 60 L 154 62 L 138 66 Z"), p.beak) drawPath(svg("M 136 60 L 154 62 L 145 63 Z"), p.beakShade, alpha = 0.5f) drawPath(svg("M 138 63 L 152 63"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.5f)) } } }