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.fillMaxSize16import androidx.compose.foundation.layout.offset17import androidx.compose.foundation.layout.fillMaxWidth18import androidx.compose.foundation.layout.height19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.systemBarsPadding21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.text.BasicTextField23import androidx.compose.foundation.text.KeyboardActions24import androidx.compose.foundation.text.KeyboardOptions25import androidx.compose.material3.Text26import androidx.compose.runtime.Composable27import androidx.compose.runtime.LaunchedEffect28import androidx.compose.runtime.getValue29import androidx.compose.runtime.mutableStateOf30import androidx.compose.runtime.remember31import androidx.compose.runtime.setValue32import androidx.compose.ui.Alignment33import androidx.compose.ui.Modifier34import androidx.compose.ui.draw.alpha35import androidx.compose.ui.draw.clip36import androidx.compose.ui.draw.drawBehind37import androidx.compose.ui.geometry.Offset38import androidx.compose.ui.geometry.Size39import androidx.compose.ui.graphics.Color40import androidx.compose.ui.graphics.SolidColor41import androidx.compose.ui.text.TextStyle42import androidx.compose.ui.text.input.ImeAction43import androidx.compose.ui.text.input.KeyboardType44import androidx.compose.ui.text.input.PasswordVisualTransformation45import androidx.compose.ui.text.style.TextAlign46import androidx.compose.ui.unit.IntOffset47import androidx.compose.ui.unit.dp48import androidx.compose.ui.unit.em49import androidx.compose.ui.unit.sp50import androidx.hilt.navigation.compose.hiltViewModel51import androidx.compose.runtime.collectAsState52import androidx.compose.ui.platform.LocalContext53import androidx.fragment.app.FragmentActivity54import com.peachpasswords.ui.PeachNav55import com.peachpasswords.ui.components.LockRuneString56import com.peachpasswords.ui.components.PeachMark57import com.peachpasswords.ui.theme.LocalPeachAccent58import com.peachpasswords.ui.theme.peachColors59import com.peachpasswords.ui.security.StrengthBar60import com.peachpasswords.ui.security.scorePassword61import com.peachpasswords.ui.security.strengthColor62import com.peachpasswords.ui.security.strengthLabel63import com.peachpasswords.ui.theme.PeachType64import com.peachpasswords.ui.theme.ShellBackground6566@Composable67fun LockScreen(68 onUnlocked: (PeachNav) -> Unit,69 onNavigate: (PeachNav) -> Unit,70 vm: LockViewModel = hiltViewModel(),71) {72 val state by vm.state.collectAsState()73 val accent = LocalPeachAccent.current74 val context = LocalContext.current75 val activity = context as? FragmentActivity76 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }7778 var password by remember { mutableStateOf("") }79 var confirm by remember { mutableStateOf("") }80 val createScore = remember(password) { scorePassword(password) }8182 LaunchedEffect(state.unlocked) {83 if (state.unlocked) {84 85 86 if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())87 }88 }8990 91 LaunchedEffect(canBiometric) {92 if (canBiometric && activity != null && !state.unlocked) {93 kotlinx.coroutines.delay(300)94 vm.biometricUnlock(activity)95 }96 }9798 99 val shake = remember { Animatable(0f) }100 LaunchedEffect(state.shakeNonce) {101 if (state.shakeNonce > 0) {102 shake.animateTo(103 targetValue = 0f,104 animationSpec = keyframes {105 durationMillis = 400106 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400107 },108 )109 }110 }111112 ShellBackground {113 114 Text(115 text = "peach",116 color = accent,117 style = PeachType.wordmark,118 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),119 )120121 Column(122 modifier = Modifier123 .fillMaxSize()124 .systemBarsPadding()125 .padding(horizontal = 28.dp),126 verticalArrangement = Arrangement.Center,127 horizontalAlignment = Alignment.CenterHorizontally,128 ) {129 130 PeachMark(size = 80.dp)131 Spacer(Modifier.height(18.dp))132 Text(133 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",134 color = peachColors.T2,135 style = PeachType.lockEyebrow,136 )137 Spacer(Modifier.height(10.dp))138 Text(139 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",140 color = peachColors.T0,141 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,142 textAlign = TextAlign.Center,143 )144 Spacer(Modifier.height(14.dp))145 LockRuneString()146 Spacer(Modifier.height(24.dp))147148 149 GlassPanel(150 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },151 ) {152 FieldLabel(if (state.hasVault) "master password" else "create a master password")153 Spacer(Modifier.height(4.dp))154 UnderlineField(155 value = password,156 onChange = { password = it; vm.clearError() },157 placeholder = "••••••••••",158 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),159 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,160 onImeAction = { if (state.hasVault) vm.unlock(password) },161 )162163 if (!state.hasVault) {164 Spacer(Modifier.height(16.dp))165 FieldLabel("confirm password")166 Spacer(Modifier.height(4.dp))167 UnderlineField(168 value = confirm,169 onChange = { confirm = it; vm.clearError() },170 placeholder = "••••••••••",171 textStyle = mono18Spaced(),172 imeAction = ImeAction.Go,173 onImeAction = { vm.create(password, confirm) },174 )175176 if (password.isNotEmpty()) {177 Spacer(Modifier.height(14.dp))178 StrengthBar(score = createScore)179 Spacer(Modifier.height(6.dp))180 Text(181 text = "strength: ${strengthLabel(createScore)}",182 color = strengthColor(createScore),183 style = PeachType.labelMono,184 modifier = Modifier.fillMaxWidth(),185 textAlign = TextAlign.Center,186 )187 }188 }189190 Text(191 text = state.error ?: " ",192 color = peachColors.Breach,193 style = PeachType.labelMono.copy(fontSize = 11.sp),194 textAlign = TextAlign.Center,195 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),196 )197198 Spacer(Modifier.height(4.dp))199 AccentButton(200 label = if (state.hasVault) "unlock" else "create vault",201 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),202 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },203 )204205 if (state.hasVault && canBiometric && activity != null) {206 Spacer(Modifier.height(12.dp))207 Text(208 text = "Use biometrics",209 color = accent,210 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),211 textAlign = TextAlign.Center,212 modifier = Modifier.fillMaxWidth()213 .clip(RoundedCornerShape(8.dp))214 .clickable { vm.biometricUnlock(activity) }215 .padding(vertical = 8.dp),216 )217 }218219 if (state.hasVault) {220 Spacer(Modifier.height(8.dp))221 Text(222 text = "Forgot your password?",223 color = peachColors.T2,224 style = PeachType.labelMono.copy(fontSize = 11.sp),225 textAlign = TextAlign.Center,226 modifier = Modifier.fillMaxWidth()227 .clip(RoundedCornerShape(8.dp))228 .clickable { onNavigate(PeachNav.RecoveryEntry) }229 .padding(vertical = 8.dp),230 )231 }232 }233234 if (!state.hasVault) {235 Spacer(Modifier.height(24.dp))236 RestoreTabs(onNavigate = onNavigate)237 }238 }239 }240}241242@Composable243private fun FieldLabel(text: String) {244 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))245}246247248private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)249250@Composable251private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {252 Column(253 modifier254 .fillMaxWidth()255 .clip(RoundedCornerShape(18.dp))256 .background(Color(0xE0131109))257 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))258 .padding(20.dp),259 content = content,260 )261}262263@Composable264private fun UnderlineField(265 value: String,266 onChange: (String) -> Unit,267 placeholder: String,268 textStyle: TextStyle,269 imeAction: ImeAction,270 onImeAction: () -> Unit,271) {272 val accent = LocalPeachAccent.current273 val interaction = remember { MutableInteractionSource() }274 val focused by interaction.collectIsFocusedAsState()275 val hairline = peachColors.T3276 BasicTextField(277 value = value,278 onValueChange = onChange,279 singleLine = true,280 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),281 cursorBrush = SolidColor(accent),282 visualTransformation = PasswordVisualTransformation(),283 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),284 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),285 interactionSource = interaction,286 modifier = Modifier287 .fillMaxWidth()288 .drawBehind {289 drawRect(290 color = if (focused) accent else hairline,291 topLeft = Offset(0f, size.height - 1.dp.toPx()),292 size = Size(size.width, 1.dp.toPx()),293 )294 }295 .padding(vertical = 8.dp),296 decorationBox = { inner ->297 Box(contentAlignment = Alignment.Center) {298 if (value.isEmpty()) {299 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))300 }301 inner()302 }303 },304 )305}306307@Composable308private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {309 val accent = LocalPeachAccent.current310 Box(311 modifier = Modifier312 .fillMaxWidth()313 .alpha(if (enabled) 1f else 0.5f)314 .clip(RoundedCornerShape(10.dp))315 .background(accent)316 .clickable(enabled = enabled, onClick = onClick)317 .padding(vertical = 13.dp),318 contentAlignment = Alignment.Center,319 ) {320 Text(321 label,322 color = Color.White,323 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),324 )325 }326}327328@Composable329private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {330 val tabs = listOf(331 "PeachScript" to PeachNav.Import,332 "CSV" to PeachNav.Import,333 "From Phone" to PeachNav.ChirpPair,334 "Codex" to PeachNav.CodexRestore,335 )336 Column(horizontalAlignment = Alignment.CenterHorizontally) {337 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)338 Spacer(Modifier.height(10.dp))339 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {340 tabs.forEach { (label, dest) ->341 Box(342 modifier = Modifier343 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))344 .clickable { onNavigate(dest) }345 .padding(horizontal = 10.dp, vertical = 8.dp),346 ) {347 Text(label, color = peachColors.T1, style = PeachType.sortPill)348 }349 }350 }351 }352}