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 val createScore = remember(password) { scorePassword(password) }136 137 138 139 val createBlocker = if (state.hasVault || password.isEmpty()) null140 else createValidationError(password, confirm, createScore)141142 143 144 145 146 147 148 LaunchedEffect(state.unlocked) {149 if (!state.unlocked) return@LaunchedEffect150 when (val route = routeOnUnlock(state, autofillPending, vm::postUnlockDestination)) {151 UnlockRoute.RecoverySetup -> onNavigate(PeachNav.RecoverySetup)152 UnlockRoute.Defer -> Unit153 is UnlockRoute.PostUnlock -> onUnlocked(route.destination)154 }155 }156157 158 LaunchedEffect(canBiometric) {159 if (canBiometric && activity != null && !state.unlocked) {160 kotlinx.coroutines.delay(300)161 vm.biometricUnlock(activity)162 }163 }164165 166 val shake = remember { Animatable(0f) }167 LaunchedEffect(state.shakeNonce) {168 if (state.shakeNonce > 0) {169 shake.animateTo(170 targetValue = 0f,171 animationSpec = keyframes {172 durationMillis = 400173 -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400174 },175 )176 }177 }178179 ShellBackground {180 RuneGridBackground(modifier = Modifier.fillMaxSize())181182 PeachWordmark(183 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),184 isPro = state.isPro,185 )186187 Column(188 modifier = Modifier189 .align(Alignment.Center)190 .fillMaxHeight()191 .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)192 .fillMaxWidth()193 .systemBarsPadding()194 .padding(horizontal = 28.dp),195 verticalArrangement = Arrangement.Center,196 horizontalAlignment = Alignment.CenterHorizontally,197 ) {198 199 PeachMark(size = 80.dp)200 Spacer(Modifier.height(18.dp))201 Text(202 text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",203 color = peachColors.T2,204 style = PeachType.lockEyebrow,205 )206 Spacer(Modifier.height(10.dp))207 Text(208 text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",209 color = peachColors.T0,210 style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,211 textAlign = TextAlign.Center,212 )213 Spacer(Modifier.height(24.dp))214215 216 GlassPanel(217 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },218 ) {219 Row(verticalAlignment = Alignment.CenterVertically) {220 FieldLabel(if (state.hasVault) "master password" else "create a master password")221 if (!state.hasVault) {222 Spacer(Modifier.weight(1f))223 Text(224 text = if (reveal) "hide" else "show",225 color = accent,226 style = PeachType.labelMono.copy(fontSize = 11.sp),227 modifier = Modifier228 .clip(RoundedCornerShape(6.dp))229 .clickable { reveal = !reveal }230 .padding(horizontal = 6.dp, vertical = 4.dp),231 )232 }233 }234 Spacer(Modifier.height(4.dp))235 UnderlineField(236 value = password,237 onChange = { password = it; vm.clearError() },238 placeholder = "••••••••••",239 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),240 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,241 onImeAction = { if (state.hasVault) vm.unlock(password) },242 masked = state.hasVault || !reveal,243 )244245 if (!state.hasVault) {246 Spacer(Modifier.height(16.dp))247 FieldLabel("confirm password")248 Spacer(Modifier.height(4.dp))249 UnderlineField(250 value = confirm,251 onChange = { confirm = it; vm.clearError() },252 placeholder = "••••••••••",253 textStyle = mono18Spaced(),254 imeAction = ImeAction.Go,255 onImeAction = { vm.create(password, confirm) },256 masked = !reveal,257 )258259 Spacer(Modifier.height(14.dp))260 if (password.isEmpty()) {261 Text(262 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",263 color = peachColors.T2,264 style = PeachType.hint,265 textAlign = TextAlign.Center,266 )267 } else {268 StrengthBar(score = createScore)269 Spacer(Modifier.height(6.dp))270 Text(271 text = "strength: ${strengthLabel(createScore)}",272 color = strengthColor(createScore),273 style = PeachType.labelMono,274 modifier = Modifier.fillMaxWidth(),275 textAlign = TextAlign.Center,276 )277 }278 }279280 Text(281 text = state.error ?: " ",282 color = peachColors.Breach,283 style = PeachType.labelMono.copy(fontSize = 11.sp),284 textAlign = TextAlign.Center,285 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),286 )287288 Spacer(Modifier.height(4.dp))289 AccentButton(290 label = when {291 state.hasVault -> "unlock"292 state.loading -> "creating..."293 createBlocker != null -> createBlocker294 else -> "create vault"295 },296 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),297 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },298 )299300 if (state.hasVault && canBiometric && activity != null) {301 Spacer(Modifier.height(12.dp))302 Text(303 text = "Use biometrics",304 color = accent,305 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),306 textAlign = TextAlign.Center,307 modifier = Modifier.fillMaxWidth()308 .clip(RoundedCornerShape(8.dp))309 .clickable { vm.biometricUnlock(activity) }310 .padding(vertical = 8.dp),311 )312 }313314 if (state.hasVault) {315 Spacer(Modifier.height(8.dp))316 Text(317 text = "Forgot your password?",318 color = peachColors.T2,319 style = PeachType.labelMono.copy(fontSize = 11.sp),320 textAlign = TextAlign.Center,321 modifier = Modifier.fillMaxWidth()322 .clip(RoundedCornerShape(8.dp))323 .clickable { onNavigate(PeachNav.RecoveryEntry) }324 .padding(vertical = 8.dp),325 )326 }327 }328329 if (!state.hasVault) {330 Spacer(Modifier.height(24.dp))331 RestoreTabs(onNavigate = onNavigate)332 }333 }334 }335}336337@Composable338private fun FieldLabel(text: String) {339 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))340}341342343private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)344345@Composable346private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {347 Column(348 modifier349 .fillMaxWidth()350 .clip(RoundedCornerShape(18.dp))351 .background(Color(0xE0131109))352 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))353 .padding(20.dp),354 content = content,355 )356}357358@Composable359private fun UnderlineField(360 value: String,361 onChange: (String) -> Unit,362 placeholder: String,363 textStyle: TextStyle,364 imeAction: ImeAction,365 onImeAction: () -> Unit,366 masked: Boolean = true,367) {368 val accent = LocalPeachAccent.current369 val interaction = remember { MutableInteractionSource() }370 val focused by interaction.collectIsFocusedAsState()371 val hairline = peachColors.T3372 BasicTextField(373 value = value,374 onValueChange = onChange,375 singleLine = true,376 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),377 cursorBrush = SolidColor(accent),378 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,379 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),380 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),381 interactionSource = interaction,382 modifier = Modifier383 .fillMaxWidth()384 .drawBehind {385 drawRect(386 color = if (focused) accent else hairline,387 topLeft = Offset(0f, size.height - 1.dp.toPx()),388 size = Size(size.width, 1.dp.toPx()),389 )390 }391 .padding(vertical = 8.dp),392 decorationBox = { inner ->393 Box(contentAlignment = Alignment.Center) {394 if (value.isEmpty()) {395 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))396 }397 inner()398 }399 },400 )401}402403@Composable404private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {405 val accent = LocalPeachAccent.current406 Box(407 modifier = Modifier408 .fillMaxWidth()409 .alpha(if (enabled) 1f else 0.5f)410 .clip(RoundedCornerShape(10.dp))411 .background(accent)412 .clickable(enabled = enabled, onClick = onClick)413 .padding(vertical = 13.dp),414 contentAlignment = Alignment.Center,415 ) {416 Text(417 label,418 color = Color.White,419 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),420 )421 }422}423424@OptIn(ExperimentalLayoutApi::class)425@Composable426private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {427 val tabs = listOf(428 "PeachScript" to PeachNav.Import,429 "CSV" to PeachNav.Import,430 "Codex" to PeachNav.CodexRestore,431 )432 Column(horizontalAlignment = Alignment.CenterHorizontally) {433 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)434 Spacer(Modifier.height(10.dp))435 436 437 438 439 440 FlowRow(441 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),442 verticalArrangement = Arrangement.spacedBy(6.dp),443 ) {444 tabs.forEach { (label, dest) ->445 Box(446 modifier = Modifier447 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))448 .clickable { onNavigate(dest) }449 .padding(horizontal = 10.dp, vertical = 8.dp),450 ) {451 Text(452 label,453 color = peachColors.T1,454 style = PeachType.sortPill,455 maxLines = 1,456 softWrap = false,457 )458 }459 }460 }461 }462}