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 }9899 is CodePairUiState.ConfirmSas -> {100 Headline(101 "Verify the code",102 "Only continue if this code is identical on both screens. " +103 "This is what proves you're talking to your own device.",104 )105 Spacer(Modifier.height(PeachSpacing.lg))106 Text(107 text = s.sas,108 color = LocalPeachAccent.current,109 fontFamily = PeachType.Mono,110 fontSize = 38.sp,111 letterSpacing = 6.sp,112 textAlign = TextAlign.Center,113 modifier = Modifier.fillMaxWidth(),114 )115 Spacer(Modifier.height(PeachSpacing.lg))116 PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }117 Spacer(Modifier.height(PeachSpacing.sm))118 PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }119 }120121 is CodePairUiState.Paired -> {122 Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")123 Spacer(Modifier.height(PeachSpacing.lg))124 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)125 }126127 is CodePairUiState.Expired -> {128 Headline(129 "That code expired",130 "Codes are good for five minutes so one doesn't stay live on a phone in your pocket. Nothing was lost.",131 )132 Spacer(Modifier.height(PeachSpacing.lg))133 PillButton("Show a new code", LocalPeachAccent.current, filled = true) { vm.restart() }134 }135136 is CodePairUiState.Failed -> {137 Headline("Couldn't pair", failureCopy(s.reason))138 Spacer(Modifier.height(PeachSpacing.lg))139 PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)140 }141 }142 Spacer(Modifier.height(PeachSpacing.md))143 }144 }145}146147private fun failureCopy(reason: String): String = when (reason) {148 "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."149 "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."150 "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."151 else -> "Something interrupted the connection. Try again."152}153154@Composable155private fun Headline(title: String, body: String) {156 Column(157 modifier = Modifier.fillMaxWidth(),158 horizontalAlignment = Alignment.CenterHorizontally,159 verticalArrangement = Arrangement.Center,160 ) {161 Text(162 title,163 color = peachColors.T0,164 fontFamily = PeachType.Display,165 fontStyle = FontStyle.Italic,166 fontSize = 22.sp,167 textAlign = TextAlign.Center,168 )169 Spacer(Modifier.height(PeachSpacing.xs))170 Text(171 body,172 color = peachColors.T2,173 fontFamily = PeachType.Body,174 fontSize = 13.sp,175 textAlign = TextAlign.Center,176 )177 }178}