1package com.peachpasswords.ui.pairing23import com.peachpasswords.BuildConfig4import androidx.compose.foundation.background5import androidx.compose.foundation.clickable6import androidx.compose.foundation.shape.RoundedCornerShape7import androidx.compose.ui.draw.clip8import androidx.compose.ui.graphics.Color9import androidx.compose.foundation.layout.Arrangement10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.Column12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.systemBarsPadding18import androidx.compose.foundation.Canvas19import androidx.compose.material3.Text20import androidx.compose.runtime.Composable21import androidx.compose.runtime.LaunchedEffect22import androidx.compose.runtime.remember23import androidx.compose.runtime.getValue24import androidx.compose.ui.platform.LocalContext25import androidx.compose.ui.Alignment26import androidx.compose.ui.Modifier27import androidx.compose.ui.text.font.FontStyle28import androidx.compose.ui.text.style.TextAlign29import androidx.compose.ui.unit.dp30import androidx.compose.ui.unit.sp31import androidx.compose.ui.geometry.Offset32import androidx.compose.ui.geometry.Size33import androidx.compose.ui.platform.LocalClipboardManager34import androidx.compose.ui.text.AnnotatedString35import androidx.hilt.navigation.compose.hiltViewModel36import androidx.lifecycle.compose.collectAsStateWithLifecycle37import com.peachpasswords.ui.components.BirdAnimation38import com.peachpasswords.ui.components.PairingAnimState39import com.peachpasswords.ui.theme.LocalPeachAccent40import com.peachpasswords.ui.theme.PeachSpacing41import com.peachpasswords.ui.theme.PeachType42import com.peachpasswords.ui.theme.ShellBackground43import com.peachpasswords.ui.theme.peachColors44import com.google.zxing.BarcodeFormat45import com.google.zxing.qrcode.QRCodeWriter4647484950515253@Composable54fun CodePairScreen(55 onBack: () -> Unit,56 vm: CodePairViewModel = hiltViewModel(),57) {58 val state by vm.state.collectAsStateWithLifecycle()59 val context = LocalContext.current6061 LaunchedEffect(Unit) { vm.start() }6263 64 65 66 67 LaunchedEffect(state) {68 if (state is CodePairUiState.Paired) {69 vm.batteryExemptionIntent()?.let { intent ->70 runCatching { context.startActivity(intent) }71 }72 }73 }7475 val animState = when (state) {76 is CodePairUiState.Paired -> PairingAnimState.CONNECTED77 is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING78 else -> PairingAnimState.WAITING79 }8081 ShellBackground {82 Column(83 modifier = Modifier84 .fillMaxSize()85 .systemBarsPadding()86 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),87 ) {88 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {89 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))90 }9192 when (val s = state) {93 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")9495 is CodePairUiState.ShowingInvitation -> {96 val clipboard = LocalClipboardManager.current97 Headline(98 "Scan this in Peach",99 "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",100 )101 Spacer(Modifier.height(PeachSpacing.md))102 PairingQrCode(103 value = s.invitation,104 modifier = Modifier105 .fillMaxWidth()106 .height(260.dp),107 )108 Spacer(Modifier.height(PeachSpacing.md))109 PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {110 clipboard.setText(AnnotatedString(s.invitation))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 through the secure relay.")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}161162@Composable163private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {164 val matrix = remember(value) {165 QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)166 }167 Canvas(modifier = modifier) {168 val side = minOf(size.width, size.height)169 val module = side / matrix.width170 val qrSize = module * matrix.width171 val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)172 drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))173 for (y in 0 until matrix.height) {174 for (x in 0 until matrix.width) {175 if (matrix[x, y]) {176 drawRect(177 Color.Black,178 topLeft = Offset(origin.x + x * module, origin.y + y * module),179 size = Size(module + 0.5f, module + 0.5f),180 )181 }182 }183 }184 }185}186187188@Composable189internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {190 val accent = LocalPeachAccent.current191 Box(192 modifier = Modifier193 .fillMaxWidth()194 .clip(RoundedCornerShape(14.dp))195 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)196 .clickable(onClick = onClick)197 .padding(vertical = PeachSpacing.md),198 contentAlignment = Alignment.Center,199 ) {200 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)201 }202}203204private fun failureCopy(reason: String): String = when (reason) {205 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."206 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."207 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."208 209 210 211 212 else -> "Something interrupted the connection. Try again."213 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }214}215216@Composable217private fun Headline(title: String, body: String) {218 Column(219 modifier = Modifier.fillMaxWidth(),220 horizontalAlignment = Alignment.CenterHorizontally,221 verticalArrangement = Arrangement.Center,222 ) {223 Text(224 title,225 color = peachColors.T0,226 fontFamily = PeachType.Display,227 fontStyle = FontStyle.Italic,228 fontSize = 22.sp,229 textAlign = TextAlign.Center,230 )231 Spacer(Modifier.height(PeachSpacing.xs))232 Text(233 body,234 color = peachColors.T2,235 fontFamily = PeachType.Body,236 fontSize = 13.sp,237 textAlign = TextAlign.Center,238 )239 }240}