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 LaunchedEffect(state.unlocked, autofillPending) {106 if (state.unlocked) {107 108 109 110 111 if (state.created) onNavigate(PeachNav.RecoverySetup)112 else if (autofillPending) Unit113 else onUnlocked(vm.postUnlockDestination())114 }115 }116117 118 LaunchedEffect(canBiometric) {119 if (canBiometric && activity != null && !state.unlocked) {120 kotlinx.coroutines.delay(300)121 vm.biometricUnlock(activity)122 }123 }124125 126 val shake = remember { Animatable(0f) }127 LaunchedEffect(state.shakeNonce) {128 if (state.shakeNonce > 0) {129 shake.animateTo(130 targetValue = 0f,131 animationSpec = keyframes {132 durationMillis = 400133 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400134 },135 )136 }137 }138139 ShellBackground {140 PeachWordmark(141 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),142 )143144 Column(145 modifier = Modifier146 .align(Alignment.Center)147 .fillMaxHeight()148 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)149 .fillMaxWidth()150 .systemBarsPadding()151 .padding(horizontal = 28.dp),152 verticalArrangement = Arrangement.Center,153 horizontalAlignment = Alignment.CenterHorizontally,154 ) {155 156 PeachMark(size = 80.dp)157 Spacer(Modifier.height(18.dp))158 Text(159 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",160 color = peachColors.T2,161 style = PeachType.lockEyebrow,162 )163 Spacer(Modifier.height(10.dp))164 Text(165 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",166 color = peachColors.T0,167 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,168 textAlign = TextAlign.Center,169 )170 Spacer(Modifier.height(14.dp))171 LockRuneString()172 Spacer(Modifier.height(24.dp))173174 175 GlassPanel(176 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },177 ) {178 Row(verticalAlignment = Alignment.CenterVertically) {179 FieldLabel(if (state.hasVault) "master password" else "create a master password")180 if (!state.hasVault) {181 Spacer(Modifier.weight(1f))182 Text(183 text = if (reveal) "hide" else "show",184 color = accent,185 style = PeachType.labelMono.copy(fontSize = 11.sp),186 modifier = Modifier187 .clip(RoundedCornerShape(6.dp))188 .clickable { reveal = !reveal }189 .padding(horizontal = 6.dp, vertical = 4.dp),190 )191 }192 }193 Spacer(Modifier.height(4.dp))194 UnderlineField(195 value = password,196 onChange = { password = it; vm.clearError() },197 placeholder = "••••••••••",198 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),199 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,200 onImeAction = { if (state.hasVault) vm.unlock(password) },201 masked = state.hasVault || !reveal,202 )203204 if (!state.hasVault) {205 Spacer(Modifier.height(16.dp))206 FieldLabel("confirm password")207 Spacer(Modifier.height(4.dp))208 UnderlineField(209 value = confirm,210 onChange = { confirm = it; vm.clearError() },211 placeholder = "••••••••••",212 textStyle = mono18Spaced(),213 imeAction = ImeAction.Go,214 onImeAction = { vm.create(password, confirm) },215 masked = !reveal,216 )217218 Spacer(Modifier.height(14.dp))219 if (password.isEmpty()) {220 Text(221 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",222 color = peachColors.T2,223 style = PeachType.hint,224 textAlign = TextAlign.Center,225 )226 } else {227 StrengthBar(score = createScore)228 Spacer(Modifier.height(6.dp))229 Text(230 text = "strength: ${strengthLabel(createScore)}",231 color = strengthColor(createScore),232 style = PeachType.labelMono,233 modifier = Modifier.fillMaxWidth(),234 textAlign = TextAlign.Center,235 )236 }237 }238239 Text(240 text = state.error ?: " ",241 color = peachColors.Breach,242 style = PeachType.labelMono.copy(fontSize = 11.sp),243 textAlign = TextAlign.Center,244 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),245 )246247 Spacer(Modifier.height(4.dp))248 AccentButton(249 label = when {250 state.hasVault -> "unlock"251 state.loading -> "creating..."252 createBlocker != null -> createBlocker253 else -> "create vault"254 },255 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),256 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },257 )258259 if (state.hasVault && canBiometric && activity != null) {260 Spacer(Modifier.height(12.dp))261 Text(262 text = "Use biometrics",263 color = accent,264 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),265 textAlign = TextAlign.Center,266 modifier = Modifier.fillMaxWidth()267 .clip(RoundedCornerShape(8.dp))268 .clickable { vm.biometricUnlock(activity) }269 .padding(vertical = 8.dp),270 )271 }272273 if (state.hasVault) {274 Spacer(Modifier.height(8.dp))275 Text(276 text = "Forgot your password?",277 color = peachColors.T2,278 style = PeachType.labelMono.copy(fontSize = 11.sp),279 textAlign = TextAlign.Center,280 modifier = Modifier.fillMaxWidth()281 .clip(RoundedCornerShape(8.dp))282 .clickable { onNavigate(PeachNav.RecoveryEntry) }283 .padding(vertical = 8.dp),284 )285 }286 }287288 if (!state.hasVault) {289 Spacer(Modifier.height(24.dp))290 RestoreTabs(onNavigate = onNavigate)291 }292 }293 }294}295296@Composable297private fun FieldLabel(text: String) {298 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))299}300301302private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)303304@Composable305private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {306 Column(307 modifier308 .fillMaxWidth()309 .clip(RoundedCornerShape(18.dp))310 .background(Color(0xE0131109))311 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))312 .padding(20.dp),313 content = content,314 )315}316317@Composable318private fun UnderlineField(319 value: String,320 onChange: (String) -> Unit,321 placeholder: String,322 textStyle: TextStyle,323 imeAction: ImeAction,324 onImeAction: () -> Unit,325 masked: Boolean = true,326) {327 val accent = LocalPeachAccent.current328 val interaction = remember { MutableInteractionSource() }329 val focused by interaction.collectIsFocusedAsState()330 val hairline = peachColors.T3331 BasicTextField(332 value = value,333 onValueChange = onChange,334 singleLine = true,335 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),336 cursorBrush = SolidColor(accent),337 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,338 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),339 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),340 interactionSource = interaction,341 modifier = Modifier342 .fillMaxWidth()343 .drawBehind {344 drawRect(345 color = if (focused) accent else hairline,346 topLeft = Offset(0f, size.height - 1.dp.toPx()),347 size = Size(size.width, 1.dp.toPx()),348 )349 }350 .padding(vertical = 8.dp),351 decorationBox = { inner ->352 Box(contentAlignment = Alignment.Center) {353 if (value.isEmpty()) {354 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))355 }356 inner()357 }358 },359 )360}361362@Composable363private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {364 val accent = LocalPeachAccent.current365 Box(366 modifier = Modifier367 .fillMaxWidth()368 .alpha(if (enabled) 1f else 0.5f)369 .clip(RoundedCornerShape(10.dp))370 .background(accent)371 .clickable(enabled = enabled, onClick = onClick)372 .padding(vertical = 13.dp),373 contentAlignment = Alignment.Center,374 ) {375 Text(376 label,377 color = Color.White,378 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),379 )380 }381}382383@OptIn(ExperimentalLayoutApi::class)384@Composable385private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {386 val tabs = listOf(387 "PeachScript" to PeachNav.Import,388 "CSV" to PeachNav.Import,389 "Codex" to PeachNav.CodexRestore,390 )391 Column(horizontalAlignment = Alignment.CenterHorizontally) {392 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)393 Spacer(Modifier.height(10.dp))394 395 396 397 398 399 FlowRow(400 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),401 verticalArrangement = Arrangement.spacedBy(6.dp),402 ) {403 tabs.forEach { (label, dest) ->404 Box(405 modifier = Modifier406 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))407 .clickable { onNavigate(dest) }408 .padding(horizontal = 10.dp, vertical = 8.dp),409 ) {410 Text(411 label,412 color = peachColors.T1,413 style = PeachType.sortPill,414 maxLines = 1,415 softWrap = false,416 )417 }418 }419 }420 }421}