package com.peachpasswords.ui.lock import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.keyframes import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset 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.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity import com.peachpasswords.ui.PeachNav import com.peachpasswords.ui.components.PeachMark import com.peachpasswords.ui.components.PeachWordmark import com.peachpasswords.ui.components.RuneGridBackground import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.security.StrengthBar import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.ui.security.strengthColor import com.peachpasswords.ui.security.strengthLabel import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground /** * Where the unlock transition should land. Extracted from the LaunchedEffect body so * the priority order (created > autofill > postUnlock) is unit-testable without a * Compose runtime; the LaunchedEffect is a thin dispatcher that calls * [routeOnUnlock] once per unlock transition. * * [Defer] means "let the NavHost handle it" — used when an autofill-driven navigation * is queued at the moment of unlock so we don't clobber it with popUpTo(0). */ internal sealed class UnlockRoute { /** A freshly created vault must walk through recovery-word setup before the list. */ data object RecoverySetup : UnlockRoute() /** A queued post-unlock navigation is in flight; the NavHost owns the destination. */ data object Defer : UnlockRoute() /** Ordinary unlock: route to the post-unlock destination the ViewModel computed. */ data class PostUnlock(val destination: PeachNav) : UnlockRoute() } /** * Decide what the lock screen does when [LockUiState.unlocked] flips true. The * priority order matters: a newly-created vault always goes to recovery-word setup, * an autofill-pending unlock defers to the NavHost, everything else lands on the * ViewModel-computed post-unlock destination. * * `postUnlock` is a lambda so the function is pure and the lazy destination lookup * is observable in tests (a [UnlockRoute.Defer] must never invoke it). */ internal fun routeOnUnlock( state: LockUiState, autofillPending: Boolean, shortcutPending: Boolean = false, postUnlock: () -> PeachNav, ): UnlockRoute = when { state.created -> UnlockRoute.RecoverySetup autofillPending || shortcutPending -> UnlockRoute.Defer else -> UnlockRoute.PostUnlock(postUnlock()) } @Composable fun LockScreen( onUnlocked: (PeachNav) -> Unit, onNavigate: (PeachNav) -> Unit, /** * True when an autofill-driven navigation (e.g. the "+ Save to Peach" * create chip) is queued and waiting for the vault to unlock. When set, * [onUnlocked] is skipped: the autofill nav path at the PeachAppNavHost * scope will navigate to its own destination (the new-entry editor with * URL prefilled), and we must not clobber it with the last-viewed entry. * Two LaunchedEffects firing on the same unlock would otherwise race -- * the LockScreen one wins because [onUnlocked] uses popUpTo(0) inclusive. */ autofillPending: Boolean = false, /** True when a launcher shortcut is waiting for this unlock transition. */ shortcutPending: Boolean = false, vm: LockViewModel = hiltViewModel(), ) { val state by vm.state.collectAsState() val accent = LocalPeachAccent.current val context = LocalContext.current val activity = context as? FragmentActivity val canBiometric = remember(state.hasVault) { vm.canTryBiometric() } var password by remember { mutableStateOf("") } var confirm by remember { mutableStateOf("") } var reveal by remember { mutableStateOf(false) } var pinMode by remember(state.pinEnabled, state.pinPreferred) { mutableStateOf(state.pinPreferred) } val createScore = remember(password) { scorePassword(password) } // Why the create button is disabled, shown on the button itself. Without // it the button just silently does nothing (browser parity: Lock.tsx's // createBlockerMessage). Null before typing -- no scolding on an empty form. val createBlocker = if (state.hasVault || password.isEmpty()) null else createValidationError(password, confirm, createScore) // Keyed on `state.unlocked` only: the autofill nav path in PeachAppNavHost consumes // `newEntryRequest` synchronously, which would otherwise flip `autofillPending` to // false mid-unlock and re-fire this effect — landing the user on the vault list // instead of the new-entry editor. Capturing `autofillPending` at the unlock // transition (rather than re-reading it on every key change) makes the two effects // cooperative instead of racing. LaunchedEffect(state.unlocked) { if (!state.unlocked) return@LaunchedEffect password = "" confirm = "" when (val route = routeOnUnlock(state, autofillPending, shortcutPending, vm::postUnlockDestination)) { UnlockRoute.RecoverySetup -> onNavigate(PeachNav.RecoverySetup) UnlockRoute.Defer -> Unit is UnlockRoute.PostUnlock -> onUnlocked(route.destination) } } // Auto-offer biometric unlock shortly after the lock screen appears. LaunchedEffect(canBiometric) { if (canBiometric && activity != null && !state.unlocked) { kotlinx.coroutines.delay(300) vm.biometricUnlock(activity) } } // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms). val shake = remember { Animatable(0f) } LaunchedEffect(state.shakeNonce) { if (state.shakeNonce > 0) { shake.animateTo( targetValue = 0f, animationSpec = keyframes { durationMillis = 400 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400 }, ) } } ShellBackground { RuneGridBackground(modifier = Modifier.fillMaxSize()) PeachWordmark( modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp), isPro = state.isPro, ) Column( modifier = Modifier .align(Alignment.Center) .fillMaxHeight() .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH) .fillMaxWidth() .systemBarsPadding() .padding(horizontal = 28.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { // Hero: centered PeachMark · eyebrow · headline. PeachMark(size = 80.dp) Spacer(Modifier.height(18.dp)) Text( text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT", color = peachColors.T2, style = PeachType.lockEyebrow, ) Spacer(Modifier.height(10.dp)) Text( text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.", color = peachColors.T0, style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline, textAlign = TextAlign.Center, ) Spacer(Modifier.height(24.dp)) // Glass panel holding the credential inputs. GlassPanel( modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) }, ) { Row(verticalAlignment = Alignment.CenterVertically) { FieldLabel( when { state.hasVault && pinMode -> "PIN" state.hasVault -> "master password" else -> "create a master password" }, ) if (!state.hasVault) { Spacer(Modifier.weight(1f)) Text( text = if (reveal) "hide" else "show", color = accent, style = PeachType.labelMono.copy(fontSize = 11.sp), modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable { reveal = !reveal } .padding(horizontal = 6.dp, vertical = 4.dp), ) } } Spacer(Modifier.height(4.dp)) UnderlineField( value = password, onChange = { password = it; vm.clearError() }, placeholder = if (pinMode) "••••" else "••••••••••", textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(), imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next, onImeAction = { if (state.hasVault) { if (pinMode) vm.unlockWithPin(password) else vm.unlock(password) } }, keyboardType = if (pinMode) KeyboardType.NumberPassword else KeyboardType.Password, masked = state.hasVault || !reveal, ) if (!state.hasVault) { Spacer(Modifier.height(16.dp)) FieldLabel("confirm password") Spacer(Modifier.height(4.dp)) UnderlineField( value = confirm, onChange = { confirm = it; vm.clearError() }, placeholder = "••••••••••", textStyle = mono18Spaced(), imeAction = ImeAction.Go, onImeAction = { vm.create(password, confirm) }, masked = !reveal, ) Spacer(Modifier.height(14.dp)) if (password.isEmpty()) { Text( text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.", color = peachColors.T2, style = PeachType.hint, textAlign = TextAlign.Center, ) } else { StrengthBar(score = createScore) Spacer(Modifier.height(6.dp)) Text( text = "strength: ${strengthLabel(createScore)}", color = strengthColor(createScore), style = PeachType.labelMono, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) } } Text( text = state.error ?: " ", color = peachColors.Breach, style = PeachType.labelMono.copy(fontSize = 11.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp), ) Spacer(Modifier.height(4.dp)) AccentButton( label = when { state.hasVault -> "unlock" state.loading -> "creating..." createBlocker != null -> createBlocker else -> "create vault" }, enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)), onClick = { if (state.hasVault) { if (pinMode) vm.unlockWithPin(password) else vm.unlock(password) } else { vm.create(password, confirm) } }, ) if (state.hasVault && state.pinEnabled) { Spacer(Modifier.height(8.dp)) Text( text = if (pinMode) "Use master password" else "Use PIN", color = accent, style = PeachType.labelMono.copy(letterSpacing = 0.08.em), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable { password = ""; pinMode = !pinMode } .padding(vertical = 8.dp), ) } if (state.hasVault && canBiometric && activity != null) { Spacer(Modifier.height(12.dp)) Text( text = "Use biometrics", color = accent, style = PeachType.labelMono.copy(letterSpacing = 0.08.em), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable { vm.biometricUnlock(activity) } .padding(vertical = 8.dp), ) } if (state.hasVault) { Spacer(Modifier.height(8.dp)) Text( text = "Forgot your password?", color = peachColors.T2, style = PeachType.labelMono.copy(fontSize = 11.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable { onNavigate(PeachNav.RecoveryEntry) } .padding(vertical = 8.dp), ) } } if (!state.hasVault) { Spacer(Modifier.height(24.dp)) RestoreTabs(onNavigate = onNavigate) } } } } @Composable private fun FieldLabel(text: String) { Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em)) } /** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */ private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em) @Composable private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { Column( modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(Color(0xE0131109)) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .padding(20.dp), content = content, ) } @Composable private fun UnderlineField( value: String, onChange: (String) -> Unit, placeholder: String, textStyle: TextStyle, imeAction: ImeAction, onImeAction: () -> Unit, keyboardType: KeyboardType = KeyboardType.Password, masked: Boolean = true, ) { val accent = LocalPeachAccent.current val interaction = remember { MutableInteractionSource() } val focused by interaction.collectIsFocusedAsState() val hairline = peachColors.T3 BasicTextField( value = value, onValueChange = onChange, singleLine = true, textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center), cursorBrush = SolidColor(accent), visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None, keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction), keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }), interactionSource = interaction, modifier = Modifier .fillMaxWidth() .drawBehind { drawRect( color = if (focused) accent else hairline, topLeft = Offset(0f, size.height - 1.dp.toPx()), size = Size(size.width, 1.dp.toPx()), ) } .padding(vertical = 8.dp), decorationBox = { inner -> Box(contentAlignment = Alignment.Center) { if (value.isEmpty()) { Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center)) } inner() } }, ) } @Composable private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) { val accent = LocalPeachAccent.current Box( modifier = Modifier .fillMaxWidth() .alpha(if (enabled) 1f else 0.5f) .clip(RoundedCornerShape(10.dp)) .background(accent) .clickable(enabled = enabled, onClick = onClick) .padding(vertical = 13.dp), contentAlignment = Alignment.Center, ) { Text( label, color = Color.White, style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em), ) } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) { val tabs = listOf( "PeachScript" to PeachNav.Import, "CSV" to PeachNav.Import, "Codex" to PeachNav.CodexRestore, ) Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("restore instead", color = peachColors.T1, style = PeachType.labelMono) Spacer(Modifier.height(10.dp)) // These four barely fit one line at 411dp. A plain Row has nowhere to // put the overflow, so it hands the last pill near-zero width — the // label stacks one letter per line and then disappears entirely, // leaving a bordered box with no text. Wrap instead, and never let a // label break mid-word. FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(6.dp), ) { tabs.forEach { (label, dest) -> Box( modifier = Modifier .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp)) .clickable { onNavigate(dest) } .padding(horizontal = 10.dp, vertical = 8.dp), ) { Text( label, color = peachColors.T1, style = PeachType.sortPill, maxLines = 1, softWrap = false, ) } } } } }