1package com.peachpasswords.ui.pairing23import androidx.compose.foundation.background4import androidx.compose.foundation.clickable5import androidx.compose.foundation.shape.RoundedCornerShape6import androidx.compose.ui.draw.clip7import androidx.compose.ui.graphics.Color8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Spacer12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.height15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.systemBarsPadding17import androidx.compose.material3.Text18import androidx.compose.runtime.Composable19import androidx.compose.runtime.LaunchedEffect20import androidx.compose.runtime.getValue21import androidx.compose.ui.platform.LocalContext22import androidx.compose.ui.Alignment23import androidx.compose.ui.Modifier24import androidx.compose.ui.text.font.FontStyle25import androidx.compose.ui.text.style.TextAlign26import androidx.compose.ui.unit.dp27import androidx.compose.ui.unit.sp28import androidx.hilt.navigation.compose.hiltViewModel29import androidx.lifecycle.compose.collectAsStateWithLifecycle30import com.peachpasswords.ui.components.BirdAnimation31import com.peachpasswords.ui.components.PairingAnimState32import com.peachpasswords.ui.theme.LocalPeachAccent33import com.peachpasswords.ui.theme.PeachSpacing34import com.peachpasswords.ui.theme.PeachType35import com.peachpasswords.ui.theme.ShellBackground36import com.peachpasswords.ui.theme.peachColors3738394041424344@Composable45fun CodePairScreen(46 onBack: () -> Unit,47 vm: CodePairViewModel = hiltViewModel(),48) {49 val state by vm.state.collectAsStateWithLifecycle()50 val context = LocalContext.current5152 LaunchedEffect(Unit) { vm.start() }5354 55 56 57 58 LaunchedEffect(state) {59 if (state is CodePairUiState.Paired) {60 vm.batteryExemptionIntent()?.let { intent ->61 runCatching { context.startActivity(intent) }62 }63 }64 }6566 val animState = when (state) {67 is CodePairUiState.Paired -> PairingAnimState.CONNECTED68 is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING69 else -> PairingAnimState.WAITING70 }7172 ShellBackground {73 Column(74 modifier = Modifier75 .fillMaxSize()76 .systemBarsPadding()77 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),78 ) {79 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {80 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))81 }8283 when (val s = state) {84 is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")8586 is CodePairUiState.ShowingCode -> {87 Headline(88 "Type this on your computer",89 "Open Peach in your browser, choose “Pair a device”, and enter this code. " +90 "Both devices need to be on the same Wi-Fi.",91 )92 Spacer(Modifier.height(PeachSpacing.lg))93 Text(94 text = s.code,95 color = LocalPeachAccent.current,96 fontFamily = PeachType.Mono,97 fontSize = 26.sp,98 letterSpacing = 2.sp,99 textAlign = TextAlign.Center,100 modifier = Modifier.fillMaxWidth(),101 )102 103 104 105 106 Spacer(Modifier.height(PeachSpacing.md))107 Text(108 text = "The code contains this phone's address on your network (${s.ip}). " +109 "If either device is on a VPN, turn it off for a moment — the computer " +110 "won't be able to reach this address through it.",111 color = peachColors.T2,112 fontFamily = PeachType.Body,113 fontSize = 11.sp,114 textAlign = TextAlign.Center,115 modifier = Modifier.fillMaxWidth(),116 )117 }118119 is CodePairUiState.ConfirmSas -> {120 Headline(121 "Verify the code",122 "Only continue if this code is identical on both screens. " +123 "This is what proves you're talking to your own device.",124 )125 Spacer(Modifier.height(PeachSpacing.lg))126 Text(127 text = s.sas,128 color = LocalPeachAccent.current,129 fontFamily = PeachType.Mono,130 fontSize = 38.sp,131 letterSpacing = 6.sp,132 textAlign = TextAlign.Center,133 modifier = Modifier.fillMaxWidth(),134 )135 Spacer(Modifier.height(PeachSpacing.lg))136 PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }137 Spacer(Modifier.height(PeachSpacing.sm))138 PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }139 }140141 is CodePairUiState.Paired -> {142 Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")143 Spacer(Modifier.height(PeachSpacing.lg))144 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)145 }146147 is CodePairUiState.Expired -> {148 Headline(149 "That code expired",150 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",151 )152 Spacer(Modifier.height(PeachSpacing.lg))153 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }154 }155156 is CodePairUiState.Failed -> {157 Headline("Couldn't pair", failureCopy(s.reason))158 Spacer(Modifier.height(PeachSpacing.lg))159 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)160 }161 }162 Spacer(Modifier.height(PeachSpacing.md))163 }164 }165}166167168@Composable169internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {170 val accent = LocalPeachAccent.current171 Box(172 modifier = Modifier173 .fillMaxWidth()174 .clip(RoundedCornerShape(14.dp))175 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)176 .clickable(onClick = onClick)177 .padding(vertical = PeachSpacing.md),178 contentAlignment = Alignment.Center,179 ) {180 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)181 }182}183184private fun failureCopy(reason: String): String = when (reason) {185 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."186 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."187 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."188 else -> "Something interrupted the connection. Try again."189}190191@Composable192private fun Headline(title: String, body: String) {193 Column(194 modifier = Modifier.fillMaxWidth(),195 horizontalAlignment = Alignment.CenterHorizontally,196 verticalArrangement = Arrangement.Center,197 ) {198 Text(199 title,200 color = peachColors.T0,201 fontFamily = PeachType.Display,202 fontStyle = FontStyle.Italic,203 fontSize = 22.sp,204 textAlign = TextAlign.Center,205 )206 Spacer(Modifier.height(PeachSpacing.xs))207 Text(208 body,209 color = peachColors.T2,210 fontFamily = PeachType.Body,211 fontSize = 13.sp,212 textAlign = TextAlign.Center,213 )214 }215}