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 shortcutPending: Boolean = false,104 postUnlock: () -> PeachNav,105): UnlockRoute = when {106 state.created -> UnlockRoute.RecoverySetup107 autofillPending || shortcutPending -> UnlockRoute.Defer108 else -> UnlockRoute.PostUnlock(postUnlock())109}110111@Composable112fun LockScreen(113 onUnlocked: (PeachNav) -> Unit,114 onNavigate: (PeachNav) -> Unit,115 116117118119120121122123124 autofillPending: Boolean = false,125 126 shortcutPending: Boolean = false,127 vm: LockViewModel = hiltViewModel(),128) {129 val state by vm.state.collectAsState()130 val accent = LocalPeachAccent.current131 val context = LocalContext.current132 val activity = context as? FragmentActivity133 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }134135 var password by remember { mutableStateOf("") }136 var confirm by remember { mutableStateOf("") }137 var reveal by remember { mutableStateOf(false) }138 var pinMode by remember(state.pinEnabled, state.pinPreferred) { mutableStateOf(state.pinPreferred) }139 val createScore = remember(password) { scorePassword(password) }140 141 142 143 val createBlocker = if (state.hasVault || password.isEmpty()) null144 else createValidationError(password, confirm, createScore)145146 147 148 149 150 151 152 LaunchedEffect(state.unlocked) {153 if (!state.unlocked) return@LaunchedEffect154 when (val route = routeOnUnlock(state, autofillPending, shortcutPending, vm::postUnlockDestination)) {155 UnlockRoute.RecoverySetup -> onNavigate(PeachNav.RecoverySetup)156 UnlockRoute.Defer -> Unit157 is UnlockRoute.PostUnlock -> onUnlocked(route.destination)158 }159 }160161 162 LaunchedEffect(canBiometric) {163 if (canBiometric && activity != null && !state.unlocked) {164 kotlinx.coroutines.delay(300)165 vm.biometricUnlock(activity)166 }167 }168169 170 val shake = remember { Animatable(0f) }171 LaunchedEffect(state.shakeNonce) {172 if (state.shakeNonce > 0) {173 shake.animateTo(174 targetValue = 0f,175 animationSpec = keyframes {176 durationMillis = 400177 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400178 },179 )180 }181 }182183 ShellBackground {184 RuneGridBackground(modifier = Modifier.fillMaxSize())185186 PeachWordmark(187 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),188 isPro = state.isPro,189 )190191 Column(192 modifier = Modifier193 .align(Alignment.Center)194 .fillMaxHeight()195 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)196 .fillMaxWidth()197 .systemBarsPadding()198 .padding(horizontal = 28.dp),199 verticalArrangement = Arrangement.Center,200 horizontalAlignment = Alignment.CenterHorizontally,201 ) {202 203 PeachMark(size = 80.dp)204 Spacer(Modifier.height(18.dp))205 Text(206 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",207 color = peachColors.T2,208 style = PeachType.lockEyebrow,209 )210 Spacer(Modifier.height(10.dp))211 Text(212 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",213 color = peachColors.T0,214 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,215 textAlign = TextAlign.Center,216 )217 Spacer(Modifier.height(24.dp))218219 220 GlassPanel(221 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },222 ) {223 Row(verticalAlignment = Alignment.CenterVertically) {224 FieldLabel(225 when {226 state.hasVault && pinMode -> "PIN"227 state.hasVault -> "master password"228 else -> "create a master password"229 },230 )231 if (!state.hasVault) {232 Spacer(Modifier.weight(1f))233 Text(234 text = if (reveal) "hide" else "show",235 color = accent,236 style = PeachType.labelMono.copy(fontSize = 11.sp),237 modifier = Modifier238 .clip(RoundedCornerShape(6.dp))239 .clickable { reveal = !reveal }240 .padding(horizontal = 6.dp, vertical = 4.dp),241 )242 }243 }244 Spacer(Modifier.height(4.dp))245 UnderlineField(246 value = password,247 onChange = { password = it; vm.clearError() },248 placeholder = if (pinMode) "••••" else "••••••••••",249 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),250 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,251 onImeAction = {252 if (state.hasVault) {253 if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)254 }255 },256 keyboardType = if (pinMode) KeyboardType.NumberPassword else KeyboardType.Password,257 masked = state.hasVault || !reveal,258 )259260 if (!state.hasVault) {261 Spacer(Modifier.height(16.dp))262 FieldLabel("confirm password")263 Spacer(Modifier.height(4.dp))264 UnderlineField(265 value = confirm,266 onChange = { confirm = it; vm.clearError() },267 placeholder = "••••••••••",268 textStyle = mono18Spaced(),269 imeAction = ImeAction.Go,270 onImeAction = { vm.create(password, confirm) },271 masked = !reveal,272 )273274 Spacer(Modifier.height(14.dp))275 if (password.isEmpty()) {276 Text(277 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",278 color = peachColors.T2,279 style = PeachType.hint,280 textAlign = TextAlign.Center,281 )282 } else {283 StrengthBar(score = createScore)284 Spacer(Modifier.height(6.dp))285 Text(286 text = "strength: ${strengthLabel(createScore)}",287 color = strengthColor(createScore),288 style = PeachType.labelMono,289 modifier = Modifier.fillMaxWidth(),290 textAlign = TextAlign.Center,291 )292 }293 }294295 Text(296 text = state.error ?: " ",297 color = peachColors.Breach,298 style = PeachType.labelMono.copy(fontSize = 11.sp),299 textAlign = TextAlign.Center,300 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),301 )302303 Spacer(Modifier.height(4.dp))304 AccentButton(305 label = when {306 state.hasVault -> "unlock"307 state.loading -> "creating..."308 createBlocker != null -> createBlocker309 else -> "create vault"310 },311 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),312 onClick = {313 if (state.hasVault) {314 if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)315 } else {316 vm.create(password, confirm)317 }318 },319 )320321 if (state.hasVault && state.pinEnabled) {322 Spacer(Modifier.height(8.dp))323 Text(324 text = if (pinMode) "Use master password" else "Use PIN",325 color = accent,326 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),327 textAlign = TextAlign.Center,328 modifier = Modifier.fillMaxWidth()329 .clip(RoundedCornerShape(8.dp))330 .clickable { password = ""; pinMode = !pinMode }331 .padding(vertical = 8.dp),332 )333 }334335 if (state.hasVault && canBiometric && activity != null) {336 Spacer(Modifier.height(12.dp))337 Text(338 text = "Use biometrics",339 color = accent,340 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),341 textAlign = TextAlign.Center,342 modifier = Modifier.fillMaxWidth()343 .clip(RoundedCornerShape(8.dp))344 .clickable { vm.biometricUnlock(activity) }345 .padding(vertical = 8.dp),346 )347 }348349 if (state.hasVault) {350 Spacer(Modifier.height(8.dp))351 Text(352 text = "Forgot your password?",353 color = peachColors.T2,354 style = PeachType.labelMono.copy(fontSize = 11.sp),355 textAlign = TextAlign.Center,356 modifier = Modifier.fillMaxWidth()357 .clip(RoundedCornerShape(8.dp))358 .clickable { onNavigate(PeachNav.RecoveryEntry) }359 .padding(vertical = 8.dp),360 )361 }362 }363364 if (!state.hasVault) {365 Spacer(Modifier.height(24.dp))366 RestoreTabs(onNavigate = onNavigate)367 }368 }369 }370}371372@Composable373private fun FieldLabel(text: String) {374 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))375}376377378private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)379380@Composable381private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {382 Column(383 modifier384 .fillMaxWidth()385 .clip(RoundedCornerShape(18.dp))386 .background(Color(0xE0131109))387 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))388 .padding(20.dp),389 content = content,390 )391}392393@Composable394private fun UnderlineField(395 value: String,396 onChange: (String) -> Unit,397 placeholder: String,398 textStyle: TextStyle,399 imeAction: ImeAction,400 onImeAction: () -> Unit,401 keyboardType: KeyboardType = KeyboardType.Password,402 masked: Boolean = true,403) {404 val accent = LocalPeachAccent.current405 val interaction = remember { MutableInteractionSource() }406 val focused by interaction.collectIsFocusedAsState()407 val hairline = peachColors.T3408 BasicTextField(409 value = value,410 onValueChange = onChange,411 singleLine = true,412 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),413 cursorBrush = SolidColor(accent),414 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,415 keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),416 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),417 interactionSource = interaction,418 modifier = Modifier419 .fillMaxWidth()420 .drawBehind {421 drawRect(422 color = if (focused) accent else hairline,423 topLeft = Offset(0f, size.height - 1.dp.toPx()),424 size = Size(size.width, 1.dp.toPx()),425 )426 }427 .padding(vertical = 8.dp),428 decorationBox = { inner ->429 Box(contentAlignment = Alignment.Center) {430 if (value.isEmpty()) {431 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))432 }433 inner()434 }435 },436 )437}438439@Composable440private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {441 val accent = LocalPeachAccent.current442 Box(443 modifier = Modifier444 .fillMaxWidth()445 .alpha(if (enabled) 1f else 0.5f)446 .clip(RoundedCornerShape(10.dp))447 .background(accent)448 .clickable(enabled = enabled, onClick = onClick)449 .padding(vertical = 13.dp),450 contentAlignment = Alignment.Center,451 ) {452 Text(453 label,454 color = Color.White,455 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),456 )457 }458}459460@OptIn(ExperimentalLayoutApi::class)461@Composable462private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {463 val tabs = listOf(464 "PeachScript" to PeachNav.Import,465 "CSV" to PeachNav.Import,466 "Codex" to PeachNav.CodexRestore,467 )468 Column(horizontalAlignment = Alignment.CenterHorizontally) {469 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)470 Spacer(Modifier.height(10.dp))471 472 473 474 475 476 FlowRow(477 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),478 verticalArrangement = Arrangement.spacedBy(6.dp),479 ) {480 tabs.forEach { (label, dest) ->481 Box(482 modifier = Modifier483 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))484 .clickable { onNavigate(dest) }485 .padding(horizontal = 10.dp, vertical = 8.dp),486 ) {487 Text(488 label,489 color = peachColors.T1,490 style = PeachType.sortPill,491 maxLines = 1,492 softWrap = false,493 )494 }495 }496 }497 }498}