package com.peachpasswords.ui.pairing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.peachpasswords.sync.PairingPhase import com.peachpasswords.ui.components.BirdAnimation import com.peachpasswords.ui.components.PairingAnimState import com.peachpasswords.ui.components.PeachTopBar import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground @Composable fun ChirpPairScreen( onBack: () -> Unit, vm: ChirpPairViewModel = hiltViewModel(), ) { val progress by vm.progress.collectAsStateWithLifecycle() val sas by vm.sasCode.collectAsStateWithLifecycle() val accent = LocalPeachAccent.current val phase = progress.phase ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { PeachTopBar( title = "Sync", leading = { Icon( Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0, modifier = Modifier .size(22.dp) .clip(RoundedCornerShape(PeachSpacing.sm)) .clickable(onClick = onBack), ) }, actions = { StatusChip(phase, accent) }, ) Spacer(Modifier.height(PeachSpacing.sm)) Box( modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center, ) { BirdAnimation( state = animState(phase), modifier = Modifier.fillMaxWidth().height(220.dp), ) } // SAS is a phase, not a modal — the browser keeps the birds on // screen above it. Scrimming them behind a full-screen overlay made // the code sit in the middle of a half-lit bird. val code = sas if (code != null) { SasBody( code = code, accent = accent, onConfirm = { if (vm.isDebug && phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) }, onCancel = { vm.confirmSas(false); vm.reset() }, ) } else { Copy(phase, progress.peerDeviceName, progress.errorMessage) Spacer(Modifier.height(PeachSpacing.lg)) PrimaryAction(phase, vm) } Spacer(Modifier.height(PeachSpacing.md)) } } } private fun animState(phase: PairingPhase): PairingAnimState = when (phase) { PairingPhase.IDLE -> PairingAnimState.WAITING PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE -> PairingAnimState.LISTENING PairingPhase.PAIRED -> PairingAnimState.CONNECTED else -> PairingAnimState.WAITING } @Composable private fun StatusChip(phase: PairingPhase, accent: Color) { val (label, dot) = when (phase) { PairingPhase.IDLE -> "ready" to peachColors.T2 PairingPhase.LISTENING, PairingPhase.CHIRPING -> "listening" to accent PairingPhase.HANDSHAKE -> "verifying" to accent PairingPhase.PAIRED -> "paired" to Color(0xFF7FB069) PairingPhase.MIC_DENIED -> "mic blocked" to peachColors.Breach else -> "interrupted" to peachColors.Breach } val active = phase == PairingPhase.LISTENING || phase == PairingPhase.CHIRPING || phase == PairingPhase.HANDSHAKE // Pulsing dot while the pairing is live (1.0→1.4 over 1.2s, spec Phase 7). val pulse = rememberInfiniteTransition(label = "chip-pulse") val scale by pulse.animateFloat( initialValue = 1f, targetValue = 1.4f, animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "chip-pulse-scale", ) val dotScale = if (active) scale else 1f Row( modifier = Modifier .clip(CircleShape) .background(accent.copy(alpha = 0.12f)) .border(1.dp, accent.copy(alpha = 0.28f), CircleShape) .padding(horizontal = 10.dp, vertical = PeachSpacing.xs), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(4.dp).graphicsLayer { scaleX = dotScale; scaleY = dotScale }.clip(CircleShape).background(dot)) Spacer(Modifier.size(PeachSpacing.xs)) Text(label, color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.08.em) } } @Composable private fun Copy(phase: PairingPhase, peer: String?, error: String?) { val (headline, sub) = when (phase) { PairingPhase.IDLE -> "Pair a device" to "Sync over your local network. No cloud, no account, no servers." PairingPhase.LISTENING, PairingPhase.CHIRPING -> "Listening…" to "Hold this phone near the other device and start pairing there too." PairingPhase.HANDSHAKE -> "Almost there" to "Check that the code below matches on ${peer ?: "the other device"}." PairingPhase.PAIRED -> "Paired" to "${peer ?: "Your devices"} will now keep your vault in step over the local network." PairingPhase.TIMEOUT -> "No chirp heard" to "Nothing answered. Move the devices closer and try again." PairingPhase.MIC_DENIED -> "Microphone needed" to "Pairing listens for an inaudible chirp. Grant mic access to continue." PairingPhase.VERSION_MISMATCH -> "Version mismatch" to "Update Peach on both devices, then try pairing again." else -> "Pairing interrupted" to (error ?: "Something went wrong. Try again.") } Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Text(headline, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp, textAlign = TextAlign.Center) Spacer(Modifier.height(PeachSpacing.sm)) Text(sub, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center) } } @Composable private fun PrimaryAction(phase: PairingPhase, vm: ChirpPairViewModel) { val accent = LocalPeachAccent.current when (phase) { PairingPhase.IDLE -> PillButton("Start pairing", accent, filled = true) { vm.startPairing() } PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE -> PillButton("Cancel", peachColors.T2, filled = false) { vm.reset() } PairingPhase.PAIRED -> PillButton("Done", accent, filled = true) { vm.reset() } else -> PillButton("Try again", accent, filled = true) { vm.startPairing() } } if (vm.isDebug && phase == PairingPhase.IDLE) { Spacer(Modifier.height(PeachSpacing.sm)) Text( "demo flow", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp, modifier = Modifier.fillMaxWidth().clickable { vm.runDemo() }.padding(8.dp), textAlign = TextAlign.Center, ) } } @Composable private fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) { val accent = LocalPeachAccent.current Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent) .clickable(onClick = onClick) .padding(vertical = PeachSpacing.md), contentAlignment = Alignment.Center, ) { Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp) } } @Composable private fun SasBody(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.md), horizontalAlignment = Alignment.CenterHorizontally, ) { Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp) Spacer(Modifier.height(PeachSpacing.xs)) Text( "Only continue if this code is identical on both screens. This is what proves you're talking to your own device.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center, ) Spacer(Modifier.height(PeachSpacing.lg)) Text( text = code, color = accent, fontFamily = PeachType.Mono, fontSize = 38.sp, letterSpacing = 6.sp, ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("They match — pair", accent, filled = true, onClick = onConfirm) Spacer(Modifier.height(PeachSpacing.sm)) PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel) } }