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.LockRuneString61import com.peachpasswords.ui.components.PeachMark62import com.peachpasswords.ui.components.PeachWordmark63import com.peachpasswords.ui.components.RuneGridBackground64import com.peachpasswords.ui.theme.LocalPeachAccent65import com.peachpasswords.ui.theme.PeachSpacing66import com.peachpasswords.ui.theme.peachColors67import com.peachpasswords.ui.security.StrengthBar68import com.peachpasswords.ui.security.scorePassword69import com.peachpasswords.ui.security.strengthColor70import com.peachpasswords.ui.security.strengthLabel71import com.peachpasswords.ui.theme.PeachType72import com.peachpasswords.ui.theme.ShellBackground7374757677787980818283internal sealed class UnlockRoute {84 85 data object RecoverySetup : UnlockRoute()86 87 data object Defer : UnlockRoute()88 89 data class PostUnlock(val destination: PeachNav) : UnlockRoute()90}919293949596979899100101internal fun routeOnUnlock(102 state: LockUiState,103 autofillPending: Boolean,104 postUnlock: () -> PeachNav,105): UnlockRoute = when {106 state.created -> UnlockRoute.RecoverySetup107 autofillPending -> UnlockRoute.Defer108 else -> UnlockRoute.PostUnlock(postUnlock())109}110111@Composable112fun LockScreen(113 onUnlocked: (PeachNav) -> Unit,114 onNavigate: (PeachNav) -> Unit,115 116117118119120121122123124 autofillPending: Boolean = false,125 vm: LockViewModel = hiltViewModel(),126) {127 val state by vm.state.collectAsState()128 val accent = LocalPeachAccent.current129 val context = LocalContext.current130 val activity = context as? FragmentActivity131 val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }132133 var password by remember { mutableStateOf("") }134 var confirm by remember { mutableStateOf("") }135 var reveal by remember { mutableStateOf(false) }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(14.dp))215 LockRuneString()216 Spacer(Modifier.height(24.dp))217218 219 GlassPanel(220 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },221 ) {222 Row(verticalAlignment = Alignment.CenterVertically) {223 FieldLabel(if (state.hasVault) "master password" else "create a master password")224 if (!state.hasVault) {225 Spacer(Modifier.weight(1f))226 Text(227 text = if (reveal) "hide" else "show",228 color = accent,229 style = PeachType.labelMono.copy(fontSize = 11.sp),230 modifier = Modifier231 .clip(RoundedCornerShape(6.dp))232 .clickable { reveal = !reveal }233 .padding(horizontal = 6.dp, vertical = 4.dp),234 )235 }236 }237 Spacer(Modifier.height(4.dp))238 UnderlineField(239 value = password,240 onChange = { password = it; vm.clearError() },241 placeholder = "••••••••••",242 textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),243 imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,244 onImeAction = { if (state.hasVault) vm.unlock(password) },245 masked = state.hasVault || !reveal,246 )247248 if (!state.hasVault) {249 Spacer(Modifier.height(16.dp))250 FieldLabel("confirm password")251 Spacer(Modifier.height(4.dp))252 UnderlineField(253 value = confirm,254 onChange = { confirm = it; vm.clearError() },255 placeholder = "••••••••••",256 textStyle = mono18Spaced(),257 imeAction = ImeAction.Go,258 onImeAction = { vm.create(password, confirm) },259 masked = !reveal,260 )261262 Spacer(Modifier.height(14.dp))263 if (password.isEmpty()) {264 Text(265 text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",266 color = peachColors.T2,267 style = PeachType.hint,268 textAlign = TextAlign.Center,269 )270 } else {271 StrengthBar(score = createScore)272 Spacer(Modifier.height(6.dp))273 Text(274 text = "strength: ${strengthLabel(createScore)}",275 color = strengthColor(createScore),276 style = PeachType.labelMono,277 modifier = Modifier.fillMaxWidth(),278 textAlign = TextAlign.Center,279 )280 }281 }282283 Text(284 text = state.error ?: " ",285 color = peachColors.Breach,286 style = PeachType.labelMono.copy(fontSize = 11.sp),287 textAlign = TextAlign.Center,288 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),289 )290291 Spacer(Modifier.height(4.dp))292 AccentButton(293 label = when {294 state.hasVault -> "unlock"295 state.loading -> "creating..."296 createBlocker != null -> createBlocker297 else -> "create vault"298 },299 enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),300 onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },301 )302303 if (state.hasVault && canBiometric && activity != null) {304 Spacer(Modifier.height(12.dp))305 Text(306 text = "Use biometrics",307 color = accent,308 style = PeachType.labelMono.copy(letterSpacing = 0.08.em),309 textAlign = TextAlign.Center,310 modifier = Modifier.fillMaxWidth()311 .clip(RoundedCornerShape(8.dp))312 .clickable { vm.biometricUnlock(activity) }313 .padding(vertical = 8.dp),314 )315 }316317 if (state.hasVault) {318 Spacer(Modifier.height(8.dp))319 Text(320 text = "Forgot your password?",321 color = peachColors.T2,322 style = PeachType.labelMono.copy(fontSize = 11.sp),323 textAlign = TextAlign.Center,324 modifier = Modifier.fillMaxWidth()325 .clip(RoundedCornerShape(8.dp))326 .clickable { onNavigate(PeachNav.RecoveryEntry) }327 .padding(vertical = 8.dp),328 )329 }330 }331332 if (!state.hasVault) {333 Spacer(Modifier.height(24.dp))334 RestoreTabs(onNavigate = onNavigate)335 }336 }337 }338}339340@Composable341private fun FieldLabel(text: String) {342 Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))343}344345346private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)347348@Composable349private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {350 Column(351 modifier352 .fillMaxWidth()353 .clip(RoundedCornerShape(18.dp))354 .background(Color(0xE0131109))355 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))356 .padding(20.dp),357 content = content,358 )359}360361@Composable362private fun UnderlineField(363 value: String,364 onChange: (String) -> Unit,365 placeholder: String,366 textStyle: TextStyle,367 imeAction: ImeAction,368 onImeAction: () -> Unit,369 masked: Boolean = true,370) {371 val accent = LocalPeachAccent.current372 val interaction = remember { MutableInteractionSource() }373 val focused by interaction.collectIsFocusedAsState()374 val hairline = peachColors.T3375 BasicTextField(376 value = value,377 onValueChange = onChange,378 singleLine = true,379 textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),380 cursorBrush = SolidColor(accent),381 visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,382 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),383 keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),384 interactionSource = interaction,385 modifier = Modifier386 .fillMaxWidth()387 .drawBehind {388 drawRect(389 color = if (focused) accent else hairline,390 topLeft = Offset(0f, size.height - 1.dp.toPx()),391 size = Size(size.width, 1.dp.toPx()),392 )393 }394 .padding(vertical = 8.dp),395 decorationBox = { inner ->396 Box(contentAlignment = Alignment.Center) {397 if (value.isEmpty()) {398 Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))399 }400 inner()401 }402 },403 )404}405406@Composable407private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {408 val accent = LocalPeachAccent.current409 Box(410 modifier = Modifier411 .fillMaxWidth()412 .alpha(if (enabled) 1f else 0.5f)413 .clip(RoundedCornerShape(10.dp))414 .background(accent)415 .clickable(enabled = enabled, onClick = onClick)416 .padding(vertical = 13.dp),417 contentAlignment = Alignment.Center,418 ) {419 Text(420 label,421 color = Color.White,422 style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),423 )424 }425}426427@OptIn(ExperimentalLayoutApi::class)428@Composable429private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {430 val tabs = listOf(431 "PeachScript" to PeachNav.Import,432 "CSV" to PeachNav.Import,433 "Codex" to PeachNav.CodexRestore,434 )435 Column(horizontalAlignment = Alignment.CenterHorizontally) {436 Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)437 Spacer(Modifier.height(10.dp))438 439 440 441 442 443 FlowRow(444 horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),445 verticalArrangement = Arrangement.spacedBy(6.dp),446 ) {447 tabs.forEach { (label, dest) ->448 Box(449 modifier = Modifier450 .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))451 .clickable { onNavigate(dest) }452 .padding(horizontal = 10.dp, vertical = 8.dp),453 ) {454 Text(455 label,456 color = peachColors.T1,457 style = PeachType.sortPill,458 maxLines = 1,459 softWrap = false,460 )461 }462 }463 }464 }465}