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.PeachSpacing59import com.peachpasswords.ui.theme.peachColors60import com.peachpasswords.ui.security.StrengthBar61import com.peachpasswords.ui.security.scorePassword62import com.peachpasswords.ui.security.strengthColor63import com.peachpasswords.ui.security.strengthLabel64import com.peachpasswords.ui.theme.PeachType65import com.peachpasswords.ui.theme.ShellBackground6667@Composable68fun LockScreen(69 onUnlocked: (PeachNav) -> Unit,70 onNavigate: (PeachNav) -> Unit,71 vm: LockViewModel = hiltViewModel(),72) {73 val state by vm.state.collectAsState()74 val accent = LocalPeachAccent.current75 val context = LocalContext.current76 val activity = context as? FragmentActivity77 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }7879 var password by remember { mutableStateOf("") }80 var confirm by remember { mutableStateOf("") }81 val createScore = remember(password) { scorePassword(password) }8283 LaunchedEffect(state.unlocked) {84 if (state.unlocked) {85 86 87 if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())88 }89 }9091 92 LaunchedEffect(canBiometric) {93 if (canBiometric && activity != null && !state.unlocked) {94 kotlinx.coroutines.delay(300)95 vm.biometricUnlock(activity)96 }97 }9899 100 val shake = remember { Animatable(0f) }101 LaunchedEffect(state.shakeNonce) {102 if (state.shakeNonce > 0) {103 shake.animateTo(104 targetValue = 0f,105 animationSpec = keyframes {106 durationMillis = 400107 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400108 },109 )110 }111 }112113 ShellBackground {114 115 Row(116 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),117 verticalAlignment = Alignment.CenterVertically,118 ) {119 PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))120 Text(121 text = "peach",122 color = accent,123 style = PeachType.wordmark,124 )125 }126127 Column(128 modifier = Modifier129 .fillMaxSize()130 .systemBarsPadding()131 .padding(horizontal = 28.dp),132 verticalArrangement = Arrangement.Center,133 horizontalAlignment = Alignment.CenterHorizontally,134 ) {135 136 PeachMark(size = 80.dp)137 Spacer(Modifier.height(18.dp))138 Text(139 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",140 color = peachColors.T2,141 style = PeachType.lockEyebrow,142 )143 Spacer(Modifier.height(10.dp))144 Text(145 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",146 color = peachColors.T0,147 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,148 textAlign = TextAlign.Center,149 )150 Spacer(Modifier.height(14.dp))151 LockRuneString()152 Spacer(Modifier.height(24.dp))153154 155 GlassPanel(156 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },157 ) {158 FieldLabel(if (state.hasVault) "master password" else "create a master password")159 Spacer(Modifier.height(4.dp))160 UnderlineField(161 value = password,162 onChange = { password = it; vm.clearError() },163 placeholder = "••••••••••",164 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),165 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,166 onImeAction = { if (state.hasVault) vm.unlock(password) },167 )168169 if (!state.hasVault) {170 Spacer(Modifier.height(16.dp))171 FieldLabel("confirm password")172 Spacer(Modifier.height(4.dp))173 UnderlineField(174 value = confirm,175 onChange = { confirm = it; vm.clearError() },176 placeholder = "••••••••••",177 textStyle = mono18Spaced(),178 imeAction = ImeAction.Go,179 onImeAction = { vm.create(password, confirm) },180 )181182 if (password.isNotEmpty()) {183 Spacer(Modifier.height(14.dp))184 StrengthBar(score = createScore)185 Spacer(Modifier.height(6.dp))186 Text(187 text = "strength: ${strengthLabel(createScore)}",188 color = strengthColor(createScore),189 style = PeachType.labelMono,190 modifier = Modifier.fillMaxWidth(),191 textAlign = TextAlign.Center,192 )193 }194 }195196 Text(197 text = state.error ?: " ",198 color = peachColors.Breach,199 style = PeachType.labelMono.copy(fontSize = 11.sp),200 textAlign = TextAlign.Center,201 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),202 )203204 Spacer(Modifier.height(4.dp))205 AccentButton(206 label = if (state.hasVault) "unlock" else "create vault",207 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),208 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },209 )210211 if (state.hasVault && canBiometric && activity != null) {212 Spacer(Modifier.height(12.dp))213 Text(214 text = "Use biometrics",215 color = accent,216 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),217 textAlign = TextAlign.Center,218 modifier = Modifier.fillMaxWidth()219 .clip(RoundedCornerShape(8.dp))220 .clickable { vm.biometricUnlock(activity) }221 .padding(vertical = 8.dp),222 )223 }224225 if (state.hasVault) {226 Spacer(Modifier.height(8.dp))227 Text(228 text = "Forgot your password?",229 color = peachColors.T2,230 style = PeachType.labelMono.copy(fontSize = 11.sp),231 textAlign = TextAlign.Center,232 modifier = Modifier.fillMaxWidth()233 .clip(RoundedCornerShape(8.dp))234 .clickable { onNavigate(PeachNav.RecoveryEntry) }235 .padding(vertical = 8.dp),236 )237 }238 }239240 if (!state.hasVault) {241 Spacer(Modifier.height(24.dp))242 RestoreTabs(onNavigate = onNavigate)243 }244 }245 }246}247248@Composable249private fun FieldLabel(text: String) {250 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))251}252253254private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)255256@Composable257private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {258 Column(259 modifier260 .fillMaxWidth()261 .clip(RoundedCornerShape(18.dp))262 .background(Color(0xE0131109))263 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))264 .padding(20.dp),265 content = content,266 )267}268269@Composable270private fun UnderlineField(271 value: String,272 onChange: (String) -> Unit,273 placeholder: String,274 textStyle: TextStyle,275 imeAction: ImeAction,276 onImeAction: () -> Unit,277) {278 val accent = LocalPeachAccent.current279 val interaction = remember { MutableInteractionSource() }280 val focused by interaction.collectIsFocusedAsState()281 val hairline = peachColors.T3282 BasicTextField(283 value = value,284 onValueChange = onChange,285 singleLine = true,286 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),287 cursorBrush = SolidColor(accent),288 visualTransformation = PasswordVisualTransformation(),289 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),290 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),291 interactionSource = interaction,292 modifier = Modifier293 .fillMaxWidth()294 .drawBehind {295 drawRect(296 color = if (focused) accent else hairline,297 topLeft = Offset(0f, size.height - 1.dp.toPx()),298 size = Size(size.width, 1.dp.toPx()),299 )300 }301 .padding(vertical = 8.dp),302 decorationBox = { inner ->303 Box(contentAlignment = Alignment.Center) {304 if (value.isEmpty()) {305 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))306 }307 inner()308 }309 },310 )311}312313@Composable314private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {315 val accent = LocalPeachAccent.current316 Box(317 modifier = Modifier318 .fillMaxWidth()319 .alpha(if (enabled) 1f else 0.5f)320 .clip(RoundedCornerShape(10.dp))321 .background(accent)322 .clickable(enabled = enabled, onClick = onClick)323 .padding(vertical = 13.dp),324 contentAlignment = Alignment.Center,325 ) {326 Text(327 label,328 color = Color.White,329 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),330 )331 }332}333334@Composable335private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {336 val tabs = listOf(337 "PeachScript" to PeachNav.Import,338 "CSV" to PeachNav.Import,339 "From Phone" to PeachNav.ChirpPair,340 "Codex" to PeachNav.CodexRestore,341 )342 Column(horizontalAlignment = Alignment.CenterHorizontally) {343 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)344 Spacer(Modifier.height(10.dp))345 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {346 tabs.forEach { (label, dest) ->347 Box(348 modifier = Modifier349 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))350 .clickable { onNavigate(dest) }351 .padding(horizontal = 10.dp, vertical = 8.dp),352 ) {353 Text(label, color = peachColors.T1, style = PeachType.sortPill)354 }355 }356 }357 }358}