PairingBirds.kt

14.4 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.foundation.Canvas4import androidx.compose.runtime.Composable5import androidx.compose.runtime.LaunchedEffect6import androidx.compose.runtime.State7import androidx.compose.runtime.getValue8import androidx.compose.runtime.mutableLongStateOf9import androidx.compose.runtime.remember10import androidx.compose.runtime.withFrameMillis11import androidx.compose.ui.Modifier12import androidx.compose.ui.geometry.Offset13import androidx.compose.ui.geometry.Rect14import androidx.compose.ui.geometry.Size15import androidx.compose.ui.graphics.Brush16import androidx.compose.ui.graphics.Color17import androidx.compose.ui.graphics.Path18import androidx.compose.ui.graphics.drawscope.DrawScope19import androidx.compose.ui.graphics.drawscope.Stroke20import androidx.compose.ui.graphics.drawscope.rotate21import androidx.compose.ui.graphics.drawscope.scale22import androidx.compose.ui.graphics.drawscope.translate23import androidx.compose.ui.graphics.vector.PathParser24import kotlin.math.sin2526/**27 * The two pairing birds, ported path-for-path from the browser extension's28 * `PairDeviceFlow.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 */37enum class BirdPose { IDLE, CHIRPING, ATTENTIVE, HAPPY, SAD }3839enum class BirdKind { FINCH, SWALLOW }4041private const val VB_W = 160f42private const val VB_H = 150f4344private 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)5455private 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)6061private 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)6667private val EyeDark = Color(0xFF1F0A10)68private val EyeGlint = Color(0xF2FFF0E1)69private val GroundShadow = Color(0x73000000)7071/** Parsed once per path string — parsing is not cheap enough to do per frame. */72private val pathCache = HashMap<String, Path>()7374private fun svg(d: String): Path = pathCache.getOrPut(d) { PathParser().parsePathString(d).toPath() }7576/** SVG gradients are in objectBoundingBox units; resolve against the shape's bounds. */77private 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    )8384@Composable85fun 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}106107/**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@Composable112private 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}120121/** Position within a repeating cycle, 0..1. */122private fun cycle(elapsed: Long, periodMs: Int): Float = (elapsed % periodMs) / periodMs.toFloat()123124/** Eased 0→1→0 over one cycle, matching the CSS keyframes' ease-in-out feel. */125private fun swell(t: Float): Float = sin(t * Math.PI.toFloat())126127/**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 */131private fun chirpBeat(t: Float): Float = when {132    t < 0.70f || t > 0.94f -> 0f133    else -> swell((t - 0.70f) / 0.24f)134}135136private 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))141142    // 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 1f145146    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    }151152    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    }160161    drawTail(kind, p, pose, beat, elapsed)162    drawFeet(kind, p)163164    // 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    }173174    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}186187private 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}207208private 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}223224private 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}259260private 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}283284private 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}