ChirpPairScreen.kt

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