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.components.PeachWordmark61import com.peachpasswords.ui.theme.LocalPeachAccent62import com.peachpasswords.ui.theme.PeachSpacing63import com.peachpasswords.ui.theme.peachColors64import com.peachpasswords.ui.security.StrengthBar65import com.peachpasswords.ui.security.scorePassword66import com.peachpasswords.ui.security.strengthColor67import com.peachpasswords.ui.security.strengthLabel68import com.peachpasswords.ui.theme.PeachType69import com.peachpasswords.ui.theme.ShellBackground7071@Composable72fun LockScreen(73 onUnlocked: (PeachNav) -> Unit,74 onNavigate: (PeachNav) -> Unit,75 vm: LockViewModel = hiltViewModel(),76) {77 val state by vm.state.collectAsState()78 val accent = LocalPeachAccent.current79 val context = LocalContext.current80 val activity = context as? FragmentActivity81 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }8283 var password by remember { mutableStateOf("") }84 var confirm by remember { mutableStateOf("") }85 var reveal by remember { mutableStateOf(false) }86 val createScore = remember(password) { scorePassword(password) }87 88 89 90 val createBlocker = if (state.hasVault || password.isEmpty()) null91 else createValidationError(password, confirm, createScore)9293 LaunchedEffect(state.unlocked) {94 if (state.unlocked) {95 96 97 if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())98 }99 }100101 102 LaunchedEffect(canBiometric) {103 if (canBiometric && activity != null && !state.unlocked) {104 kotlinx.coroutines.delay(300)105 vm.biometricUnlock(activity)106 }107 }108109 110 val shake = remember { Animatable(0f) }111 LaunchedEffect(state.shakeNonce) {112 if (state.shakeNonce > 0) {113 shake.animateTo(114 targetValue = 0f,115 animationSpec = keyframes {116 durationMillis = 400117 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400118 },119 )120 }121 }122123 ShellBackground {124 PeachWordmark(125 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),126 )127128 Column(129 modifier = Modifier130 131 132 133 134 .align(Alignment.Center)135 .fillMaxHeight()136 .widthIn(max = 480.dp)137 .fillMaxWidth()138 .systemBarsPadding()139 .padding(horizontal = 28.dp),140 verticalArrangement = Arrangement.Center,141 horizontalAlignment = Alignment.CenterHorizontally,142 ) {143 144 PeachMark(size = 80.dp)145 Spacer(Modifier.height(18.dp))146 Text(147 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",148 color = peachColors.T2,149 style = PeachType.lockEyebrow,150 )151 Spacer(Modifier.height(10.dp))152 Text(153 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",154 color = peachColors.T0,155 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,156 textAlign = TextAlign.Center,157 )158 Spacer(Modifier.height(14.dp))159 LockRuneString()160 Spacer(Modifier.height(24.dp))161162 163 GlassPanel(164 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },165 ) {166 Row(verticalAlignment = Alignment.CenterVertically) {167 FieldLabel(if (state.hasVault) "master password" else "create a master password")168 if (!state.hasVault) {169 Spacer(Modifier.weight(1f))170 Text(171 text = if (reveal) "hide" else "show",172 color = accent,173 style = PeachType.labelMono.copy(fontSize = 11.sp),174 modifier = Modifier175 .clip(RoundedCornerShape(6.dp))176 .clickable { reveal = !reveal }177 .padding(horizontal = 6.dp, vertical = 4.dp),178 )179 }180 }181 Spacer(Modifier.height(4.dp))182 UnderlineField(183 value = password,184 onChange = { password = it; vm.clearError() },185 placeholder = "••••••••••",186 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),187 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,188 onImeAction = { if (state.hasVault) vm.unlock(password) },189 masked = state.hasVault || !reveal,190 )191192 if (!state.hasVault) {193 Spacer(Modifier.height(16.dp))194 FieldLabel("confirm password")195 Spacer(Modifier.height(4.dp))196 UnderlineField(197 value = confirm,198 onChange = { confirm = it; vm.clearError() },199 placeholder = "••••••••••",200 textStyle = mono18Spaced(),201 imeAction = ImeAction.Go,202 onImeAction = { vm.create(password, confirm) },203 masked = !reveal,204 )205206 Spacer(Modifier.height(14.dp))207 if (password.isEmpty()) {208 Text(209 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",210 color = peachColors.T2,211 style = PeachType.hint,212 textAlign = TextAlign.Center,213 )214 } else {215 StrengthBar(score = createScore)216 Spacer(Modifier.height(6.dp))217 Text(218 text = "strength: ${strengthLabel(createScore)}",219 color = strengthColor(createScore),220 style = PeachType.labelMono,221 modifier = Modifier.fillMaxWidth(),222 textAlign = TextAlign.Center,223 )224 }225 }226227 Text(228 text = state.error ?: " ",229 color = peachColors.Breach,230 style = PeachType.labelMono.copy(fontSize = 11.sp),231 textAlign = TextAlign.Center,232 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),233 )234235 Spacer(Modifier.height(4.dp))236 AccentButton(237 label = when {238 state.hasVault -> "unlock"239 state.loading -> "creating..."240 createBlocker != null -> createBlocker241 else -> "create vault"242 },243 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),244 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },245 )246247 if (state.hasVault && canBiometric && activity != null) {248 Spacer(Modifier.height(12.dp))249 Text(250 text = "Use biometrics",251 color = accent,252 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),253 textAlign = TextAlign.Center,254 modifier = Modifier.fillMaxWidth()255 .clip(RoundedCornerShape(8.dp))256 .clickable { vm.biometricUnlock(activity) }257 .padding(vertical = 8.dp),258 )259 }260261 if (state.hasVault) {262 Spacer(Modifier.height(8.dp))263 Text(264 text = "Forgot your password?",265 color = peachColors.T2,266 style = PeachType.labelMono.copy(fontSize = 11.sp),267 textAlign = TextAlign.Center,268 modifier = Modifier.fillMaxWidth()269 .clip(RoundedCornerShape(8.dp))270 .clickable { onNavigate(PeachNav.RecoveryEntry) }271 .padding(vertical = 8.dp),272 )273 }274 }275276 if (!state.hasVault) {277 Spacer(Modifier.height(24.dp))278 RestoreTabs(onNavigate = onNavigate)279 }280 }281 }282}283284@Composable285private fun FieldLabel(text: String) {286 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))287}288289290private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)291292@Composable293private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {294 Column(295 modifier296 .fillMaxWidth()297 .clip(RoundedCornerShape(18.dp))298 .background(Color(0xE0131109))299 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))300 .padding(20.dp),301 content = content,302 )303}304305@Composable306private fun UnderlineField(307 value: String,308 onChange: (String) -> Unit,309 placeholder: String,310 textStyle: TextStyle,311 imeAction: ImeAction,312 onImeAction: () -> Unit,313 masked: Boolean = true,314) {315 val accent = LocalPeachAccent.current316 val interaction = remember { MutableInteractionSource() }317 val focused by interaction.collectIsFocusedAsState()318 val hairline = peachColors.T3319 BasicTextField(320 value = value,321 onValueChange = onChange,322 singleLine = true,323 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),324 cursorBrush = SolidColor(accent),325 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,326 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),327 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),328 interactionSource = interaction,329 modifier = Modifier330 .fillMaxWidth()331 .drawBehind {332 drawRect(333 color = if (focused) accent else hairline,334 topLeft = Offset(0f, size.height - 1.dp.toPx()),335 size = Size(size.width, 1.dp.toPx()),336 )337 }338 .padding(vertical = 8.dp),339 decorationBox = { inner ->340 Box(contentAlignment = Alignment.Center) {341 if (value.isEmpty()) {342 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))343 }344 inner()345 }346 },347 )348}349350@Composable351private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {352 val accent = LocalPeachAccent.current353 Box(354 modifier = Modifier355 .fillMaxWidth()356 .alpha(if (enabled) 1f else 0.5f)357 .clip(RoundedCornerShape(10.dp))358 .background(accent)359 .clickable(enabled = enabled, onClick = onClick)360 .padding(vertical = 13.dp),361 contentAlignment = Alignment.Center,362 ) {363 Text(364 label,365 color = Color.White,366 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),367 )368 }369}370371@Composable372private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {373 val tabs = listOf(374 "PeachScript" to PeachNav.Import,375 "CSV" to PeachNav.Import,376 "From Phone" to PeachNav.ChirpPair,377 "Codex" to PeachNav.CodexRestore,378 )379 Column(horizontalAlignment = Alignment.CenterHorizontally) {380 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)381 Spacer(Modifier.height(10.dp))382 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {383 tabs.forEach { (label, dest) ->384 Box(385 modifier = Modifier386 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))387 .clickable { onNavigate(dest) }388 .padding(horizontal = 10.dp, vertical = 8.dp),389 ) {390 Text(label, color = peachColors.T1, style = PeachType.sortPill)391 }392 }393 }394 }395}