CodePairScreen.kt

7.4 KB · raw

1package com.peachpasswords.ui.pairing23import androidx.compose.foundation.layout.Arrangement4import androidx.compose.foundation.layout.Box5import androidx.compose.foundation.layout.Column6import androidx.compose.foundation.layout.Spacer7import androidx.compose.foundation.layout.fillMaxSize8import androidx.compose.foundation.layout.fillMaxWidth9import androidx.compose.foundation.layout.height10import androidx.compose.foundation.layout.padding11import androidx.compose.foundation.layout.systemBarsPadding12import androidx.compose.material3.Text13import androidx.compose.runtime.Composable14import androidx.compose.runtime.LaunchedEffect15import androidx.compose.runtime.getValue16import androidx.compose.ui.platform.LocalContext17import androidx.compose.ui.Alignment18import androidx.compose.ui.Modifier19import androidx.compose.ui.text.font.FontStyle20import androidx.compose.ui.text.style.TextAlign21import androidx.compose.ui.unit.dp22import androidx.compose.ui.unit.sp23import androidx.hilt.navigation.compose.hiltViewModel24import androidx.lifecycle.compose.collectAsStateWithLifecycle25import com.peachpasswords.ui.components.BirdAnimation26import com.peachpasswords.ui.components.PairingAnimState27import com.peachpasswords.ui.theme.LocalPeachAccent28import com.peachpasswords.ui.theme.PeachSpacing29import com.peachpasswords.ui.theme.PeachType30import com.peachpasswords.ui.theme.ShellBackground31import com.peachpasswords.ui.theme.peachColors3233/**34 * Pair a device by code. The phone shows the code; the browser types it in and35 * dials. Mirrors the extension's own phase structure (enter → connecting → sas36 * → paired), minus the entry field — the phone never types a code, it publishes37 * one.38 */39@Composable40fun CodePairScreen(41    onBack: () -> Unit,42    vm: CodePairViewModel = hiltViewModel(),43) {44    val state by vm.state.collectAsStateWithLifecycle()45    val context = LocalContext.current4647    LaunchedEffect(Unit) { vm.start() }4849    // Asked after a successful pairing, not before: at that point the user has50    // just chosen to rely on this phone staying reachable, so the system's51    // "run in the background?" dialog has obvious context. Asking on arrival52    // would be a scary prompt for a feature they hadn't committed to yet.53    LaunchedEffect(state) {54        if (state is CodePairUiState.Paired) {55            vm.batteryExemptionIntent()?.let { intent ->56                runCatching { context.startActivity(intent) }57            }58        }59    }6061    val animState = when (state) {62        is CodePairUiState.Paired -> PairingAnimState.CONNECTED63        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING64        else -> PairingAnimState.WAITING65    }6667    ShellBackground {68        Column(69            modifier = Modifier70                .fillMaxSize()71                .systemBarsPadding()72                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),73        ) {74            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {75                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))76            }7778            when (val s = state) {79                is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")8081                is CodePairUiState.ShowingCode -> {82                    Headline(83                        "Type this on your computer",84                        "Open Peach in your browser, choose “Pair a device”, and enter this code. " +85                            "Both devices need to be on the same Wi-Fi.",86                    )87                    Spacer(Modifier.height(PeachSpacing.lg))88                    Text(89                        text = s.code,90                        color = LocalPeachAccent.current,91                        fontFamily = PeachType.Mono,92                        fontSize = 26.sp,93                        letterSpacing = 2.sp,94                        textAlign = TextAlign.Center,95                        modifier = Modifier.fillMaxWidth(),96                    )97                }9899                is CodePairUiState.ConfirmSas -> {100                    Headline(101                        "Verify the code",102                        "Only continue if this code is identical on both screens. " +103                            "This is what proves you're talking to your own device.",104                    )105                    Spacer(Modifier.height(PeachSpacing.lg))106                    Text(107                        text = s.sas,108                        color = LocalPeachAccent.current,109                        fontFamily = PeachType.Mono,110                        fontSize = 38.sp,111                        letterSpacing = 6.sp,112                        textAlign = TextAlign.Center,113                        modifier = Modifier.fillMaxWidth(),114                    )115                    Spacer(Modifier.height(PeachSpacing.lg))116                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }117                    Spacer(Modifier.height(PeachSpacing.sm))118                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }119                }120121                is CodePairUiState.Paired -> {122                    Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")123                    Spacer(Modifier.height(PeachSpacing.lg))124                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)125                }126127                is CodePairUiState.Expired -> {128                    Headline(129                        "That code expired",130                        "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",131                    )132                    Spacer(Modifier.height(PeachSpacing.lg))133                    PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }134                }135136                is CodePairUiState.Failed -> {137                    Headline("Couldn't pair", failureCopy(s.reason))138                    Spacer(Modifier.height(PeachSpacing.lg))139                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)140                }141            }142            Spacer(Modifier.height(PeachSpacing.md))143        }144    }145}146147private fun failureCopy(reason: String): String = when (reason) {148    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."149    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."150    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."151    else -> "Something interrupted the connection. Try again."152}153154@Composable155private fun Headline(title: String, body: String) {156    Column(157        modifier = Modifier.fillMaxWidth(),158        horizontalAlignment = Alignment.CenterHorizontally,159        verticalArrangement = Arrangement.Center,160    ) {161        Text(162            title,163            color = peachColors.T0,164            fontFamily = PeachType.Display,165            fontStyle = FontStyle.Italic,166            fontSize = 22.sp,167            textAlign = TextAlign.Center,168        )169        Spacer(Modifier.height(PeachSpacing.xs))170        Text(171            body,172            color = peachColors.T2,173            fontFamily = PeachType.Body,174            fontSize = 13.sp,175            textAlign = TextAlign.Center,176        )177    }178}