LockScreen.kt

21.4 KB · raw

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.ShellBackground7273/**74 * Where the unlock transition should land. Extracted from the LaunchedEffect body so75 * the priority order (created > autofill > postUnlock) is unit-testable without a76 * Compose runtime; the LaunchedEffect is a thin dispatcher that calls77 * [routeOnUnlock] once per unlock transition.78 *79 * [Defer] means "let the NavHost handle it" — used when an autofill-driven navigation80 * is queued at the moment of unlock so we don't clobber it with popUpTo(0).81 */82internal sealed class UnlockRoute {83    /** A freshly created vault must walk through recovery-word setup before the list. */84    data object RecoverySetup : UnlockRoute()85    /** A queued post-unlock navigation is in flight; the NavHost owns the destination. */86    data object Defer : UnlockRoute()87    /** Ordinary unlock: route to the post-unlock destination the ViewModel computed. */88    data class PostUnlock(val destination: PeachNav) : UnlockRoute()89}9091/**92 * Decide what the lock screen does when [LockUiState.unlocked] flips true. The93 * priority order matters: a newly-created vault always goes to recovery-word setup,94 * an autofill-pending unlock defers to the NavHost, everything else lands on the95 * ViewModel-computed post-unlock destination.96 *97 * `postUnlock` is a lambda so the function is pure and the lazy destination lookup98 * is observable in tests (a [UnlockRoute.Defer] must never invoke it).99 */100internal 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    /**116     * True when an autofill-driven navigation (e.g. the "+ Save to Peach"117     * create chip) is queued and waiting for the vault to unlock. When set,118     * [onUnlocked] is skipped: the autofill nav path at the PeachAppNavHost119     * scope will navigate to its own destination (the new-entry editor with120     * URL prefilled), and we must not clobber it with the last-viewed entry.121     * Two LaunchedEffects firing on the same unlock would otherwise race --122     * the LockScreen one wins because [onUnlocked] uses popUpTo(0) inclusive.123     */124    autofillPending: Boolean = false,125    /** True when a launcher shortcut is waiting for this unlock transition. */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    // Why the create button is disabled, shown on the button itself. Without141    // it the button just silently does nothing (browser parity: Lock.tsx's142    // createBlockerMessage). Null before typing -- no scolding on an empty form.143    val createBlocker = if (state.hasVault || password.isEmpty()) null144                        else createValidationError(password, confirm, createScore)145146    // Keyed on `state.unlocked` only: the autofill nav path in PeachAppNavHost consumes147    // `newEntryRequest` synchronously, which would otherwise flip `autofillPending` to148    // false mid-unlock and re-fire this effect — landing the user on the vault list149    // instead of the new-entry editor. Capturing `autofillPending` at the unlock150    // transition (rather than re-reading it on every key change) makes the two effects151    // cooperative instead of racing.152    LaunchedEffect(state.unlocked) {153        if (!state.unlocked) return@LaunchedEffect154        password = ""155        confirm = ""156        when (val route = routeOnUnlock(state, autofillPending, shortcutPending, vm::postUnlockDestination)) {157            UnlockRoute.RecoverySetup -> onNavigate(PeachNav.RecoverySetup)158            UnlockRoute.Defer -> Unit159            is UnlockRoute.PostUnlock -> onUnlocked(route.destination)160        }161    }162163    // Auto-offer biometric unlock shortly after the lock screen appears.164    LaunchedEffect(canBiometric) {165        if (canBiometric && activity != null && !state.unlocked) {166            kotlinx.coroutines.delay(300)167            vm.biometricUnlock(activity)168        }169    }170171    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).172    val shake = remember { Animatable(0f) }173    LaunchedEffect(state.shakeNonce) {174        if (state.shakeNonce > 0) {175            shake.animateTo(176                targetValue = 0f,177                animationSpec = keyframes {178                    durationMillis = 400179                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400180                },181            )182        }183    }184185    ShellBackground {186        RuneGridBackground(modifier = Modifier.fillMaxSize())187188        PeachWordmark(189            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),190            isPro = state.isPro,191        )192193        Column(194            modifier = Modifier195                .align(Alignment.Center)196                .fillMaxHeight()197                .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)198                .fillMaxWidth()199                .systemBarsPadding()200                .padding(horizontal = 28.dp),201            verticalArrangement = Arrangement.Center,202            horizontalAlignment = Alignment.CenterHorizontally,203        ) {204            // Hero: centered PeachMark · eyebrow · headline.205            PeachMark(size = 80.dp)206            Spacer(Modifier.height(18.dp))207            Text(208                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",209                color = peachColors.T2,210                style = PeachType.lockEyebrow,211            )212            Spacer(Modifier.height(10.dp))213            Text(214                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",215                color = peachColors.T0,216                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,217                textAlign = TextAlign.Center,218            )219            Spacer(Modifier.height(24.dp))220221            // Glass panel holding the credential inputs.222            GlassPanel(223                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },224            ) {225                Row(verticalAlignment = Alignment.CenterVertically) {226                    FieldLabel(227                        when {228                            state.hasVault && pinMode -> "PIN"229                            state.hasVault -> "master password"230                            else -> "create a master password"231                        },232                    )233                    if (!state.hasVault) {234                        Spacer(Modifier.weight(1f))235                        Text(236                            text = if (reveal) "hide" else "show",237                            color = accent,238                            style = PeachType.labelMono.copy(fontSize = 11.sp),239                            modifier = Modifier240                                .clip(RoundedCornerShape(6.dp))241                                .clickable { reveal = !reveal }242                                .padding(horizontal = 6.dp, vertical = 4.dp),243                        )244                    }245                }246                Spacer(Modifier.height(4.dp))247                UnderlineField(248                    value = password,249                    onChange = { password = it; vm.clearError() },250                    placeholder = if (pinMode) "••••" else "••••••••••",251                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),252                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,253                    onImeAction = {254                        if (state.hasVault) {255                            if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)256                        }257                    },258                    keyboardType = if (pinMode) KeyboardType.NumberPassword else KeyboardType.Password,259                    masked = state.hasVault || !reveal,260                )261262                if (!state.hasVault) {263                    Spacer(Modifier.height(16.dp))264                    FieldLabel("confirm password")265                    Spacer(Modifier.height(4.dp))266                    UnderlineField(267                        value = confirm,268                        onChange = { confirm = it; vm.clearError() },269                        placeholder = "••••••••••",270                        textStyle = mono18Spaced(),271                        imeAction = ImeAction.Go,272                        onImeAction = { vm.create(password, confirm) },273                        masked = !reveal,274                    )275276                    Spacer(Modifier.height(14.dp))277                    if (password.isEmpty()) {278                        Text(279                            text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",280                            color = peachColors.T2,281                            style = PeachType.hint,282                            textAlign = TextAlign.Center,283                        )284                    } else {285                        StrengthBar(score = createScore)286                        Spacer(Modifier.height(6.dp))287                        Text(288                            text = "strength: ${strengthLabel(createScore)}",289                            color = strengthColor(createScore),290                            style = PeachType.labelMono,291                            modifier = Modifier.fillMaxWidth(),292                            textAlign = TextAlign.Center,293                        )294                    }295                }296297                Text(298                    text = state.error ?: " ",299                    color = peachColors.Breach,300                    style = PeachType.labelMono.copy(fontSize = 11.sp),301                    textAlign = TextAlign.Center,302                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),303                )304305                Spacer(Modifier.height(4.dp))306                AccentButton(307                    label = when {308                        state.hasVault -> "unlock"309                        state.loading -> "creating..."310                        createBlocker != null -> createBlocker311                        else -> "create vault"312                    },313                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),314                    onClick = {315                        if (state.hasVault) {316                            if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)317                        } else {318                            vm.create(password, confirm)319                        }320                    },321                )322323                if (state.hasVault && state.pinEnabled) {324                    Spacer(Modifier.height(8.dp))325                    Text(326                        text = if (pinMode) "Use master password" else "Use PIN",327                        color = accent,328                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),329                        textAlign = TextAlign.Center,330                        modifier = Modifier.fillMaxWidth()331                            .clip(RoundedCornerShape(8.dp))332                            .clickable { password = ""; pinMode = !pinMode }333                            .padding(vertical = 8.dp),334                    )335                }336337                if (state.hasVault && canBiometric && activity != null) {338                    Spacer(Modifier.height(12.dp))339                    Text(340                        text = "Use biometrics",341                        color = accent,342                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),343                        textAlign = TextAlign.Center,344                        modifier = Modifier.fillMaxWidth()345                            .clip(RoundedCornerShape(8.dp))346                            .clickable { vm.biometricUnlock(activity) }347                            .padding(vertical = 8.dp),348                    )349                }350351                if (state.hasVault) {352                    Spacer(Modifier.height(8.dp))353                    Text(354                        text = "Forgot your password?",355                        color = peachColors.T2,356                        style = PeachType.labelMono.copy(fontSize = 11.sp),357                        textAlign = TextAlign.Center,358                        modifier = Modifier.fillMaxWidth()359                            .clip(RoundedCornerShape(8.dp))360                            .clickable { onNavigate(PeachNav.RecoveryEntry) }361                            .padding(vertical = 8.dp),362                    )363                }364            }365366            if (!state.hasVault) {367                Spacer(Modifier.height(24.dp))368                RestoreTabs(onNavigate = onNavigate)369            }370        }371    }372}373374@Composable375private fun FieldLabel(text: String) {376    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))377}378379/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */380private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)381382@Composable383private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {384    Column(385        modifier386            .fillMaxWidth()387            .clip(RoundedCornerShape(18.dp))388            .background(Color(0xE0131109))389            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))390            .padding(20.dp),391        content = content,392    )393}394395@Composable396private fun UnderlineField(397    value: String,398    onChange: (String) -> Unit,399    placeholder: String,400    textStyle: TextStyle,401    imeAction: ImeAction,402    onImeAction: () -> Unit,403    keyboardType: KeyboardType = KeyboardType.Password,404    masked: Boolean = true,405) {406    val accent = LocalPeachAccent.current407    val interaction = remember { MutableInteractionSource() }408    val focused by interaction.collectIsFocusedAsState()409    val hairline = peachColors.T3410    BasicTextField(411        value = value,412        onValueChange = onChange,413        singleLine = true,414        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),415        cursorBrush = SolidColor(accent),416        visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,417        keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),418        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),419        interactionSource = interaction,420        modifier = Modifier421            .fillMaxWidth()422            .drawBehind {423                drawRect(424                    color = if (focused) accent else hairline,425                    topLeft = Offset(0f, size.height - 1.dp.toPx()),426                    size = Size(size.width, 1.dp.toPx()),427                )428            }429            .padding(vertical = 8.dp),430        decorationBox = { inner ->431            Box(contentAlignment = Alignment.Center) {432                if (value.isEmpty()) {433                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))434                }435                inner()436            }437        },438    )439}440441@Composable442private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {443    val accent = LocalPeachAccent.current444    Box(445        modifier = Modifier446            .fillMaxWidth()447            .alpha(if (enabled) 1f else 0.5f)448            .clip(RoundedCornerShape(10.dp))449            .background(accent)450            .clickable(enabled = enabled, onClick = onClick)451            .padding(vertical = 13.dp),452        contentAlignment = Alignment.Center,453    ) {454        Text(455            label,456            color = Color.White,457            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),458        )459    }460}461462@OptIn(ExperimentalLayoutApi::class)463@Composable464private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {465    val tabs = listOf(466        "PeachScript" to PeachNav.Import,467        "CSV" to PeachNav.Import,468        "Codex" to PeachNav.CodexRestore,469    )470    Column(horizontalAlignment = Alignment.CenterHorizontally) {471        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)472        Spacer(Modifier.height(10.dp))473        // These four barely fit one line at 411dp. A plain Row has nowhere to474        // put the overflow, so it hands the last pill near-zero width — the475        // label stacks one letter per line and then disappears entirely,476        // leaving a bordered box with no text. Wrap instead, and never let a477        // label break mid-word.478        FlowRow(479            horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),480            verticalArrangement = Arrangement.spacedBy(6.dp),481        ) {482            tabs.forEach { (label, dest) ->483                Box(484                    modifier = Modifier485                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))486                        .clickable { onNavigate(dest) }487                        .padding(horizontal = 10.dp, vertical = 8.dp),488                ) {489                    Text(490                        label,491                        color = peachColors.T1,492                        style = PeachType.sortPill,493                        maxLines = 1,494                        softWrap = false,495                    )496                }497            }498        }499    }500}