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.ExperimentalLayoutApi14import androidx.compose.foundation.layout.FlowRow15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.Spacer17import androidx.compose.foundation.layout.fillMaxHeight18import androidx.compose.foundation.layout.fillMaxSize19import androidx.compose.foundation.layout.offset20import androidx.compose.foundation.layout.fillMaxWidth21import androidx.compose.foundation.layout.height22import androidx.compose.foundation.layout.padding23import androidx.compose.foundation.layout.systemBarsPadding24import androidx.compose.foundation.layout.widthIn25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.foundation.text.KeyboardActions28import androidx.compose.foundation.text.KeyboardOptions29import androidx.compose.material3.Text30import androidx.compose.runtime.Composable31import androidx.compose.runtime.LaunchedEffect32import androidx.compose.runtime.getValue33import androidx.compose.runtime.mutableStateOf34import androidx.compose.runtime.remember35import androidx.compose.runtime.setValue36import androidx.compose.ui.Alignment37import androidx.compose.ui.Modifier38import androidx.compose.ui.draw.alpha39import androidx.compose.ui.draw.clip40import androidx.compose.ui.draw.drawBehind41import androidx.compose.ui.geometry.Offset42import androidx.compose.ui.geometry.Size43import androidx.compose.ui.graphics.Color44import androidx.compose.ui.graphics.SolidColor45import androidx.compose.ui.text.TextStyle46import androidx.compose.ui.text.input.ImeAction47import androidx.compose.ui.text.input.KeyboardType48import androidx.compose.ui.text.input.PasswordVisualTransformation49import androidx.compose.ui.text.input.VisualTransformation50import androidx.compose.ui.text.style.TextAlign51import androidx.compose.ui.unit.IntOffset52import androidx.compose.ui.unit.dp53import androidx.compose.ui.unit.em54import androidx.compose.ui.unit.sp55import androidx.hilt.navigation.compose.hiltViewModel56import androidx.compose.runtime.collectAsState57import androidx.compose.ui.platform.LocalContext58import androidx.fragment.app.FragmentActivity59import com.peachpasswords.ui.PeachNav60import com.peachpasswords.ui.components.PeachMark61import com.peachpasswords.ui.components.PeachWordmark62import com.peachpasswords.ui.components.RuneGridBackground63import com.peachpasswords.ui.theme.LocalPeachAccent64import com.peachpasswords.ui.theme.PeachSpacing65import com.peachpasswords.ui.theme.peachColors66import com.peachpasswords.ui.security.StrengthBar67import com.peachpasswords.ui.security.scorePassword68import com.peachpasswords.ui.security.strengthColor69import com.peachpasswords.ui.security.strengthLabel70import com.peachpasswords.ui.theme.PeachType71import com.peachpasswords.ui.theme.ShellBackground7273747576777879808182internal sealed class UnlockRoute {83 84 data object RecoverySetup : UnlockRoute()85 86 data object Defer : UnlockRoute()87 88 data class PostUnlock(val destination: PeachNav) : UnlockRoute()89}90919293949596979899100internal fun routeOnUnlock(101 state: LockUiState,102 autofillPending: Boolean,103 postUnlock: () -> PeachNav,104): UnlockRoute = when {105 state.created -> UnlockRoute.RecoverySetup106 autofillPending -> UnlockRoute.Defer107 else -> UnlockRoute.PostUnlock(postUnlock())108}109110@Composable111fun LockScreen(112 onUnlocked: (PeachNav) -> Unit,113 onNavigate: (PeachNav) -> Unit,114 115116117118119120121122123 autofillPending: Boolean = false,124 vm: LockViewModel = hiltViewModel(),125) {126 val state by vm.state.collectAsState()127 val accent = LocalPeachAccent.current128 val context = LocalContext.current129 val activity = context as? FragmentActivity130 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }131132 var password by remember { mutableStateOf("") }133 var confirm by remember { mutableStateOf("") }134 var reveal by remember { mutableStateOf(false) }135 var pinMode by remember(state.pinEnabled, state.pinPreferred) { mutableStateOf(state.pinPreferred) }136 val createScore = remember(password) { scorePassword(password) }137 138 139 140 val createBlocker = if (state.hasVault || password.isEmpty()) null141 else createValidationError(password, confirm, createScore)142143 144 145 146 147 148 149 LaunchedEffect(state.unlocked) {150 if (!state.unlocked) return@LaunchedEffect151 when (val route = routeOnUnlock(state, autofillPending, vm::postUnlockDestination)) {152 UnlockRoute.RecoverySetup -> onNavigate(PeachNav.RecoverySetup)153 UnlockRoute.Defer -> Unit154 is UnlockRoute.PostUnlock -> onUnlocked(route.destination)155 }156 }157158 159 LaunchedEffect(canBiometric) {160 if (canBiometric && activity != null && !state.unlocked) {161 kotlinx.coroutines.delay(300)162 vm.biometricUnlock(activity)163 }164 }165166 167 val shake = remember { Animatable(0f) }168 LaunchedEffect(state.shakeNonce) {169 if (state.shakeNonce > 0) {170 shake.animateTo(171 targetValue = 0f,172 animationSpec = keyframes {173 durationMillis = 400174 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400175 },176 )177 }178 }179180 ShellBackground {181 RuneGridBackground(modifier = Modifier.fillMaxSize())182183 PeachWordmark(184 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),185 isPro = state.isPro,186 )187188 Column(189 modifier = Modifier190 .align(Alignment.Center)191 .fillMaxHeight()192 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)193 .fillMaxWidth()194 .systemBarsPadding()195 .padding(horizontal = 28.dp),196 verticalArrangement = Arrangement.Center,197 horizontalAlignment = Alignment.CenterHorizontally,198 ) {199 200 PeachMark(size = 80.dp)201 Spacer(Modifier.height(18.dp))202 Text(203 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",204 color = peachColors.T2,205 style = PeachType.lockEyebrow,206 )207 Spacer(Modifier.height(10.dp))208 Text(209 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",210 color = peachColors.T0,211 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,212 textAlign = TextAlign.Center,213 )214 Spacer(Modifier.height(24.dp))215216 217 GlassPanel(218 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },219 ) {220 Row(verticalAlignment = Alignment.CenterVertically) {221 FieldLabel(222 when {223 state.hasVault && pinMode -> "PIN"224 state.hasVault -> "master password"225 else -> "create a master password"226 },227 )228 if (!state.hasVault) {229 Spacer(Modifier.weight(1f))230 Text(231 text = if (reveal) "hide" else "show",232 color = accent,233 style = PeachType.labelMono.copy(fontSize = 11.sp),234 modifier = Modifier235 .clip(RoundedCornerShape(6.dp))236 .clickable { reveal = !reveal }237 .padding(horizontal = 6.dp, vertical = 4.dp),238 )239 }240 }241 Spacer(Modifier.height(4.dp))242 UnderlineField(243 value = password,244 onChange = { password = it; vm.clearError() },245 placeholder = if (pinMode) "••••" else "••••••••••",246 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),247 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,248 onImeAction = {249 if (state.hasVault) {250 if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)251 }252 },253 keyboardType = if (pinMode) KeyboardType.NumberPassword else KeyboardType.Password,254 masked = state.hasVault || !reveal,255 )256257 if (!state.hasVault) {258 Spacer(Modifier.height(16.dp))259 FieldLabel("confirm password")260 Spacer(Modifier.height(4.dp))261 UnderlineField(262 value = confirm,263 onChange = { confirm = it; vm.clearError() },264 placeholder = "••••••••••",265 textStyle = mono18Spaced(),266 imeAction = ImeAction.Go,267 onImeAction = { vm.create(password, confirm) },268 masked = !reveal,269 )270271 Spacer(Modifier.height(14.dp))272 if (password.isEmpty()) {273 Text(274 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",275 color = peachColors.T2,276 style = PeachType.hint,277 textAlign = TextAlign.Center,278 )279 } else {280 StrengthBar(score = createScore)281 Spacer(Modifier.height(6.dp))282 Text(283 text = "strength: ${strengthLabel(createScore)}",284 color = strengthColor(createScore),285 style = PeachType.labelMono,286 modifier = Modifier.fillMaxWidth(),287 textAlign = TextAlign.Center,288 )289 }290 }291292 Text(293 text = state.error ?: " ",294 color = peachColors.Breach,295 style = PeachType.labelMono.copy(fontSize = 11.sp),296 textAlign = TextAlign.Center,297 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),298 )299300 Spacer(Modifier.height(4.dp))301 AccentButton(302 label = when {303 state.hasVault -> "unlock"304 state.loading -> "creating..."305 createBlocker != null -> createBlocker306 else -> "create vault"307 },308 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),309 onClick = {310 if (state.hasVault) {311 if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)312 } else {313 vm.create(password, confirm)314 }315 },316 )317318 if (state.hasVault && state.pinEnabled) {319 Spacer(Modifier.height(8.dp))320 Text(321 text = if (pinMode) "Use master password" else "Use PIN",322 color = accent,323 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),324 textAlign = TextAlign.Center,325 modifier = Modifier.fillMaxWidth()326 .clip(RoundedCornerShape(8.dp))327 .clickable { password = ""; pinMode = !pinMode }328 .padding(vertical = 8.dp),329 )330 }331332 if (state.hasVault && canBiometric && activity != null) {333 Spacer(Modifier.height(12.dp))334 Text(335 text = "Use biometrics",336 color = accent,337 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),338 textAlign = TextAlign.Center,339 modifier = Modifier.fillMaxWidth()340 .clip(RoundedCornerShape(8.dp))341 .clickable { vm.biometricUnlock(activity) }342 .padding(vertical = 8.dp),343 )344 }345346 if (state.hasVault) {347 Spacer(Modifier.height(8.dp))348 Text(349 text = "Forgot your password?",350 color = peachColors.T2,351 style = PeachType.labelMono.copy(fontSize = 11.sp),352 textAlign = TextAlign.Center,353 modifier = Modifier.fillMaxWidth()354 .clip(RoundedCornerShape(8.dp))355 .clickable { onNavigate(PeachNav.RecoveryEntry) }356 .padding(vertical = 8.dp),357 )358 }359 }360361 if (!state.hasVault) {362 Spacer(Modifier.height(24.dp))363 RestoreTabs(onNavigate = onNavigate)364 }365 }366 }367}368369@Composable370private fun FieldLabel(text: String) {371 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))372}373374375private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)376377@Composable378private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {379 Column(380 modifier381 .fillMaxWidth()382 .clip(RoundedCornerShape(18.dp))383 .background(Color(0xE0131109))384 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))385 .padding(20.dp),386 content = content,387 )388}389390@Composable391private fun UnderlineField(392 value: String,393 onChange: (String) -> Unit,394 placeholder: String,395 textStyle: TextStyle,396 imeAction: ImeAction,397 onImeAction: () -> Unit,398 keyboardType: KeyboardType = KeyboardType.Password,399 masked: Boolean = true,400) {401 val accent = LocalPeachAccent.current402 val interaction = remember { MutableInteractionSource() }403 val focused by interaction.collectIsFocusedAsState()404 val hairline = peachColors.T3405 BasicTextField(406 value = value,407 onValueChange = onChange,408 singleLine = true,409 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),410 cursorBrush = SolidColor(accent),411 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,412 keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),413 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),414 interactionSource = interaction,415 modifier = Modifier416 .fillMaxWidth()417 .drawBehind {418 drawRect(419 color = if (focused) accent else hairline,420 topLeft = Offset(0f, size.height - 1.dp.toPx()),421 size = Size(size.width, 1.dp.toPx()),422 )423 }424 .padding(vertical = 8.dp),425 decorationBox = { inner ->426 Box(contentAlignment = Alignment.Center) {427 if (value.isEmpty()) {428 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))429 }430 inner()431 }432 },433 )434}435436@Composable437private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {438 val accent = LocalPeachAccent.current439 Box(440 modifier = Modifier441 .fillMaxWidth()442 .alpha(if (enabled) 1f else 0.5f)443 .clip(RoundedCornerShape(10.dp))444 .background(accent)445 .clickable(enabled = enabled, onClick = onClick)446 .padding(vertical = 13.dp),447 contentAlignment = Alignment.Center,448 ) {449 Text(450 label,451 color = Color.White,452 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),453 )454 }455}456457@OptIn(ExperimentalLayoutApi::class)458@Composable459private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {460 val tabs = listOf(461 "PeachScript" to PeachNav.Import,462 "CSV" to PeachNav.Import,463 "Codex" to PeachNav.CodexRestore,464 )465 Column(horizontalAlignment = Alignment.CenterHorizontally) {466 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)467 Spacer(Modifier.height(10.dp))468 469 470 471 472 473 FlowRow(474 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),475 verticalArrangement = Arrangement.spacedBy(6.dp),476 ) {477 tabs.forEach { (label, dest) ->478 Box(479 modifier = Modifier480 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))481 .clickable { onNavigate(dest) }482 .padding(horizontal = 10.dp, vertical = 8.dp),483 ) {484 Text(485 label,486 color = peachColors.T1,487 style = PeachType.sortPill,488 maxLines = 1,489 softWrap = false,490 )491 }492 }493 }494 }495}