BirdAnimation.kt

7.0 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.animation.core.FastOutSlowInEasing4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.RepeatMode6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.animateFloatAsState8import androidx.compose.animation.core.infiniteRepeatable9import androidx.compose.animation.core.rememberInfiniteTransition10import androidx.compose.animation.core.spring11import androidx.compose.animation.core.tween12import androidx.compose.foundation.Canvas13import androidx.compose.runtime.Composable14import androidx.compose.runtime.getValue15import androidx.compose.ui.Modifier16import androidx.compose.ui.geometry.Offset17import androidx.compose.ui.geometry.Size18import androidx.compose.ui.graphics.Color19import androidx.compose.ui.graphics.Path20import androidx.compose.ui.graphics.drawscope.DrawScope21import androidx.compose.ui.graphics.drawscope.Stroke22import com.peachpasswords.ui.theme.LocalPeachAccent23import kotlin.math.sin2425/** The three acts of the pairing animation, bound to the audio protocol. */26enum class PairingAnimState { WAITING, LISTENING, CONNECTED }2728private val BodyHi = Color(0xFFFFB082)29private val BodyLo = Color(0xFFE5805C)30private val Beak = Color(0xFFF2B45A)31private val Eye = Color(0xFF1F0A10)3233/**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.39 */40@Composable41fun BirdAnimation(42    state: PairingAnimState,43    modifier: Modifier = Modifier,44) {45    val accent = LocalPeachAccent.current4647    val leftX by animateFloatAsState(48        targetValue = when (state) {49            PairingAnimState.WAITING -> 0.16f50            PairingAnimState.LISTENING -> 0.22f51            PairingAnimState.CONNECTED -> 0.39f52        },53        animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),54        label = "leftX",55    )56    val rightX by animateFloatAsState(57        targetValue = when (state) {58            PairingAnimState.WAITING -> 0.84f59            PairingAnimState.LISTENING -> 0.78f60            PairingAnimState.CONNECTED -> 0.61f61        },62        animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),63        label = "rightX",64    )6566    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    )72    // Sound-arc sweep (LISTENING) and connection-glow pulse (CONNECTED).73    val pulse by transition.animateFloat(74        initialValue = 0f, targetValue = 1f,75        animationSpec = infiniteRepeatable(76            tween(if (state == PairingAnimState.CONNECTED) 1600 else 900, easing = FastOutSlowInEasing),77            repeatMode = RepeatMode.Restart,78        ),79        label = "pulse",80    )8182    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)8889        if (state == PairingAnimState.LISTENING) {90            drawSoundArcs(leftCenter, rightCenter, pulse, accent)91        }92        if (state == PairingAnimState.CONNECTED) {93            drawConnection(leftCenter, rightCenter, pulse, accent)94        }9596        drawBird(leftCenter, birdScale, facingRight = true, listening = state != PairingAnimState.WAITING)97        drawBird(rightCenter, birdScale, facingRight = false, listening = state != PairingAnimState.WAITING)98    }99}100101private fun DrawScope.drawSoundArcs(left: Offset, right: Offset, pulse: Float, accent: Color) {102    val mid = Offset((left.x + right.x) / 2f, (left.y + right.y) / 2f)103    val span = (right.x - left.x) / 2f104    for (i in 0 until 3) {105        val t = (pulse + i / 3f) % 1f106        val radius = span * (0.2f + t * 0.8f)107        val alpha = (1f - t) * 0.5f108        drawCircle(109            color = accent.copy(alpha = alpha),110            radius = radius,111            center = mid,112            style = Stroke(width = 1.6f),113        )114    }115}116117private 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)122    val glow = 3f + sin(pulse * 2f * Math.PI.toFloat()) * 2f + 4f123    drawCircle(accent.copy(alpha = 0.22f), radius = glow * 2.4f, center = mid)124    drawCircle(accent, radius = glow, center = mid)125}126127/** A compact finch: body blob, wing, head, beak, eye. */128private 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)136137    // 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    )143144    // 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)154155    // 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)164165    // 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))178}