1package com.peachpasswords.ui.pairing23import androidx.compose.animation.core.RepeatMode4import androidx.compose.animation.core.animateFloat5import androidx.compose.animation.core.infiniteRepeatable6import androidx.compose.animation.core.rememberInfiniteTransition7import androidx.compose.animation.core.tween8import androidx.compose.foundation.background9import androidx.compose.foundation.border10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Arrangement12import androidx.compose.foundation.layout.Box13import androidx.compose.foundation.layout.Column14import androidx.compose.foundation.layout.Row15import androidx.compose.foundation.layout.Spacer16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.fillMaxWidth18import androidx.compose.foundation.layout.height19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.size21import androidx.compose.foundation.layout.systemBarsPadding22import androidx.compose.foundation.shape.CircleShape23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.material3.Text25import androidx.compose.runtime.Composable26import androidx.compose.runtime.getValue27import androidx.compose.ui.Alignment28import androidx.compose.ui.Modifier29import androidx.compose.ui.draw.clip30import androidx.compose.ui.graphics.Color31import androidx.compose.ui.graphics.graphicsLayer32import androidx.compose.ui.text.font.FontStyle33import androidx.compose.ui.text.style.TextAlign34import androidx.compose.ui.unit.dp35import androidx.compose.ui.unit.em36import androidx.compose.ui.unit.sp37import androidx.hilt.navigation.compose.hiltViewModel38import androidx.lifecycle.compose.collectAsStateWithLifecycle39import com.peachpasswords.sync.PairingPhase40import com.peachpasswords.ui.components.BirdAnimation41import com.peachpasswords.ui.components.PairingAnimState42import com.peachpasswords.ui.theme.LocalPeachAccent43import com.peachpasswords.ui.theme.peachColors44import com.peachpasswords.ui.theme.PeachType45import com.peachpasswords.ui.theme.ShellBackground4647@Composable48fun ChirpPairScreen(49 onBack: () -> Unit,50 vm: ChirpPairViewModel = hiltViewModel(),51) {52 val progress by vm.progress.collectAsStateWithLifecycle()53 val sas by vm.sasCode.collectAsStateWithLifecycle()54 val accent = LocalPeachAccent.current55 val phase = progress.phase5657 ShellBackground {58 Column(modifier = Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = 24.dp)) {59 60 Row(61 modifier = Modifier.fillMaxWidth().height(56.dp),62 verticalAlignment = Alignment.CenterVertically,63 ) {64 Text(65 text = "‹",66 color = peachColors.T1,67 fontSize = 26.sp,68 modifier = Modifier.clip(CircleShape).clickable(onClick = onBack).padding(horizontal = 8.dp),69 )70 Spacer(Modifier.size(8.dp))71 Text("Sync", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)72 }7374 Spacer(Modifier.height(4.dp))75 StatusChip(phase, accent)7677 Box(78 modifier = Modifier.fillMaxWidth().weight(1f),79 contentAlignment = Alignment.Center,80 ) {81 BirdAnimation(82 state = animState(phase),83 modifier = Modifier.fillMaxWidth().height(220.dp),84 )85 }8687 Copy(phase, progress.peerDeviceName, progress.errorMessage)8889 Spacer(Modifier.height(20.dp))90 PrimaryAction(phase, vm)91 Spacer(Modifier.height(28.dp))92 }9394 if (sas != null) {95 SasOverlay(96 code = sas!!,97 accent = accent,98 onConfirm = { if (vm.isDebug && progress.phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },99 onCancel = { vm.confirmSas(false); vm.reset() },100 )101 }102 }103}104105private fun animState(phase: PairingPhase): PairingAnimState = when (phase) {106 PairingPhase.IDLE -> PairingAnimState.WAITING107 PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE -> PairingAnimState.LISTENING108 PairingPhase.PAIRED -> PairingAnimState.CONNECTED109 else -> PairingAnimState.WAITING110}111112@Composable113private fun StatusChip(phase: PairingPhase, accent: Color) {114 val (label, dot) = when (phase) {115 PairingPhase.IDLE -> "ready" to peachColors.T2116 PairingPhase.LISTENING, PairingPhase.CHIRPING -> "listening" to accent117 PairingPhase.HANDSHAKE -> "verifying" to accent118 PairingPhase.PAIRED -> "paired" to Color(0xFF7FB069)119 PairingPhase.MIC_DENIED -> "mic blocked" to peachColors.Breach120 else -> "interrupted" to peachColors.Breach121 }122 val active = phase == PairingPhase.LISTENING || phase == PairingPhase.CHIRPING || phase == PairingPhase.HANDSHAKE123 124 val pulse = rememberInfiniteTransition(label = "chip-pulse")125 val scale by pulse.animateFloat(126 initialValue = 1f, targetValue = 1.4f,127 animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "chip-pulse-scale",128 )129 val dotScale = if (active) scale else 1f130 Row(131 modifier = Modifier132 .clip(CircleShape)133 .background(accent.copy(alpha = 0.12f))134 .border(1.dp, accent.copy(alpha = 0.28f), CircleShape)135 .padding(horizontal = 10.dp, vertical = 5.dp),136 verticalAlignment = Alignment.CenterVertically,137 ) {138 Box(Modifier.size(4.dp).graphicsLayer { scaleX = dotScale; scaleY = dotScale }.clip(CircleShape).background(dot))139 Spacer(Modifier.size(6.dp))140 Text(label, color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.08.em)141 }142}143144@Composable145private fun Copy(phase: PairingPhase, peer: String?, error: String?) {146 val (headline, sub) = when (phase) {147 PairingPhase.IDLE -> "Pair a device" to "Sync over your local network. No cloud, no account, no servers."148 PairingPhase.LISTENING, PairingPhase.CHIRPING -> "Listening…" to "Hold this phone near the other device and start pairing there too."149 PairingPhase.HANDSHAKE -> "Almost there" to "Check that the code below matches on ${peer ?: "the other device"}."150 PairingPhase.PAIRED -> "Paired" to "${peer ?: "Your devices"} will now keep your vault in step over the local network."151 PairingPhase.TIMEOUT -> "No chirp heard" to "Nothing answered. Move the devices closer and try again."152 PairingPhase.MIC_DENIED -> "Microphone needed" to "Pairing listens for an inaudible chirp. Grant mic access to continue."153 PairingPhase.VERSION_MISMATCH -> "Version mismatch" to "Update Peach on both devices, then try pairing again."154 else -> "Pairing interrupted" to (error ?: "Something went wrong. Try again.")155 }156 Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {157 Text(headline, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp, textAlign = TextAlign.Center)158 Spacer(Modifier.height(8.dp))159 Text(sub, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)160 }161}162163@Composable164private fun PrimaryAction(phase: PairingPhase, vm: ChirpPairViewModel) {165 val accent = LocalPeachAccent.current166 when (phase) {167 PairingPhase.IDLE -> PillButton("Start pairing", accent, filled = true) { vm.startPairing() }168 PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE ->169 PillButton("Cancel", peachColors.T2, filled = false) { vm.reset() }170 PairingPhase.PAIRED -> PillButton("Done", accent, filled = true) { vm.reset() }171 else -> PillButton("Try again", accent, filled = true) { vm.startPairing() }172 }173 if (vm.isDebug && phase == PairingPhase.IDLE) {174 Spacer(Modifier.height(10.dp))175 Text(176 "demo flow",177 color = peachColors.T3,178 fontFamily = PeachType.Mono,179 fontSize = 9.sp,180 modifier = Modifier.fillMaxWidth().clickable { vm.runDemo() }.padding(8.dp),181 textAlign = TextAlign.Center,182 )183 }184}185186@Composable187private fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {188 val accent = LocalPeachAccent.current189 Box(190 modifier = Modifier191 .fillMaxWidth()192 .clip(RoundedCornerShape(14.dp))193 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)194 .clickable(onClick = onClick)195 .padding(vertical = 15.dp),196 contentAlignment = Alignment.Center,197 ) {198 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)199 }200}201202@Composable203private fun SasOverlay(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {204 Box(205 modifier = Modifier.fillMaxSize().background(Color(0xCC0C0A06)),206 contentAlignment = Alignment.Center,207 ) {208 Column(209 modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),210 horizontalAlignment = Alignment.CenterHorizontally,211 ) {212 Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)213 Spacer(Modifier.height(6.dp))214 Text("It must match on both devices.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)215 Spacer(Modifier.height(28.dp))216 Text(217 text = code,218 color = accent,219 fontFamily = PeachType.Mono,220 fontSize = 38.sp,221 letterSpacing = 6.sp,222 )223 Spacer(Modifier.height(34.dp))224 PillButton("They match — pair", accent, filled = true, onClick = onConfirm)225 Spacer(Modifier.height(10.dp))226 PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)227 }228 }229}