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.material3.Text19import androidx.compose.runtime.Composable20import androidx.compose.runtime.LaunchedEffect21import androidx.compose.runtime.getValue22import androidx.compose.ui.platform.LocalContext23import androidx.compose.ui.Alignment24import androidx.compose.ui.Modifier25import androidx.compose.ui.text.font.FontStyle26import androidx.compose.ui.text.style.TextAlign27import androidx.compose.ui.unit.dp28import androidx.compose.ui.unit.sp29import androidx.hilt.navigation.compose.hiltViewModel30import androidx.lifecycle.compose.collectAsStateWithLifecycle31import com.peachpasswords.ui.components.BirdAnimation32import com.peachpasswords.ui.components.PairingAnimState33import com.peachpasswords.ui.theme.LocalPeachAccent34import com.peachpasswords.ui.theme.PeachSpacing35import com.peachpasswords.ui.theme.PeachType36import com.peachpasswords.ui.theme.ShellBackground37import com.peachpasswords.ui.theme.peachColors3839404142434445@Composable46fun CodePairScreen(47 onBack: () -> Unit,48 vm: CodePairViewModel = hiltViewModel(),49) {50 val state by vm.state.collectAsStateWithLifecycle()51 val context = LocalContext.current5253 LaunchedEffect(Unit) { vm.start() }5455 56 57 58 59 LaunchedEffect(state) {60 if (state is CodePairUiState.Paired) {61 vm.batteryExemptionIntent()?.let { intent ->62 runCatching { context.startActivity(intent) }63 }64 }65 }6667 val animState = when (state) {68 is CodePairUiState.Paired -> PairingAnimState.CONNECTED69 is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING70 else -> PairingAnimState.WAITING71 }7273 ShellBackground {74 Column(75 modifier = Modifier76 .fillMaxSize()77 .systemBarsPadding()78 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),79 ) {80 Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {81 BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))82 }8384 when (val s = state) {85 is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")8687 is CodePairUiState.ShowingCode -> {88 Headline(89 "Type this on your computer",90 "Open Peach in your browser, choose “Pair a device”, and enter this code. " +91 "Both devices need to be on the same Wi-Fi.",92 )93 Spacer(Modifier.height(PeachSpacing.lg))94 Text(95 text = s.code,96 color = LocalPeachAccent.current,97 fontFamily = PeachType.Mono,98 fontSize = 26.sp,99 letterSpacing = 2.sp,100 textAlign = TextAlign.Center,101 modifier = Modifier.fillMaxWidth(),102 )103 104 105 106 107 Spacer(Modifier.height(PeachSpacing.md))108 Text(109 text = "The code contains this phone's address on your network (${s.ip}). " +110 "If either device is on a VPN, turn it off for a moment — the computer " +111 "won't be able to reach this address through it.",112 color = peachColors.T2,113 fontFamily = PeachType.Body,114 fontSize = 11.sp,115 textAlign = TextAlign.Center,116 modifier = Modifier.fillMaxWidth(),117 )118 }119120 is CodePairUiState.ConfirmSas -> {121 Headline(122 "Verify the code",123 "Only continue if this code is identical on both screens. " +124 "This is what proves you're talking to your own device.",125 )126 Spacer(Modifier.height(PeachSpacing.lg))127 Text(128 text = s.sas,129 color = LocalPeachAccent.current,130 fontFamily = PeachType.Mono,131 fontSize = 38.sp,132 letterSpacing = 6.sp,133 textAlign = TextAlign.Center,134 modifier = Modifier.fillMaxWidth(),135 )136 Spacer(Modifier.height(PeachSpacing.lg))137 PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }138 Spacer(Modifier.height(PeachSpacing.sm))139 PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }140 }141142 is CodePairUiState.Paired -> {143 Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")144 Spacer(Modifier.height(PeachSpacing.lg))145 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)146 }147148 is CodePairUiState.Expired -> {149 Headline(150 "That code expired",151 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",152 )153 Spacer(Modifier.height(PeachSpacing.lg))154 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }155 }156157 is CodePairUiState.Failed -> {158 Headline("Couldn't pair", failureCopy(s.reason))159 Spacer(Modifier.height(PeachSpacing.lg))160 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)161 }162 }163 Spacer(Modifier.height(PeachSpacing.md))164 }165 }166}167168169@Composable170internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {171 val accent = LocalPeachAccent.current172 Box(173 modifier = Modifier174 .fillMaxWidth()175 .clip(RoundedCornerShape(14.dp))176 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)177 .clickable(onClick = onClick)178 .padding(vertical = PeachSpacing.md),179 contentAlignment = Alignment.Center,180 ) {181 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)182 }183}184185private fun failureCopy(reason: String): String = when (reason) {186 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."187 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."188 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."189 190 191 192 193 else -> "Something interrupted the connection. Try again."194 .let { if (BuildConfig.DEBUG) "$it\n\n[$reason]" else it }195}196197@Composable198private fun Headline(title: String, body: String) {199 Column(200 modifier = Modifier.fillMaxWidth(),201 horizontalAlignment = Alignment.CenterHorizontally,202 verticalArrangement = Arrangement.Center,203 ) {204 Text(205 title,206 color = peachColors.T0,207 fontFamily = PeachType.Display,208 fontStyle = FontStyle.Italic,209 fontSize = 22.sp,210 textAlign = TextAlign.Center,211 )212 Spacer(Modifier.height(PeachSpacing.xs))213 Text(214 body,215 color = peachColors.T2,216 fontFamily = PeachType.Body,217 fontSize = 13.sp,218 textAlign = TextAlign.Center,219 )220 }221}