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.peachColors3233343536373839@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 50 51 52 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 98 99 100 101 Spacer(Modifier.height(PeachSpacing.md))102 Text(103 text = "The code contains this phone's address on your network (${s.ip}). " +104 "If either device is on a VPN, turn it off for a moment — the computer " +105 "won't be able to reach this address through it.",106 color = peachColors.T2,107 fontFamily = PeachType.Body,108 fontSize = 11.sp,109 textAlign = TextAlign.Center,110 modifier = Modifier.fillMaxWidth(),111 )112 }113114 is CodePairUiState.ConfirmSas -> {115 Headline(116 "Verify the code",117 "Only continue if this code is identical on both screens. " +118 "This is what proves you're talking to your own device.",119 )120 Spacer(Modifier.height(PeachSpacing.lg))121 Text(122 text = s.sas,123 color = LocalPeachAccent.current,124 fontFamily = PeachType.Mono,125 fontSize = 38.sp,126 letterSpacing = 6.sp,127 textAlign = TextAlign.Center,128 modifier = Modifier.fillMaxWidth(),129 )130 Spacer(Modifier.height(PeachSpacing.lg))131 PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }132 Spacer(Modifier.height(PeachSpacing.sm))133 PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }134 }135136 is CodePairUiState.Paired -> {137 Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")138 Spacer(Modifier.height(PeachSpacing.lg))139 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)140 }141142 is CodePairUiState.Expired -> {143 Headline(144 "That code expired",145 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",146 )147 Spacer(Modifier.height(PeachSpacing.lg))148 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }149 }150151 is CodePairUiState.Failed -> {152 Headline("Couldn't pair", failureCopy(s.reason))153 Spacer(Modifier.height(PeachSpacing.lg))154 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)155 }156 }157 Spacer(Modifier.height(PeachSpacing.md))158 }159 }160}161162private fun failureCopy(reason: String): String = when (reason) {163 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."164 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."165 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."166 else -> "Something interrupted the connection. Try again."167}168169@Composable170private fun Headline(title: String, body: String) {171 Column(172 modifier = Modifier.fillMaxWidth(),173 horizontalAlignment = Alignment.CenterHorizontally,174 verticalArrangement = Arrangement.Center,175 ) {176 Text(177 title,178 color = peachColors.T0,179 fontFamily = PeachType.Display,180 fontStyle = FontStyle.Italic,181 fontSize = 22.sp,182 textAlign = TextAlign.Center,183 )184 Spacer(Modifier.height(PeachSpacing.xs))185 Text(186 body,187 color = peachColors.T2,188 fontFamily = PeachType.Body,189 fontSize = 13.sp,190 textAlign = TextAlign.Center,191 )192 }193}