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 97 98 99 val code = sas100 if (code != null) {101 SasBody(102 code = code,103 accent = accent,104 onConfirm = { if (vm.isDebug && phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },105 onCancel = { vm.confirmSas(false); vm.reset() },106 )107 } else {108 Copy(phase, progress.peerDeviceName, progress.errorMessage)109 Spacer(Modifier.height(PeachSpacing.lg))110 PrimaryAction(phase, vm)111 }112 Spacer(Modifier.height(PeachSpacing.md))113 }114 }115}116117private fun animState(phase: PairingPhase): PairingAnimState = when (phase) {118 PairingPhase.IDLE -> PairingAnimState.WAITING119 PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE -> PairingAnimState.LISTENING120 PairingPhase.PAIRED -> PairingAnimState.CONNECTED121 else -> PairingAnimState.WAITING122}123124@Composable125private fun StatusChip(phase: PairingPhase, accent: Color) {126 val (label, dot) = when (phase) {127 PairingPhase.IDLE -> "ready" to peachColors.T2128 PairingPhase.LISTENING, PairingPhase.CHIRPING -> "listening" to accent129 PairingPhase.HANDSHAKE -> "verifying" to accent130 PairingPhase.PAIRED -> "paired" to Color(0xFF7FB069)131 PairingPhase.MIC_DENIED -> "mic blocked" to peachColors.Breach132 else -> "interrupted" to peachColors.Breach133 }134 val active = phase == PairingPhase.LISTENING || phase == PairingPhase.CHIRPING || phase == PairingPhase.HANDSHAKE135 136 val pulse = rememberInfiniteTransition(label = "chip-pulse")137 val scale by pulse.animateFloat(138 initialValue = 1f, targetValue = 1.4f,139 animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "chip-pulse-scale",140 )141 val dotScale = if (active) scale else 1f142 Row(143 modifier = Modifier144 .clip(CircleShape)145 .background(accent.copy(alpha = 0.12f))146 .border(1.dp, accent.copy(alpha = 0.28f), CircleShape)147 .padding(horizontal = 10.dp, vertical = PeachSpacing.xs),148 verticalAlignment = Alignment.CenterVertically,149 ) {150 Box(Modifier.size(4.dp).graphicsLayer { scaleX = dotScale; scaleY = dotScale }.clip(CircleShape).background(dot))151 Spacer(Modifier.size(PeachSpacing.xs))152 Text(label, color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.08.em)153 }154}155156@Composable157private fun Copy(phase: PairingPhase, peer: String?, error: String?) {158 val (headline, sub) = when (phase) {159 PairingPhase.IDLE -> "Pair a device" to "Sync over your local network. No cloud, no account, no servers."160 PairingPhase.LISTENING, PairingPhase.CHIRPING -> "Listening…" to "Hold this phone near the other device and start pairing there too."161 PairingPhase.HANDSHAKE -> "Almost there" to "Check that the code below matches on ${peer ?: "the other device"}."162 PairingPhase.PAIRED -> "Paired" to "${peer ?: "Your devices"} will now keep your vault in step over the local network."163 PairingPhase.TIMEOUT -> "No chirp heard" to "Nothing answered. Move the devices closer and try again."164 PairingPhase.MIC_DENIED -> "Microphone needed" to "Pairing listens for an inaudible chirp. Grant mic access to continue."165 PairingPhase.VERSION_MISMATCH -> "Version mismatch" to "Update Peach on both devices, then try pairing again."166 else -> "Pairing interrupted" to (error ?: "Something went wrong. Try again.")167 }168 Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {169 Text(headline, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp, textAlign = TextAlign.Center)170 Spacer(Modifier.height(PeachSpacing.sm))171 Text(sub, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)172 }173}174175@Composable176private fun PrimaryAction(phase: PairingPhase, vm: ChirpPairViewModel) {177 val accent = LocalPeachAccent.current178 when (phase) {179 PairingPhase.IDLE -> PillButton("Start pairing", accent, filled = true) { vm.startPairing() }180 PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE ->181 PillButton("Cancel", peachColors.T2, filled = false) { vm.reset() }182 PairingPhase.PAIRED -> PillButton("Done", accent, filled = true) { vm.reset() }183 else -> PillButton("Try again", accent, filled = true) { vm.startPairing() }184 }185 if (vm.isDebug && phase == PairingPhase.IDLE) {186 Spacer(Modifier.height(PeachSpacing.sm))187 Text(188 "demo flow",189 color = peachColors.T3,190 fontFamily = PeachType.Mono,191 fontSize = 9.sp,192 modifier = Modifier.fillMaxWidth().clickable { vm.runDemo() }.padding(8.dp),193 textAlign = TextAlign.Center,194 )195 }196}197198@Composable199internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {200 val accent = LocalPeachAccent.current201 Box(202 modifier = Modifier203 .fillMaxWidth()204 .clip(RoundedCornerShape(14.dp))205 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)206 .clickable(onClick = onClick)207 .padding(vertical = PeachSpacing.md),208 contentAlignment = Alignment.Center,209 ) {210 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)211 }212}213214@Composable215private fun SasBody(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {216 Column(217 modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.md),218 horizontalAlignment = Alignment.CenterHorizontally,219 ) {220 Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)221 Spacer(Modifier.height(PeachSpacing.xs))222 Text(223 "Only continue if this code is identical on both screens. This is what proves you're talking to your own device.",224 color = peachColors.T2,225 fontFamily = PeachType.Body,226 fontSize = 13.sp,227 textAlign = TextAlign.Center,228 )229 Spacer(Modifier.height(PeachSpacing.lg))230 Text(231 text = code,232 color = accent,233 fontFamily = PeachType.Mono,234 fontSize = 38.sp,235 letterSpacing = 6.sp,236 )237 Spacer(Modifier.height(PeachSpacing.lg))238 PillButton("They match — pair", accent, filled = true, onClick = onConfirm)239 Spacer(Modifier.height(PeachSpacing.sm))240 PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)241 }242}