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.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxHeight16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.offset18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.systemBarsPadding22import androidx.compose.foundation.layout.widthIn23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.foundation.text.KeyboardActions26import androidx.compose.foundation.text.KeyboardOptions27import androidx.compose.material3.Text28import androidx.compose.runtime.Composable29import androidx.compose.runtime.LaunchedEffect30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.setValue34import androidx.compose.ui.Alignment35import androidx.compose.ui.Modifier36import androidx.compose.ui.draw.alpha37import androidx.compose.ui.draw.clip38import androidx.compose.ui.draw.drawBehind39import androidx.compose.ui.geometry.Offset40import androidx.compose.ui.geometry.Size41import androidx.compose.ui.graphics.Color42import androidx.compose.ui.graphics.SolidColor43import androidx.compose.ui.text.TextStyle44import androidx.compose.ui.text.input.ImeAction45import androidx.compose.ui.text.input.KeyboardType46import androidx.compose.ui.text.input.PasswordVisualTransformation47import androidx.compose.ui.text.input.VisualTransformation48import androidx.compose.ui.text.style.TextAlign49import androidx.compose.ui.unit.IntOffset50import androidx.compose.ui.unit.dp51import androidx.compose.ui.unit.em52import androidx.compose.ui.unit.sp53import androidx.hilt.navigation.compose.hiltViewModel54import androidx.compose.runtime.collectAsState55import androidx.compose.ui.platform.LocalContext56import androidx.fragment.app.FragmentActivity57import com.peachpasswords.ui.PeachNav58import com.peachpasswords.ui.components.LockRuneString59import com.peachpasswords.ui.components.PeachMark60import com.peachpasswords.ui.theme.LocalPeachAccent61import com.peachpasswords.ui.theme.PeachSpacing62import com.peachpasswords.ui.theme.peachColors63import com.peachpasswords.ui.security.StrengthBar64import com.peachpasswords.ui.security.scorePassword65import com.peachpasswords.ui.security.strengthColor66import com.peachpasswords.ui.security.strengthLabel67import com.peachpasswords.ui.theme.PeachType68import com.peachpasswords.ui.theme.ShellBackground6970@Composable71fun LockScreen(72 onUnlocked: (PeachNav) -> Unit,73 onNavigate: (PeachNav) -> Unit,74 vm: LockViewModel = hiltViewModel(),75) {76 val state by vm.state.collectAsState()77 val accent = LocalPeachAccent.current78 val context = LocalContext.current79 val activity = context as? FragmentActivity80 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }8182 var password by remember { mutableStateOf("") }83 var confirm by remember { mutableStateOf("") }84 var reveal by remember { mutableStateOf(false) }85 val createScore = remember(password) { scorePassword(password) }86 87 88 89 val createBlocker = if (state.hasVault || password.isEmpty()) null90 else createValidationError(password, confirm, createScore)9192 LaunchedEffect(state.unlocked) {93 if (state.unlocked) {94 95 96 if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())97 }98 }99100 101 LaunchedEffect(canBiometric) {102 if (canBiometric && activity != null && !state.unlocked) {103 kotlinx.coroutines.delay(300)104 vm.biometricUnlock(activity)105 }106 }107108 109 val shake = remember { Animatable(0f) }110 LaunchedEffect(state.shakeNonce) {111 if (state.shakeNonce > 0) {112 shake.animateTo(113 targetValue = 0f,114 animationSpec = keyframes {115 durationMillis = 400116 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400117 },118 )119 }120 }121122 ShellBackground {123 124 Row(125 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),126 verticalAlignment = Alignment.CenterVertically,127 ) {128 PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))129 Text(130 text = "peach",131 color = accent,132 style = PeachType.wordmark,133 )134 }135136 Column(137 modifier = Modifier138 139 140 141 142 .align(Alignment.Center)143 .fillMaxHeight()144 .widthIn(max = 480.dp)145 .fillMaxWidth()146 .systemBarsPadding()147 .padding(horizontal = 28.dp),148 verticalArrangement = Arrangement.Center,149 horizontalAlignment = Alignment.CenterHorizontally,150 ) {151 152 PeachMark(size = 80.dp)153 Spacer(Modifier.height(18.dp))154 Text(155 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",156 color = peachColors.T2,157 style = PeachType.lockEyebrow,158 )159 Spacer(Modifier.height(10.dp))160 Text(161 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",162 color = peachColors.T0,163 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,164 textAlign = TextAlign.Center,165 )166 Spacer(Modifier.height(14.dp))167 LockRuneString()168 Spacer(Modifier.height(24.dp))169170 171 GlassPanel(172 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },173 ) {174 Row(verticalAlignment = Alignment.CenterVertically) {175 FieldLabel(if (state.hasVault) "master password" else "create a master password")176 if (!state.hasVault) {177 Spacer(Modifier.weight(1f))178 Text(179 text = if (reveal) "hide" else "show",180 color = accent,181 style = PeachType.labelMono.copy(fontSize = 11.sp),182 modifier = Modifier183 .clip(RoundedCornerShape(6.dp))184 .clickable { reveal = !reveal }185 .padding(horizontal = 6.dp, vertical = 4.dp),186 )187 }188 }189 Spacer(Modifier.height(4.dp))190 UnderlineField(191 value = password,192 onChange = { password = it; vm.clearError() },193 placeholder = "••••••••••",194 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),195 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,196 onImeAction = { if (state.hasVault) vm.unlock(password) },197 masked = state.hasVault || !reveal,198 )199200 if (!state.hasVault) {201 Spacer(Modifier.height(16.dp))202 FieldLabel("confirm password")203 Spacer(Modifier.height(4.dp))204 UnderlineField(205 value = confirm,206 onChange = { confirm = it; vm.clearError() },207 placeholder = "••••••••••",208 textStyle = mono18Spaced(),209 imeAction = ImeAction.Go,210 onImeAction = { vm.create(password, confirm) },211 masked = !reveal,212 )213214 Spacer(Modifier.height(14.dp))215 if (password.isEmpty()) {216 Text(217 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",218 color = peachColors.T2,219 style = PeachType.hint,220 textAlign = TextAlign.Center,221 )222 } else {223 StrengthBar(score = createScore)224 Spacer(Modifier.height(6.dp))225 Text(226 text = "strength: ${strengthLabel(createScore)}",227 color = strengthColor(createScore),228 style = PeachType.labelMono,229 modifier = Modifier.fillMaxWidth(),230 textAlign = TextAlign.Center,231 )232 }233 }234235 Text(236 text = state.error ?: " ",237 color = peachColors.Breach,238 style = PeachType.labelMono.copy(fontSize = 11.sp),239 textAlign = TextAlign.Center,240 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),241 )242243 Spacer(Modifier.height(4.dp))244 AccentButton(245 label = when {246 state.hasVault -> "unlock"247 state.loading -> "creating..."248 createBlocker != null -> createBlocker249 else -> "create vault"250 },251 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),252 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },253 )254255 if (state.hasVault && canBiometric && activity != null) {256 Spacer(Modifier.height(12.dp))257 Text(258 text = "Use biometrics",259 color = accent,260 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),261 textAlign = TextAlign.Center,262 modifier = Modifier.fillMaxWidth()263 .clip(RoundedCornerShape(8.dp))264 .clickable { vm.biometricUnlock(activity) }265 .padding(vertical = 8.dp),266 )267 }268269 if (state.hasVault) {270 Spacer(Modifier.height(8.dp))271 Text(272 text = "Forgot your password?",273 color = peachColors.T2,274 style = PeachType.labelMono.copy(fontSize = 11.sp),275 textAlign = TextAlign.Center,276 modifier = Modifier.fillMaxWidth()277 .clip(RoundedCornerShape(8.dp))278 .clickable { onNavigate(PeachNav.RecoveryEntry) }279 .padding(vertical = 8.dp),280 )281 }282 }283284 if (!state.hasVault) {285 Spacer(Modifier.height(24.dp))286 RestoreTabs(onNavigate = onNavigate)287 }288 }289 }290}291292@Composable293private fun FieldLabel(text: String) {294 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))295}296297298private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)299300@Composable301private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {302 Column(303 modifier304 .fillMaxWidth()305 .clip(RoundedCornerShape(18.dp))306 .background(Color(0xE0131109))307 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))308 .padding(20.dp),309 content = content,310 )311}312313@Composable314private fun UnderlineField(315 value: String,316 onChange: (String) -> Unit,317 placeholder: String,318 textStyle: TextStyle,319 imeAction: ImeAction,320 onImeAction: () -> Unit,321 masked: Boolean = true,322) {323 val accent = LocalPeachAccent.current324 val interaction = remember { MutableInteractionSource() }325 val focused by interaction.collectIsFocusedAsState()326 val hairline = peachColors.T3327 BasicTextField(328 value = value,329 onValueChange = onChange,330 singleLine = true,331 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),332 cursorBrush = SolidColor(accent),333 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,334 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),335 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),336 interactionSource = interaction,337 modifier = Modifier338 .fillMaxWidth()339 .drawBehind {340 drawRect(341 color = if (focused) accent else hairline,342 topLeft = Offset(0f, size.height - 1.dp.toPx()),343 size = Size(size.width, 1.dp.toPx()),344 )345 }346 .padding(vertical = 8.dp),347 decorationBox = { inner ->348 Box(contentAlignment = Alignment.Center) {349 if (value.isEmpty()) {350 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))351 }352 inner()353 }354 },355 )356}357358@Composable359private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {360 val accent = LocalPeachAccent.current361 Box(362 modifier = Modifier363 .fillMaxWidth()364 .alpha(if (enabled) 1f else 0.5f)365 .clip(RoundedCornerShape(10.dp))366 .background(accent)367 .clickable(enabled = enabled, onClick = onClick)368 .padding(vertical = 13.dp),369 contentAlignment = Alignment.Center,370 ) {371 Text(372 label,373 color = Color.White,374 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),375 )376 }377}378379@Composable380private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {381 val tabs = listOf(382 "PeachScript" to PeachNav.Import,383 "CSV" to PeachNav.Import,384 "From Phone" to PeachNav.ChirpPair,385 "Codex" to PeachNav.CodexRestore,386 )387 Column(horizontalAlignment = Alignment.CenterHorizontally) {388 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)389 Spacer(Modifier.height(10.dp))390 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {391 tabs.forEach { (label, dest) ->392 Box(393 modifier = Modifier394 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))395 .clickable { onNavigate(dest) }396 .padding(horizontal = 10.dp, vertical = 8.dp),397 ) {398 Text(label, color = peachColors.T1, style = PeachType.sortPill)399 }400 }401 }402 }403}