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.mutableStateOf23import androidx.compose.runtime.remember24import androidx.compose.runtime.setValue25import androidx.compose.runtime.getValue26import androidx.compose.ui.platform.LocalContext27import androidx.compose.ui.Alignment28import androidx.compose.ui.Modifier29import androidx.compose.ui.text.font.FontStyle30import androidx.compose.ui.text.style.TextAlign31import androidx.compose.ui.unit.dp32import androidx.compose.ui.unit.sp33import androidx.compose.ui.geometry.Offset34import androidx.compose.ui.geometry.Size35import 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 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }6263 LaunchedEffect(Unit) { vm.start() }6465 LaunchedEffect(state) {66 if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true67 }6869 if (showBackgroundSyncPrompt) {70 BackgroundSyncDialog { enabled ->71 showBackgroundSyncPrompt = false72 vm.setBackgroundSync(enabled)73 if (enabled) {74 vm.batteryExemptionIntent()?.let { intent ->75 runCatching { context.startActivity(intent) }76 }77 }78 }79 }8081 val animState = when (state) {82 is CodePairUiState.Paired -> PairingAnimState.CONNECTED83 is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING84 else -> PairingAnimState.WAITING85 }8687 ShellBackground {88 Column(89 modifier = Modifier90 .fillMaxSize()91 .systemBarsPadding()92 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),93 ) {94 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {95 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))96 }9798 when (val s = state) {99 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")100101 is CodePairUiState.ShowingInvitation -> {102 Headline(103 "Scan this in Peach",104 "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",105 )106 Spacer(Modifier.height(PeachSpacing.md))107 PairingQrCode(108 value = s.invitation,109 modifier = Modifier110 .fillMaxWidth()111 .height(260.dp),112 )113 }114115 is CodePairUiState.AwaitingPeerConfirmation -> {116 Headline(117 "Confirm on the scanning device",118 "If this code matches there, tap “They match” on that device. " +119 "Peach finishes only after both sides save the pairing.",120 )121 Spacer(Modifier.height(PeachSpacing.lg))122 Text(123 text = s.sas,124 color = LocalPeachAccent.current,125 fontFamily = PeachType.Mono,126 fontSize = 38.sp,127 letterSpacing = 6.sp,128 textAlign = TextAlign.Center,129 modifier = Modifier.fillMaxWidth(),130 )131 }132133 is CodePairUiState.Paired -> {134 Headline("Paired", "${s.deviceName} will now sync through the relay.")135 Spacer(Modifier.height(PeachSpacing.lg))136 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)137 }138139 is CodePairUiState.Expired -> {140 Headline(141 "That code expired",142 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",143 )144 Spacer(Modifier.height(PeachSpacing.lg))145 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }146 }147148 is CodePairUiState.Failed -> {149 Headline("Couldn't pair", failureCopy(s.reason))150 Spacer(Modifier.height(PeachSpacing.lg))151 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)152 }153 }154 Spacer(Modifier.height(PeachSpacing.md))155 }156 }157}158159@Composable160private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {161 val matrix = remember(value) {162 QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)163 }164 Canvas(modifier = modifier) {165 val side = minOf(size.width, size.height)166 val module = side / matrix.width167 val qrSize = module * matrix.width168 val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)169 drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))170 for (y in 0 until matrix.height) {171 for (x in 0 until matrix.width) {172 if (matrix[x, y]) {173 drawRect(174 Color.Black,175 topLeft = Offset(origin.x + x * module, origin.y + y * module),176 size = Size(module + 0.5f, module + 0.5f),177 )178 }179 }180 }181 }182}183184185@Composable186internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {187 val accent = LocalPeachAccent.current188 Box(189 modifier = Modifier190 .fillMaxWidth()191 .clip(RoundedCornerShape(14.dp))192 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)193 .clickable(onClick = onClick)194 .padding(vertical = PeachSpacing.md),195 contentAlignment = Alignment.Center,196 ) {197 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)198 }199}200201private fun failureCopy(reason: String): String = when (reason) {202 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."203 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."204 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."205 "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."206 207 208 "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."209 210 211 212 213 else -> "Something interrupted the connection. Try again."214 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }215}216217@Composable218private fun Headline(title: String, body: String) {219 Column(220 modifier = Modifier.fillMaxWidth(),221 horizontalAlignment = Alignment.CenterHorizontally,222 verticalArrangement = Arrangement.Center,223 ) {224 Text(225 title,226 color = peachColors.T0,227 fontFamily = PeachType.Display,228 fontStyle = FontStyle.Italic,229 fontSize = 22.sp,230 textAlign = TextAlign.Center,231 )232 Spacer(Modifier.height(PeachSpacing.xs))233 Text(234 body,235 color = peachColors.T2,236 fontFamily = PeachType.Body,237 fontSize = 13.sp,238 textAlign = TextAlign.Center,239 )240 }241}