1package com.peachpasswords.ui.lock23import androidx.compose.animation.core.Animatable4import androidx.compose.animation.core.keyframes5import androidx.compose.foundation.background6import androidx.compose.foundation.border7import androidx.compose.foundation.clickable8import androidx.compose.foundation.interaction.MutableInteractionSource9import androidx.compose.foundation.interaction.collectIsFocusedAsState10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.ExperimentalLayoutApi14import androidx.compose.foundation.layout.FlowRow15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.Spacer17import androidx.compose.foundation.layout.fillMaxHeight18import androidx.compose.foundation.layout.fillMaxSize19import androidx.compose.foundation.layout.offset20import androidx.compose.foundation.layout.fillMaxWidth21import androidx.compose.foundation.layout.height22import androidx.compose.foundation.layout.padding23import androidx.compose.foundation.layout.systemBarsPadding24import androidx.compose.foundation.layout.widthIn25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.foundation.text.KeyboardActions28import androidx.compose.foundation.text.KeyboardOptions29import androidx.compose.material3.Text30import androidx.compose.runtime.Composable31import androidx.compose.runtime.LaunchedEffect32import androidx.compose.runtime.getValue33import androidx.compose.runtime.mutableStateOf34import androidx.compose.runtime.remember35import androidx.compose.runtime.setValue36import androidx.compose.ui.Alignment37import androidx.compose.ui.Modifier38import androidx.compose.ui.draw.alpha39import androidx.compose.ui.draw.clip40import androidx.compose.ui.draw.drawBehind41import androidx.compose.ui.geometry.Offset42import androidx.compose.ui.geometry.Size43import androidx.compose.ui.graphics.Color44import androidx.compose.ui.graphics.SolidColor45import androidx.compose.ui.text.TextStyle46import androidx.compose.ui.text.input.ImeAction47import androidx.compose.ui.text.input.KeyboardType48import androidx.compose.ui.text.input.PasswordVisualTransformation49import androidx.compose.ui.text.input.VisualTransformation50import androidx.compose.ui.text.style.TextAlign51import androidx.compose.ui.unit.IntOffset52import androidx.compose.ui.unit.dp53import androidx.compose.ui.unit.em54import androidx.compose.ui.unit.sp55import androidx.hilt.navigation.compose.hiltViewModel56import androidx.compose.runtime.collectAsState57import androidx.compose.ui.platform.LocalContext58import androidx.fragment.app.FragmentActivity59import com.peachpasswords.ui.PeachNav60import com.peachpasswords.ui.components.PeachMark61import com.peachpasswords.ui.components.PeachWordmark62import com.peachpasswords.ui.components.RuneGridBackground63import com.peachpasswords.ui.theme.LocalPeachAccent64import com.peachpasswords.ui.theme.PeachSpacing65import com.peachpasswords.ui.theme.peachColors66import com.peachpasswords.ui.security.StrengthBar67import com.peachpasswords.ui.security.scorePassword68import com.peachpasswords.ui.security.strengthColor69import com.peachpasswords.ui.security.strengthLabel70import com.peachpasswords.ui.theme.PeachType71import com.peachpasswords.ui.theme.ShellBackground7273747576777879808182internal sealed class UnlockRoute {83 84 data object RecoverySetup : UnlockRoute()85 86 data object Defer : UnlockRoute()87 88 data class PostUnlock(val destination: PeachNav) : UnlockRoute()89}90919293949596979899100internal fun routeOnUnlock(101 state: LockUiState,102 autofillPending: Boolean,103 shortcutPending: Boolean = false,104 postUnlock: () -> PeachNav,105): UnlockRoute = when {106 state.created -> UnlockRoute.RecoverySetup107 autofillPending || shortcutPending -> UnlockRoute.Defer108 else -> UnlockRoute.PostUnlock(postUnlock())109}110111@Composable112fun LockScreen(113 onUnlocked: (PeachNav) -> Unit,114 onNavigate: (PeachNav) -> Unit,115 116117118119120121122123124 autofillPending: Boolean = false,125 126 shortcutPending: Boolean = false,127 vm: LockViewModel = hiltViewModel(),128) {129 val state by vm.state.collectAsState()130 val accent = LocalPeachAccent.current131 val context = LocalContext.current132 val activity = context as? FragmentActivity133 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }134135 var password by remember { mutableStateOf("") }136 var confirm by remember { mutableStateOf("") }137 var reveal by remember { mutableStateOf(false) }138 var pinMode by remember(state.pinEnabled, state.pinPreferred) { mutableStateOf(state.pinPreferred) }139 val createScore = remember(password) { scorePassword(password) }140 141 142 143 val createBlocker = if (state.hasVault || password.isEmpty()) null144 else createValidationError(password, confirm, createScore)145146 147 148 149 150 151 152 LaunchedEffect(state.unlocked) {153 if (!state.unlocked) return@LaunchedEffect154 password = ""155 confirm = ""156 when (val route = routeOnUnlock(state, autofillPending, shortcutPending, vm::postUnlockDestination)) {157 UnlockRoute.RecoverySetup -> onNavigate(PeachNav.RecoverySetup)158 UnlockRoute.Defer -> Unit159 is UnlockRoute.PostUnlock -> onUnlocked(route.destination)160 }161 }162163 164 LaunchedEffect(canBiometric) {165 if (canBiometric && activity != null && !state.unlocked) {166 kotlinx.coroutines.delay(300)167 vm.biometricUnlock(activity)168 }169 }170171 172 val shake = remember { Animatable(0f) }173 LaunchedEffect(state.shakeNonce) {174 if (state.shakeNonce > 0) {175 shake.animateTo(176 targetValue = 0f,177 animationSpec = keyframes {178 durationMillis = 400179 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400180 },181 )182 }183 }184185 ShellBackground {186 RuneGridBackground(modifier = Modifier.fillMaxSize())187188 PeachWordmark(189 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),190 isPro = state.isPro,191 )192193 Column(194 modifier = Modifier195 .align(Alignment.Center)196 .fillMaxHeight()197 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)198 .fillMaxWidth()199 .systemBarsPadding()200 .padding(horizontal = 28.dp),201 verticalArrangement = Arrangement.Center,202 horizontalAlignment = Alignment.CenterHorizontally,203 ) {204 205 PeachMark(size = 80.dp)206 Spacer(Modifier.height(18.dp))207 Text(208 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",209 color = peachColors.T2,210 style = PeachType.lockEyebrow,211 )212 Spacer(Modifier.height(10.dp))213 Text(214 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",215 color = peachColors.T0,216 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,217 textAlign = TextAlign.Center,218 )219 Spacer(Modifier.height(24.dp))220221 222 GlassPanel(223 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },224 ) {225 Row(verticalAlignment = Alignment.CenterVertically) {226 FieldLabel(227 when {228 state.hasVault && pinMode -> "PIN"229 state.hasVault -> "master password"230 else -> "create a master password"231 },232 )233 if (!state.hasVault) {234 Spacer(Modifier.weight(1f))235 Text(236 text = if (reveal) "hide" else "show",237 color = accent,238 style = PeachType.labelMono.copy(fontSize = 11.sp),239 modifier = Modifier240 .clip(RoundedCornerShape(6.dp))241 .clickable { reveal = !reveal }242 .padding(horizontal = 6.dp, vertical = 4.dp),243 )244 }245 }246 Spacer(Modifier.height(4.dp))247 UnderlineField(248 value = password,249 onChange = { password = it; vm.clearError() },250 placeholder = if (pinMode) "••••" else "••••••••••",251 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),252 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,253 onImeAction = {254 if (state.hasVault) {255 if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)256 }257 },258 keyboardType = if (pinMode) KeyboardType.NumberPassword else KeyboardType.Password,259 masked = state.hasVault || !reveal,260 )261262 if (!state.hasVault) {263 Spacer(Modifier.height(16.dp))264 FieldLabel("confirm password")265 Spacer(Modifier.height(4.dp))266 UnderlineField(267 value = confirm,268 onChange = { confirm = it; vm.clearError() },269 placeholder = "••••••••••",270 textStyle = mono18Spaced(),271 imeAction = ImeAction.Go,272 onImeAction = { vm.create(password, confirm) },273 masked = !reveal,274 )275276 Spacer(Modifier.height(14.dp))277 if (password.isEmpty()) {278 Text(279 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",280 color = peachColors.T2,281 style = PeachType.hint,282 textAlign = TextAlign.Center,283 )284 } else {285 StrengthBar(score = createScore)286 Spacer(Modifier.height(6.dp))287 Text(288 text = "strength: ${strengthLabel(createScore)}",289 color = strengthColor(createScore),290 style = PeachType.labelMono,291 modifier = Modifier.fillMaxWidth(),292 textAlign = TextAlign.Center,293 )294 }295 }296297 Text(298 text = state.error ?: " ",299 color = peachColors.Breach,300 style = PeachType.labelMono.copy(fontSize = 11.sp),301 textAlign = TextAlign.Center,302 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),303 )304305 Spacer(Modifier.height(4.dp))306 AccentButton(307 label = when {308 state.hasVault -> "unlock"309 state.loading -> "creating..."310 createBlocker != null -> createBlocker311 else -> "create vault"312 },313 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),314 onClick = {315 if (state.hasVault) {316 if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)317 } else {318 vm.create(password, confirm)319 }320 },321 )322323 if (state.hasVault && state.pinEnabled) {324 Spacer(Modifier.height(8.dp))325 Text(326 text = if (pinMode) "Use master password" else "Use PIN",327 color = accent,328 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),329 textAlign = TextAlign.Center,330 modifier = Modifier.fillMaxWidth()331 .clip(RoundedCornerShape(8.dp))332 .clickable { password = ""; pinMode = !pinMode }333 .padding(vertical = 8.dp),334 )335 }336337 if (state.hasVault && canBiometric && activity != null) {338 Spacer(Modifier.height(12.dp))339 Text(340 text = "Use biometrics",341 color = accent,342 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),343 textAlign = TextAlign.Center,344 modifier = Modifier.fillMaxWidth()345 .clip(RoundedCornerShape(8.dp))346 .clickable { vm.biometricUnlock(activity) }347 .padding(vertical = 8.dp),348 )349 }350351 if (state.hasVault) {352 Spacer(Modifier.height(8.dp))353 Text(354 text = "Forgot your password?",355 color = peachColors.T2,356 style = PeachType.labelMono.copy(fontSize = 11.sp),357 textAlign = TextAlign.Center,358 modifier = Modifier.fillMaxWidth()359 .clip(RoundedCornerShape(8.dp))360 .clickable { onNavigate(PeachNav.RecoveryEntry) }361 .padding(vertical = 8.dp),362 )363 }364 }365366 if (!state.hasVault) {367 Spacer(Modifier.height(24.dp))368 RestoreTabs(onNavigate = onNavigate)369 }370 }371 }372}373374@Composable375private fun FieldLabel(text: String) {376 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))377}378379380private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)381382@Composable383private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {384 Column(385 modifier386 .fillMaxWidth()387 .clip(RoundedCornerShape(18.dp))388 .background(Color(0xE0131109))389 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))390 .padding(20.dp),391 content = content,392 )393}394395@Composable396private fun UnderlineField(397 value: String,398 onChange: (String) -> Unit,399 placeholder: String,400 textStyle: TextStyle,401 imeAction: ImeAction,402 onImeAction: () -> Unit,403 keyboardType: KeyboardType = KeyboardType.Password,404 masked: Boolean = true,405) {406 val accent = LocalPeachAccent.current407 val interaction = remember { MutableInteractionSource() }408 val focused by interaction.collectIsFocusedAsState()409 val hairline = peachColors.T3410 BasicTextField(411 value = value,412 onValueChange = onChange,413 singleLine = true,414 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),415 cursorBrush = SolidColor(accent),416 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,417 keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),418 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),419 interactionSource = interaction,420 modifier = Modifier421 .fillMaxWidth()422 .drawBehind {423 drawRect(424 color = if (focused) accent else hairline,425 topLeft = Offset(0f, size.height - 1.dp.toPx()),426 size = Size(size.width, 1.dp.toPx()),427 )428 }429 .padding(vertical = 8.dp),430 decorationBox = { inner ->431 Box(contentAlignment = Alignment.Center) {432 if (value.isEmpty()) {433 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))434 }435 inner()436 }437 },438 )439}440441@Composable442private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {443 val accent = LocalPeachAccent.current444 Box(445 modifier = Modifier446 .fillMaxWidth()447 .alpha(if (enabled) 1f else 0.5f)448 .clip(RoundedCornerShape(10.dp))449 .background(accent)450 .clickable(enabled = enabled, onClick = onClick)451 .padding(vertical = 13.dp),452 contentAlignment = Alignment.Center,453 ) {454 Text(455 label,456 color = Color.White,457 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),458 )459 }460}461462@OptIn(ExperimentalLayoutApi::class)463@Composable464private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {465 val tabs = listOf(466 "PeachScript" to PeachNav.Import,467 "CSV" to PeachNav.Import,468 "Codex" to PeachNav.CodexRestore,469 )470 Column(horizontalAlignment = Alignment.CenterHorizontally) {471 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)472 Spacer(Modifier.height(10.dp))473 474 475 476 477 478 FlowRow(479 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),480 verticalArrangement = Arrangement.spacedBy(6.dp),481 ) {482 tabs.forEach { (label, dest) ->483 Box(484 modifier = Modifier485 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))486 .clickable { onNavigate(dest) }487 .padding(horizontal = 10.dp, vertical = 8.dp),488 ) {489 Text(490 label,491 color = peachColors.T1,492 style = PeachType.sortPill,493 maxLines = 1,494 softWrap = false,495 )496 }497 }498 }499 }500}