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.LockRuneString61import com.peachpasswords.ui.components.PeachMark62import com.peachpasswords.ui.components.PeachWordmark63import 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.ShellBackground7273@Composable74fun LockScreen(75 onUnlocked: (PeachNav) -> Unit,76 onNavigate: (PeachNav) -> Unit,77 787980818283848586 autofillPending: Boolean = false,87 vm: LockViewModel = hiltViewModel(),88) {89 val state by vm.state.collectAsState()90 val accent = LocalPeachAccent.current91 val context = LocalContext.current92 val activity = context as? FragmentActivity93 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }9495 var password by remember { mutableStateOf("") }96 var confirm by remember { mutableStateOf("") }97 var reveal by remember { mutableStateOf(false) }98 val createScore = remember(password) { scorePassword(password) }99 100 101 102 val createBlocker = if (state.hasVault || password.isEmpty()) null103 else createValidationError(password, confirm, createScore)104105 106 107 108 109 110 111 LaunchedEffect(state.unlocked) {112 if (state.unlocked) {113 114 115 116 117 if (state.created) onNavigate(PeachNav.RecoverySetup)118 else if (autofillPending) Unit119 else onUnlocked(vm.postUnlockDestination())120 }121 }122123 124 LaunchedEffect(canBiometric) {125 if (canBiometric && activity != null && !state.unlocked) {126 kotlinx.coroutines.delay(300)127 vm.biometricUnlock(activity)128 }129 }130131 132 val shake = remember { Animatable(0f) }133 LaunchedEffect(state.shakeNonce) {134 if (state.shakeNonce > 0) {135 shake.animateTo(136 targetValue = 0f,137 animationSpec = keyframes {138 durationMillis = 400139 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400140 },141 )142 }143 }144145 ShellBackground {146 PeachWordmark(147 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),148 )149150 Column(151 modifier = Modifier152 .align(Alignment.Center)153 .fillMaxHeight()154 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)155 .fillMaxWidth()156 .systemBarsPadding()157 .padding(horizontal = 28.dp),158 verticalArrangement = Arrangement.Center,159 horizontalAlignment = Alignment.CenterHorizontally,160 ) {161 162 PeachMark(size = 80.dp)163 Spacer(Modifier.height(18.dp))164 Text(165 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",166 color = peachColors.T2,167 style = PeachType.lockEyebrow,168 )169 Spacer(Modifier.height(10.dp))170 Text(171 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",172 color = peachColors.T0,173 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,174 textAlign = TextAlign.Center,175 )176 Spacer(Modifier.height(14.dp))177 LockRuneString()178 Spacer(Modifier.height(24.dp))179180 181 GlassPanel(182 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },183 ) {184 Row(verticalAlignment = Alignment.CenterVertically) {185 FieldLabel(if (state.hasVault) "master password" else "create a master password")186 if (!state.hasVault) {187 Spacer(Modifier.weight(1f))188 Text(189 text = if (reveal) "hide" else "show",190 color = accent,191 style = PeachType.labelMono.copy(fontSize = 11.sp),192 modifier = Modifier193 .clip(RoundedCornerShape(6.dp))194 .clickable { reveal = !reveal }195 .padding(horizontal = 6.dp, vertical = 4.dp),196 )197 }198 }199 Spacer(Modifier.height(4.dp))200 UnderlineField(201 value = password,202 onChange = { password = it; vm.clearError() },203 placeholder = "••••••••••",204 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),205 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,206 onImeAction = { if (state.hasVault) vm.unlock(password) },207 masked = state.hasVault || !reveal,208 )209210 if (!state.hasVault) {211 Spacer(Modifier.height(16.dp))212 FieldLabel("confirm password")213 Spacer(Modifier.height(4.dp))214 UnderlineField(215 value = confirm,216 onChange = { confirm = it; vm.clearError() },217 placeholder = "••••••••••",218 textStyle = mono18Spaced(),219 imeAction = ImeAction.Go,220 onImeAction = { vm.create(password, confirm) },221 masked = !reveal,222 )223224 Spacer(Modifier.height(14.dp))225 if (password.isEmpty()) {226 Text(227 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",228 color = peachColors.T2,229 style = PeachType.hint,230 textAlign = TextAlign.Center,231 )232 } else {233 StrengthBar(score = createScore)234 Spacer(Modifier.height(6.dp))235 Text(236 text = "strength: ${strengthLabel(createScore)}",237 color = strengthColor(createScore),238 style = PeachType.labelMono,239 modifier = Modifier.fillMaxWidth(),240 textAlign = TextAlign.Center,241 )242 }243 }244245 Text(246 text = state.error ?: " ",247 color = peachColors.Breach,248 style = PeachType.labelMono.copy(fontSize = 11.sp),249 textAlign = TextAlign.Center,250 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),251 )252253 Spacer(Modifier.height(4.dp))254 AccentButton(255 label = when {256 state.hasVault -> "unlock"257 state.loading -> "creating..."258 createBlocker != null -> createBlocker259 else -> "create vault"260 },261 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),262 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },263 )264265 if (state.hasVault && canBiometric && activity != null) {266 Spacer(Modifier.height(12.dp))267 Text(268 text = "Use biometrics",269 color = accent,270 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),271 textAlign = TextAlign.Center,272 modifier = Modifier.fillMaxWidth()273 .clip(RoundedCornerShape(8.dp))274 .clickable { vm.biometricUnlock(activity) }275 .padding(vertical = 8.dp),276 )277 }278279 if (state.hasVault) {280 Spacer(Modifier.height(8.dp))281 Text(282 text = "Forgot your password?",283 color = peachColors.T2,284 style = PeachType.labelMono.copy(fontSize = 11.sp),285 textAlign = TextAlign.Center,286 modifier = Modifier.fillMaxWidth()287 .clip(RoundedCornerShape(8.dp))288 .clickable { onNavigate(PeachNav.RecoveryEntry) }289 .padding(vertical = 8.dp),290 )291 }292 }293294 if (!state.hasVault) {295 Spacer(Modifier.height(24.dp))296 RestoreTabs(onNavigate = onNavigate)297 }298 }299 }300}301302@Composable303private fun FieldLabel(text: String) {304 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))305}306307308private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)309310@Composable311private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {312 Column(313 modifier314 .fillMaxWidth()315 .clip(RoundedCornerShape(18.dp))316 .background(Color(0xE0131109))317 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))318 .padding(20.dp),319 content = content,320 )321}322323@Composable324private fun UnderlineField(325 value: String,326 onChange: (String) -> Unit,327 placeholder: String,328 textStyle: TextStyle,329 imeAction: ImeAction,330 onImeAction: () -> Unit,331 masked: Boolean = true,332) {333 val accent = LocalPeachAccent.current334 val interaction = remember { MutableInteractionSource() }335 val focused by interaction.collectIsFocusedAsState()336 val hairline = peachColors.T3337 BasicTextField(338 value = value,339 onValueChange = onChange,340 singleLine = true,341 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),342 cursorBrush = SolidColor(accent),343 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,344 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),345 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),346 interactionSource = interaction,347 modifier = Modifier348 .fillMaxWidth()349 .drawBehind {350 drawRect(351 color = if (focused) accent else hairline,352 topLeft = Offset(0f, size.height - 1.dp.toPx()),353 size = Size(size.width, 1.dp.toPx()),354 )355 }356 .padding(vertical = 8.dp),357 decorationBox = { inner ->358 Box(contentAlignment = Alignment.Center) {359 if (value.isEmpty()) {360 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))361 }362 inner()363 }364 },365 )366}367368@Composable369private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {370 val accent = LocalPeachAccent.current371 Box(372 modifier = Modifier373 .fillMaxWidth()374 .alpha(if (enabled) 1f else 0.5f)375 .clip(RoundedCornerShape(10.dp))376 .background(accent)377 .clickable(enabled = enabled, onClick = onClick)378 .padding(vertical = 13.dp),379 contentAlignment = Alignment.Center,380 ) {381 Text(382 label,383 color = Color.White,384 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),385 )386 }387}388389@OptIn(ExperimentalLayoutApi::class)390@Composable391private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {392 val tabs = listOf(393 "PeachScript" to PeachNav.Import,394 "CSV" to PeachNav.Import,395 "Codex" to PeachNav.CodexRestore,396 )397 Column(horizontalAlignment = Alignment.CenterHorizontally) {398 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)399 Spacer(Modifier.height(10.dp))400 401 402 403 404 405 FlowRow(406 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),407 verticalArrangement = Arrangement.spacedBy(6.dp),408 ) {409 tabs.forEach { (label, dest) ->410 Box(411 modifier = Modifier412 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))413 .clickable { onNavigate(dest) }414 .padding(horizontal = 10.dp, vertical = 8.dp),415 ) {416 Text(417 label,418 color = peachColors.T1,419 style = PeachType.sortPill,420 maxLines = 1,421 softWrap = false,422 )423 }424 }425 }426 }427}