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.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxSize16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.height18import androidx.compose.foundation.layout.padding19import androidx.compose.foundation.layout.size20import androidx.compose.foundation.layout.systemBarsPadding21import androidx.compose.material.icons.Icons22import androidx.compose.material.icons.automirrored.rounded.ArrowBack23import androidx.compose.material3.Icon24import androidx.compose.foundation.shape.CircleShape25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.material3.Text27import androidx.compose.runtime.Composable28import androidx.compose.runtime.getValue29import androidx.compose.ui.Alignment30import androidx.compose.ui.Modifier31import androidx.compose.ui.draw.clip32import androidx.compose.ui.graphics.Color33import androidx.compose.ui.graphics.graphicsLayer34import androidx.compose.ui.text.font.FontStyle35import androidx.compose.ui.text.style.TextAlign36import androidx.compose.ui.unit.dp37import androidx.compose.ui.unit.em38import androidx.compose.ui.unit.sp39import androidx.hilt.navigation.compose.hiltViewModel40import androidx.lifecycle.compose.collectAsStateWithLifecycle41import com.peachpasswords.sync.PairingPhase42import com.peachpasswords.ui.components.BirdAnimation43import com.peachpasswords.ui.components.PairingAnimState44import com.peachpasswords.ui.components.PeachTopBar45import com.peachpasswords.ui.theme.LocalPeachAccent46import com.peachpasswords.ui.theme.PeachSpacing47import com.peachpasswords.ui.theme.peachColors48import com.peachpasswords.ui.theme.PeachType49import com.peachpasswords.ui.theme.ShellBackground5051@Composable52fun ChirpPairScreen(53 onBack: () -> Unit,54 vm: ChirpPairViewModel = hiltViewModel(),55) {56 val progress by vm.progress.collectAsStateWithLifecycle()57 val sas by vm.sasCode.collectAsStateWithLifecycle()58 val accent = LocalPeachAccent.current59 val phase = progress.phase6061 ShellBackground {62 Column(63 modifier = Modifier64 .fillMaxSize()65 .systemBarsPadding()66 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),67 ) {68 PeachTopBar(69 title = "Sync",70 leading = {71 Icon(72 Icons.AutoMirrored.Rounded.ArrowBack,73 contentDescription = "back",74 tint = peachColors.T0,75 modifier = Modifier76 .size(22.dp)77 .clip(RoundedCornerShape(PeachSpacing.sm))78 .clickable(onClick = onBack),79 )80 },81 actions = { StatusChip(phase, accent) },82 )8384 Spacer(Modifier.height(PeachSpacing.sm))8586 Box(87 modifier = Modifier.fillMaxWidth().weight(1f),88 contentAlignment = Alignment.Center,89 ) {90 BirdAnimation(91 state = animState(phase),92 modifier = Modifier.fillMaxWidth().height(220.dp),93 )94 }9596 Copy(phase, progress.peerDeviceName, progress.errorMessage)9798 Spacer(Modifier.height(PeachSpacing.lg))99 PrimaryAction(phase, vm)100 Spacer(Modifier.height(PeachSpacing.md))101 }102103 if (sas != null) {104 SasOverlay(105 code = sas!!,106 accent = accent,107 onConfirm = { if (vm.isDebug && progress.phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },108 onCancel = { vm.confirmSas(false); vm.reset() },109 )110 }111 }112}113114private fun animState(phase: PairingPhase): PairingAnimState = when (phase) {115 PairingPhase.IDLE -> PairingAnimState.WAITING116 PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE -> PairingAnimState.LISTENING117 PairingPhase.PAIRED -> PairingAnimState.CONNECTED118 else -> PairingAnimState.WAITING119}120121@Composable122private fun StatusChip(phase: PairingPhase, accent: Color) {123 val (label, dot) = when (phase) {124 PairingPhase.IDLE -> "ready" to peachColors.T2125 PairingPhase.LISTENING, PairingPhase.CHIRPING -> "listening" to accent126 PairingPhase.HANDSHAKE -> "verifying" to accent127 PairingPhase.PAIRED -> "paired" to Color(0xFF7FB069)128 PairingPhase.MIC_DENIED -> "mic blocked" to peachColors.Breach129 else -> "interrupted" to peachColors.Breach130 }131 val active = phase == PairingPhase.LISTENING || phase == PairingPhase.CHIRPING || phase == PairingPhase.HANDSHAKE132 133 val pulse = rememberInfiniteTransition(label = "chip-pulse")134 val scale by pulse.animateFloat(135 initialValue = 1f, targetValue = 1.4f,136 animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "chip-pulse-scale",137 )138 val dotScale = if (active) scale else 1f139 Row(140 modifier = Modifier141 .clip(CircleShape)142 .background(accent.copy(alpha = 0.12f))143 .border(1.dp, accent.copy(alpha = 0.28f), CircleShape)144 .padding(horizontal = 10.dp, vertical = PeachSpacing.xs),145 verticalAlignment = Alignment.CenterVertically,146 ) {147 Box(Modifier.size(4.dp).graphicsLayer { scaleX = dotScale; scaleY = dotScale }.clip(CircleShape).background(dot))148 Spacer(Modifier.size(PeachSpacing.xs))149 Text(label, color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.08.em)150 }151}152153@Composable154private fun Copy(phase: PairingPhase, peer: String?, error: String?) {155 val (headline, sub) = when (phase) {156 PairingPhase.IDLE -> "Pair a device" to "Sync over your local network. No cloud, no account, no servers."157 PairingPhase.LISTENING, PairingPhase.CHIRPING -> "Listening…" to "Hold this phone near the other device and start pairing there too."158 PairingPhase.HANDSHAKE -> "Almost there" to "Check that the code below matches on ${peer ?: "the other device"}."159 PairingPhase.PAIRED -> "Paired" to "${peer ?: "Your devices"} will now keep your vault in step over the local network."160 PairingPhase.TIMEOUT -> "No chirp heard" to "Nothing answered. Move the devices closer and try again."161 PairingPhase.MIC_DENIED -> "Microphone needed" to "Pairing listens for an inaudible chirp. Grant mic access to continue."162 PairingPhase.VERSION_MISMATCH -> "Version mismatch" to "Update Peach on both devices, then try pairing again."163 else -> "Pairing interrupted" to (error ?: "Something went wrong. Try again.")164 }165 Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {166 Text(headline, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp, textAlign = TextAlign.Center)167 Spacer(Modifier.height(PeachSpacing.sm))168 Text(sub, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)169 }170}171172@Composable173private fun PrimaryAction(phase: PairingPhase, vm: ChirpPairViewModel) {174 val accent = LocalPeachAccent.current175 when (phase) {176 PairingPhase.IDLE -> PillButton("Start pairing", accent, filled = true) { vm.startPairing() }177 PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE ->178 PillButton("Cancel", peachColors.T2, filled = false) { vm.reset() }179 PairingPhase.PAIRED -> PillButton("Done", accent, filled = true) { vm.reset() }180 else -> PillButton("Try again", accent, filled = true) { vm.startPairing() }181 }182 if (vm.isDebug && phase == PairingPhase.IDLE) {183 Spacer(Modifier.height(PeachSpacing.sm))184 Text(185 "demo flow",186 color = peachColors.T3,187 fontFamily = PeachType.Mono,188 fontSize = 9.sp,189 modifier = Modifier.fillMaxWidth().clickable { vm.runDemo() }.padding(8.dp),190 textAlign = TextAlign.Center,191 )192 }193}194195@Composable196private fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {197 val accent = LocalPeachAccent.current198 Box(199 modifier = Modifier200 .fillMaxWidth()201 .clip(RoundedCornerShape(14.dp))202 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)203 .clickable(onClick = onClick)204 .padding(vertical = PeachSpacing.md),205 contentAlignment = Alignment.Center,206 ) {207 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)208 }209}210211@Composable212private fun SasOverlay(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {213 Box(214 modifier = Modifier.fillMaxSize().background(Color(0xCC0C0A06)),215 contentAlignment = Alignment.Center,216 ) {217 Column(218 modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),219 horizontalAlignment = Alignment.CenterHorizontally,220 ) {221 Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)222 Spacer(Modifier.height(PeachSpacing.xs))223 Text("It must match on both devices.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)224 Spacer(Modifier.height(PeachSpacing.xl))225 Text(226 text = code,227 color = accent,228 fontFamily = PeachType.Mono,229 fontSize = 38.sp,230 letterSpacing = 6.sp,231 )232 Spacer(Modifier.height(PeachSpacing.xxl))233 PillButton("They match — pair", accent, filled = true, onClick = onConfirm)234 Spacer(Modifier.height(PeachSpacing.sm))235 PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)236 }237 }238}