BirdAnimation.kt

5.8 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.animation.core.FastOutSlowInEasing4import androidx.compose.animation.core.RepeatMode5import androidx.compose.animation.core.animateFloat6import androidx.compose.animation.core.animateFloatAsState7import androidx.compose.animation.core.infiniteRepeatable8import androidx.compose.animation.core.rememberInfiniteTransition9import androidx.compose.animation.core.spring10import androidx.compose.animation.core.tween11import androidx.compose.foundation.Canvas12import androidx.compose.foundation.layout.Box13import androidx.compose.foundation.layout.BoxWithConstraints14import androidx.compose.foundation.layout.fillMaxSize15import androidx.compose.foundation.layout.offset16import androidx.compose.foundation.layout.size17import androidx.compose.runtime.Composable18import androidx.compose.runtime.getValue19import androidx.compose.ui.Alignment20import androidx.compose.ui.Modifier21import androidx.compose.ui.geometry.Offset22import androidx.compose.ui.graphics.Color23import androidx.compose.ui.graphics.drawscope.DrawScope24import androidx.compose.ui.graphics.drawscope.Stroke25import androidx.compose.ui.unit.dp26import com.peachpasswords.ui.theme.LocalPeachAccent27import kotlin.math.sin2829/** The three acts of the pairing animation, bound to the audio protocol. */30enum class PairingAnimState { WAITING, LISTENING, CONNECTED }3132/**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].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.28f52        },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.72f61        },62        animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),63        label = "rightX",64    )6566    val transition = rememberInfiniteTransition(label = "bird")67    // Sound-arc sweep (LISTENING) and connection-glow pulse (CONNECTED).68    val pulse by transition.animateFloat(69        initialValue = 0f, targetValue = 1f,70        animationSpec = infiniteRepeatable(71            tween(if (state == PairingAnimState.CONNECTED) 1600 else 900, easing = FastOutSlowInEasing),72            repeatMode = RepeatMode.Restart,73        ),74        label = "pulse",75    )7677    // 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.ATTENTIVE8283    BoxWithConstraints(modifier) {84        val birdSize = (maxHeight * 0.92f).coerceAtMost(maxWidth * 0.38f)8586        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            }95        }9697        // 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        }117    }118}119120private fun DrawScope.drawSoundArcs(left: Offset, right: Offset, pulse: Float, accent: Color) {121    val mid = Offset((left.x + right.x) / 2f, (left.y + right.y) / 2f)122    val span = (right.x - left.x) / 2f123    for (i in 0 until 3) {124        val t = (pulse + i / 3f) % 1f125        val radius = span * (0.2f + t * 0.8f)126        val alpha = (1f - t) * 0.5f127        drawCircle(128            color = accent.copy(alpha = alpha),129            radius = radius,130            center = mid,131            style = Stroke(width = 1.6f),132        )133    }134}135136private fun DrawScope.drawConnection(left: Offset, right: Offset, pulse: Float, accent: Color) {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)139    val glow = 3f + sin(pulse * 2f * Math.PI.toFloat()) * 2f + 4f140    drawCircle(accent.copy(alpha = 0.22f), radius = glow * 2.4f, center = mid)141    drawCircle(accent, radius = glow, center = mid)142}