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 .align(Alignment.Center)131 .fillMaxHeight()132 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)133 .fillMaxWidth()134 .systemBarsPadding()135 .padding(horizontal = 28.dp),136 verticalArrangement = Arrangement.Center,137 horizontalAlignment = Alignment.CenterHorizontally,138 ) {139 140 PeachMark(size = 80.dp)141 Spacer(Modifier.height(18.dp))142 Text(143 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",144 color = peachColors.T2,145 style = PeachType.lockEyebrow,146 )147 Spacer(Modifier.height(10.dp))148 Text(149 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",150 color = peachColors.T0,151 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,152 textAlign = TextAlign.Center,153 )154 Spacer(Modifier.height(14.dp))155 LockRuneString()156 Spacer(Modifier.height(24.dp))157158 159 GlassPanel(160 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },161 ) {162 Row(verticalAlignment = Alignment.CenterVertically) {163 FieldLabel(if (state.hasVault) "master password" else "create a master password")164 if (!state.hasVault) {165 Spacer(Modifier.weight(1f))166 Text(167 text = if (reveal) "hide" else "show",168 color = accent,169 style = PeachType.labelMono.copy(fontSize = 11.sp),170 modifier = Modifier171 .clip(RoundedCornerShape(6.dp))172 .clickable { reveal = !reveal }173 .padding(horizontal = 6.dp, vertical = 4.dp),174 )175 }176 }177 Spacer(Modifier.height(4.dp))178 UnderlineField(179 value = password,180 onChange = { password = it; vm.clearError() },181 placeholder = "••••••••••",182 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),183 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,184 onImeAction = { if (state.hasVault) vm.unlock(password) },185 masked = state.hasVault || !reveal,186 )187188 if (!state.hasVault) {189 Spacer(Modifier.height(16.dp))190 FieldLabel("confirm password")191 Spacer(Modifier.height(4.dp))192 UnderlineField(193 value = confirm,194 onChange = { confirm = it; vm.clearError() },195 placeholder = "••••••••••",196 textStyle = mono18Spaced(),197 imeAction = ImeAction.Go,198 onImeAction = { vm.create(password, confirm) },199 masked = !reveal,200 )201202 Spacer(Modifier.height(14.dp))203 if (password.isEmpty()) {204 Text(205 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",206 color = peachColors.T2,207 style = PeachType.hint,208 textAlign = TextAlign.Center,209 )210 } else {211 StrengthBar(score = createScore)212 Spacer(Modifier.height(6.dp))213 Text(214 text = "strength: ${strengthLabel(createScore)}",215 color = strengthColor(createScore),216 style = PeachType.labelMono,217 modifier = Modifier.fillMaxWidth(),218 textAlign = TextAlign.Center,219 )220 }221 }222223 Text(224 text = state.error ?: " ",225 color = peachColors.Breach,226 style = PeachType.labelMono.copy(fontSize = 11.sp),227 textAlign = TextAlign.Center,228 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),229 )230231 Spacer(Modifier.height(4.dp))232 AccentButton(233 label = when {234 state.hasVault -> "unlock"235 state.loading -> "creating..."236 createBlocker != null -> createBlocker237 else -> "create vault"238 },239 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),240 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },241 )242243 if (state.hasVault && canBiometric && activity != null) {244 Spacer(Modifier.height(12.dp))245 Text(246 text = "Use biometrics",247 color = accent,248 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),249 textAlign = TextAlign.Center,250 modifier = Modifier.fillMaxWidth()251 .clip(RoundedCornerShape(8.dp))252 .clickable { vm.biometricUnlock(activity) }253 .padding(vertical = 8.dp),254 )255 }256257 if (state.hasVault) {258 Spacer(Modifier.height(8.dp))259 Text(260 text = "Forgot your password?",261 color = peachColors.T2,262 style = PeachType.labelMono.copy(fontSize = 11.sp),263 textAlign = TextAlign.Center,264 modifier = Modifier.fillMaxWidth()265 .clip(RoundedCornerShape(8.dp))266 .clickable { onNavigate(PeachNav.RecoveryEntry) }267 .padding(vertical = 8.dp),268 )269 }270 }271272 if (!state.hasVault) {273 Spacer(Modifier.height(24.dp))274 RestoreTabs(onNavigate = onNavigate)275 }276 }277 }278}279280@Composable281private fun FieldLabel(text: String) {282 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))283}284285286private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)287288@Composable289private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {290 Column(291 modifier292 .fillMaxWidth()293 .clip(RoundedCornerShape(18.dp))294 .background(Color(0xE0131109))295 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))296 .padding(20.dp),297 content = content,298 )299}300301@Composable302private fun UnderlineField(303 value: String,304 onChange: (String) -> Unit,305 placeholder: String,306 textStyle: TextStyle,307 imeAction: ImeAction,308 onImeAction: () -> Unit,309 masked: Boolean = true,310) {311 val accent = LocalPeachAccent.current312 val interaction = remember { MutableInteractionSource() }313 val focused by interaction.collectIsFocusedAsState()314 val hairline = peachColors.T3315 BasicTextField(316 value = value,317 onValueChange = onChange,318 singleLine = true,319 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),320 cursorBrush = SolidColor(accent),321 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,322 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),323 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),324 interactionSource = interaction,325 modifier = Modifier326 .fillMaxWidth()327 .drawBehind {328 drawRect(329 color = if (focused) accent else hairline,330 topLeft = Offset(0f, size.height - 1.dp.toPx()),331 size = Size(size.width, 1.dp.toPx()),332 )333 }334 .padding(vertical = 8.dp),335 decorationBox = { inner ->336 Box(contentAlignment = Alignment.Center) {337 if (value.isEmpty()) {338 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))339 }340 inner()341 }342 },343 )344}345346@Composable347private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {348 val accent = LocalPeachAccent.current349 Box(350 modifier = Modifier351 .fillMaxWidth()352 .alpha(if (enabled) 1f else 0.5f)353 .clip(RoundedCornerShape(10.dp))354 .background(accent)355 .clickable(enabled = enabled, onClick = onClick)356 .padding(vertical = 13.dp),357 contentAlignment = Alignment.Center,358 ) {359 Text(360 label,361 color = Color.White,362 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),363 )364 }365}366367@Composable368private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {369 val tabs = listOf(370 "PeachScript" to PeachNav.Import,371 "CSV" to PeachNav.Import,372 "From Phone" to PeachNav.ChirpPair,373 "Codex" to PeachNav.CodexRestore,374 )375 Column(horizontalAlignment = Alignment.CenterHorizontally) {376 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)377 Spacer(Modifier.height(10.dp))378 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {379 tabs.forEach { (label, dest) ->380 Box(381 modifier = Modifier382 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))383 .clickable { onNavigate(dest) }384 .padding(horizontal = 10.dp, vertical = 8.dp),385 ) {386 Text(label, color = peachColors.T1, style = PeachType.sortPill)387 }388 }389 }390 }391}