ChirpPairScreen.kt

10.7 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.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            // SAS is a phase, not a modal — the browser keeps the birds on97            // screen above it. Scrimming them behind a full-screen overlay made98            // the code sit in the middle of a half-lit bird.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    // Pulsing dot while the pairing is live (1.0→1.4 over 1.2s, spec Phase 7).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@Composable199private 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}