ChirpPairScreen.kt

10.2 KB · raw

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            // Header60            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    // Pulsing dot while the pairing is live (1.0→1.4 over 1.2s, spec Phase 7).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}