package com.peachpasswords.ui.pairing import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.peachpasswords.ui.security.StrengthBar import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.ui.security.strengthLabel import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.ui.theme.peachColors @Composable fun AdoptVaultScreen( onBack: () -> Unit, onComplete: () -> Unit, vm: AdoptVaultViewModel = hiltViewModel(), ) { val state by vm.state.collectAsStateWithLifecycle() var password by remember { mutableStateOf("") } var confirmation by remember { mutableStateOf("") } var reveal by remember { mutableStateOf(false) } var invitation by remember { mutableStateOf("") } val score = scorePassword(password) val passwordReady = password.length >= 12 && password == confirmation && score >= 2 ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .verticalScroll(rememberScrollState()) .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( if (vm.joining) "Join a vault" else "I already have Peach", color = peachColors.T0, fontFamily = PeachType.Display, fontSize = 26.sp, ) Spacer(Modifier.height(PeachSpacing.sm)) when (val current = state) { AdoptVaultUiState.Disclosure -> { Body( "This device already has a vault. Joining merges it with the one you " + "scan: nothing is deleted, and after the first sync both devices " + "hold every entry from both.", ) Spacer(Modifier.height(PeachSpacing.md)) Body( "One thing changes for good. Both devices end up sharing a single set " + "of recovery words — the ones from the vault you join. This " + "device's current recovery words will stop working, and nothing " + "will restore from them afterwards.", ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("I understand — continue", LocalPeachAccent.current, filled = true) { vm.acceptDisclosure() } } is AdoptVaultUiState.RetireWords -> { Body( "Both devices confirmed the same code. Nothing has been written yet.", ) Spacer(Modifier.height(PeachSpacing.md)) Body( "The moment you continue, this device's recovery words are retired and " + "replaced by the words below — the ones for the vault you are " + "joining. Write them down now, or continue if you already have them.", ) Spacer(Modifier.height(PeachSpacing.md)) Text( if (reveal) "hide the words" else "show the words", color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = Modifier .clickable { reveal = !reveal } .padding(PeachSpacing.sm), ) if (reveal) { Text( current.incomingWords .mapIndexed { i, w -> "${i + 1}. $w" } .joinToString(" "), color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 13.sp, lineHeight = 22.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(PeachSpacing.md)) } PillButton( if (reveal) "I've written them down" else "I already have these words", LocalPeachAccent.current, filled = true, ) { reveal = false vm.acknowledgeWordRetirement(true) } Spacer(Modifier.height(PeachSpacing.sm)) PillButton("Cancel — keep my words", peachColors.T2, filled = false) { reveal = false vm.acknowledgeWordRetirement(false) } } AdoptVaultUiState.Password -> { Body( "Choose the password this phone will use. It never leaves this device; " + "your existing device will share the vault only after both screens confirm the same code.", ) Spacer(Modifier.height(PeachSpacing.lg)) OutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("new password") }, singleLine = true, visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(PeachSpacing.sm)) OutlinedTextField( value = confirmation, onValueChange = { confirmation = it }, label = { Text("confirm password") }, singleLine = true, visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) Text( if (reveal) "hide passwords" else "show passwords", color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = Modifier .align(Alignment.End) .clickable { reveal = !reveal } .padding(PeachSpacing.sm), ) StrengthBar(score, Modifier.fillMaxWidth()) Spacer(Modifier.height(PeachSpacing.xs)) Text( "strength: ${strengthLabel(score)} · at least 12 characters", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton( "Continue", LocalPeachAccent.current, filled = true, ) { if (passwordReady && vm.acceptPassword(password, confirmation)) { password = "" confirmation = "" } } if (!passwordReady) { Spacer(Modifier.height(PeachSpacing.xs)) Text( "Continue unlocks when both passwords match and pass the strength check.", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, textAlign = TextAlign.Center, ) } } AdoptVaultUiState.Pair -> { Body( "Connecting via secure relay — works anywhere. Scan the QR shown by " + "Peach on your existing device. It contains only the relay address and a single-use token.", ) Spacer(Modifier.height(PeachSpacing.lg)) PairingQrCamera(onInvitation = vm::adopt) Spacer(Modifier.height(PeachSpacing.lg)) Text( "or enter the invitation manually", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp, ) OutlinedTextField( value = invitation, onValueChange = { invitation = it }, label = { Text("peach-pair-v1:…") }, minLines = 3, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(PeachSpacing.md)) PillButton( "Connect", LocalPeachAccent.current, filled = true, ) { vm.adopt(invitation) } } AdoptVaultUiState.Connecting -> { Body("Opening the single-use relay room and establishing an encrypted session…") } is AdoptVaultUiState.ConfirmSas -> { Body( "Only continue if this code is identical on the device sharing the vault.", ) Spacer(Modifier.height(PeachSpacing.lg)) Text( current.sas, color = LocalPeachAccent.current, fontFamily = PeachType.Mono, fontSize = 38.sp, letterSpacing = 6.sp, ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton( if (vm.joining) "They match — join vault" else "They match — adopt vault", LocalPeachAccent.current, filled = true, ) { vm.confirmSas(true) } Spacer(Modifier.height(PeachSpacing.sm)) PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false) } } AdoptVaultUiState.Adopting -> { Body( if (vm.joining) { "Joining the vault. Your entries stay exactly where they are…" } else { "Both devices confirmed. Sealing the adopted vault under this phone's password…" }, ) } is AdoptVaultUiState.Complete -> { Body( if (vm.joining) { "Joined ${current.sourceName}. Both devices now share one vault — " + "the next sync brings every entry across in both directions." } else { "${current.sourceName} shared the vault successfully. Encrypted sync is now active." }, ) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Open vault", LocalPeachAccent.current, filled = true, onClick = onComplete) } is AdoptVaultUiState.Failed -> { Body(adoptionFailureCopy(current.reason)) Spacer(Modifier.height(PeachSpacing.lg)) PillButton("Start again", LocalPeachAccent.current, filled = true) { invitation = "" vm.restart() } } } if (state !is AdoptVaultUiState.Adopting && state !is AdoptVaultUiState.Complete ) { Spacer(Modifier.height(PeachSpacing.md)) PillButton("Back", peachColors.T2, filled = false, onClick = onBack) } } } } @Composable private fun Body(value: String) { Text( value, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) } private fun adoptionFailureCopy(reason: String): String = when (reason) { "invalid_pairing_invitation" -> "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again." "ADOPT_SAS_REJECTED", "adoption_rejected" -> "The confirmation was cancelled. No vault key was accepted." "ADOPT_VAULT_ID_MISMATCH" -> "The received vault did not pass its integrity check, so Peach refused it." "join_declined_at_word_retirement" -> "Cancelled before anything changed. This device kept its own vault and its recovery words still work." "JOIN_UNRECOVERABLE_ROOT_KEY" -> "That vault's recovery words cannot be represented on this device, so Peach stopped rather than leave you unable to restore. Nothing changed." "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" -> "Unlock this device's vault first, then try joining again." else -> "The secure adoption session ended before it completed. Start again with a fresh invitation." }