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 vm: LockViewModel = hiltViewModel(),78) {79 val state by vm.state.collectAsState()80 val accent = LocalPeachAccent.current81 val context = LocalContext.current82 val activity = context as? FragmentActivity83 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }8485 var password by remember { mutableStateOf("") }86 var confirm by remember { mutableStateOf("") }87 var reveal by remember { mutableStateOf(false) }88 val createScore = remember(password) { scorePassword(password) }89 90 91 92 val createBlocker = if (state.hasVault || password.isEmpty()) null93 else createValidationError(password, confirm, createScore)9495 LaunchedEffect(state.unlocked) {96 if (state.unlocked) {97 98 99 if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())100 }101 }102103 104 LaunchedEffect(canBiometric) {105 if (canBiometric && activity != null && !state.unlocked) {106 kotlinx.coroutines.delay(300)107 vm.biometricUnlock(activity)108 }109 }110111 112 val shake = remember { Animatable(0f) }113 LaunchedEffect(state.shakeNonce) {114 if (state.shakeNonce > 0) {115 shake.animateTo(116 targetValue = 0f,117 animationSpec = keyframes {118 durationMillis = 400119 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400120 },121 )122 }123 }124125 ShellBackground {126 PeachWordmark(127 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),128 )129130 Column(131 modifier = Modifier132 .align(Alignment.Center)133 .fillMaxHeight()134 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)135 .fillMaxWidth()136 .systemBarsPadding()137 .padding(horizontal = 28.dp),138 verticalArrangement = Arrangement.Center,139 horizontalAlignment = Alignment.CenterHorizontally,140 ) {141 142 PeachMark(size = 80.dp)143 Spacer(Modifier.height(18.dp))144 Text(145 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",146 color = peachColors.T2,147 style = PeachType.lockEyebrow,148 )149 Spacer(Modifier.height(10.dp))150 Text(151 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",152 color = peachColors.T0,153 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,154 textAlign = TextAlign.Center,155 )156 Spacer(Modifier.height(14.dp))157 LockRuneString()158 Spacer(Modifier.height(24.dp))159160 161 GlassPanel(162 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },163 ) {164 Row(verticalAlignment = Alignment.CenterVertically) {165 FieldLabel(if (state.hasVault) "master password" else "create a master password")166 if (!state.hasVault) {167 Spacer(Modifier.weight(1f))168 Text(169 text = if (reveal) "hide" else "show",170 color = accent,171 style = PeachType.labelMono.copy(fontSize = 11.sp),172 modifier = Modifier173 .clip(RoundedCornerShape(6.dp))174 .clickable { reveal = !reveal }175 .padding(horizontal = 6.dp, vertical = 4.dp),176 )177 }178 }179 Spacer(Modifier.height(4.dp))180 UnderlineField(181 value = password,182 onChange = { password = it; vm.clearError() },183 placeholder = "••••••••••",184 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),185 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,186 onImeAction = { if (state.hasVault) vm.unlock(password) },187 masked = state.hasVault || !reveal,188 )189190 if (!state.hasVault) {191 Spacer(Modifier.height(16.dp))192 FieldLabel("confirm password")193 Spacer(Modifier.height(4.dp))194 UnderlineField(195 value = confirm,196 onChange = { confirm = it; vm.clearError() },197 placeholder = "••••••••••",198 textStyle = mono18Spaced(),199 imeAction = ImeAction.Go,200 onImeAction = { vm.create(password, confirm) },201 masked = !reveal,202 )203204 Spacer(Modifier.height(14.dp))205 if (password.isEmpty()) {206 Text(207 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",208 color = peachColors.T2,209 style = PeachType.hint,210 textAlign = TextAlign.Center,211 )212 } else {213 StrengthBar(score = createScore)214 Spacer(Modifier.height(6.dp))215 Text(216 text = "strength: ${strengthLabel(createScore)}",217 color = strengthColor(createScore),218 style = PeachType.labelMono,219 modifier = Modifier.fillMaxWidth(),220 textAlign = TextAlign.Center,221 )222 }223 }224225 Text(226 text = state.error ?: " ",227 color = peachColors.Breach,228 style = PeachType.labelMono.copy(fontSize = 11.sp),229 textAlign = TextAlign.Center,230 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),231 )232233 Spacer(Modifier.height(4.dp))234 AccentButton(235 label = when {236 state.hasVault -> "unlock"237 state.loading -> "creating..."238 createBlocker != null -> createBlocker239 else -> "create vault"240 },241 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),242 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },243 )244245 if (state.hasVault && canBiometric && activity != null) {246 Spacer(Modifier.height(12.dp))247 Text(248 text = "Use biometrics",249 color = accent,250 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),251 textAlign = TextAlign.Center,252 modifier = Modifier.fillMaxWidth()253 .clip(RoundedCornerShape(8.dp))254 .clickable { vm.biometricUnlock(activity) }255 .padding(vertical = 8.dp),256 )257 }258259 if (state.hasVault) {260 Spacer(Modifier.height(8.dp))261 Text(262 text = "Forgot your password?",263 color = peachColors.T2,264 style = PeachType.labelMono.copy(fontSize = 11.sp),265 textAlign = TextAlign.Center,266 modifier = Modifier.fillMaxWidth()267 .clip(RoundedCornerShape(8.dp))268 .clickable { onNavigate(PeachNav.RecoveryEntry) }269 .padding(vertical = 8.dp),270 )271 }272 }273274 if (!state.hasVault) {275 Spacer(Modifier.height(24.dp))276 RestoreTabs(onNavigate = onNavigate)277 }278 }279 }280}281282@Composable283private fun FieldLabel(text: String) {284 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))285}286287288private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)289290@Composable291private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {292 Column(293 modifier294 .fillMaxWidth()295 .clip(RoundedCornerShape(18.dp))296 .background(Color(0xE0131109))297 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))298 .padding(20.dp),299 content = content,300 )301}302303@Composable304private fun UnderlineField(305 value: String,306 onChange: (String) -> Unit,307 placeholder: String,308 textStyle: TextStyle,309 imeAction: ImeAction,310 onImeAction: () -> Unit,311 masked: Boolean = true,312) {313 val accent = LocalPeachAccent.current314 val interaction = remember { MutableInteractionSource() }315 val focused by interaction.collectIsFocusedAsState()316 val hairline = peachColors.T3317 BasicTextField(318 value = value,319 onValueChange = onChange,320 singleLine = true,321 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),322 cursorBrush = SolidColor(accent),323 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,324 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),325 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),326 interactionSource = interaction,327 modifier = Modifier328 .fillMaxWidth()329 .drawBehind {330 drawRect(331 color = if (focused) accent else hairline,332 topLeft = Offset(0f, size.height - 1.dp.toPx()),333 size = Size(size.width, 1.dp.toPx()),334 )335 }336 .padding(vertical = 8.dp),337 decorationBox = { inner ->338 Box(contentAlignment = Alignment.Center) {339 if (value.isEmpty()) {340 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))341 }342 inner()343 }344 },345 )346}347348@Composable349private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {350 val accent = LocalPeachAccent.current351 Box(352 modifier = Modifier353 .fillMaxWidth()354 .alpha(if (enabled) 1f else 0.5f)355 .clip(RoundedCornerShape(10.dp))356 .background(accent)357 .clickable(enabled = enabled, onClick = onClick)358 .padding(vertical = 13.dp),359 contentAlignment = Alignment.Center,360 ) {361 Text(362 label,363 color = Color.White,364 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),365 )366 }367}368369@OptIn(ExperimentalLayoutApi::class)370@Composable371private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {372 val tabs = listOf(373 "PeachScript" to PeachNav.Import,374 "CSV" to PeachNav.Import,375 "Codex" to PeachNav.CodexRestore,376 )377 Column(horizontalAlignment = Alignment.CenterHorizontally) {378 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)379 Spacer(Modifier.height(10.dp))380 381 382 383 384 385 FlowRow(386 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),387 verticalArrangement = Arrangement.spacedBy(6.dp),388 ) {389 tabs.forEach { (label, dest) ->390 Box(391 modifier = Modifier392 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))393 .clickable { onNavigate(dest) }394 .padding(horizontal = 10.dp, vertical = 8.dp),395 ) {396 Text(397 label,398 color = peachColors.T1,399 style = PeachType.sortPill,400 maxLines = 1,401 softWrap = false,402 )403 }404 }405 }406 }407}