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.sin242526enum 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)323334353637383940@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 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}126127128private 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 138 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 145 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 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 166 drawCircle(BodyHi, radius = 22f * scale, center = p(34f, -30f))167 168 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 176 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}