LockScreen.kt

19.8 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.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.ShellBackground7374/**75 * Where the unlock transition should land. Extracted from the LaunchedEffect body so76 * the priority order (created > autofill > postUnlock) is unit-testable without a77 * Compose runtime; the LaunchedEffect is a thin dispatcher that calls78 * [routeOnUnlock] once per unlock transition.79 *80 * [Defer] means "let the NavHost handle it" — used when an autofill-driven navigation81 * is queued at the moment of unlock so we don't clobber it with popUpTo(0).82 */83internal sealed class UnlockRoute {84    /** A freshly created vault must walk through recovery-word setup before the list. */85    data object RecoverySetup : UnlockRoute()86    /** An autofill nav request is in flight; the NavHost owns the destination. */87    data object Defer : UnlockRoute()88    /** Ordinary unlock: route to the post-unlock destination the ViewModel computed. */89    data class PostUnlock(val destination: PeachNav) : UnlockRoute()90}9192/**93 * Decide what the lock screen does when [LockUiState.unlocked] flips true. The94 * priority order matters: a newly-created vault always goes to recovery-word setup,95 * an autofill-pending unlock defers to the NavHost, everything else lands on the96 * ViewModel-computed post-unlock destination.97 *98 * `postUnlock` is a lambda so the function is pure and the lazy destination lookup99 * is observable in tests (a [UnlockRoute.Defer] must never invoke it).100 */101internal 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    /**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    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    // Why the create button is disabled, shown on the button itself. Without138    // it the button just silently does nothing (browser parity: Lock.tsx's139    // createBlockerMessage). Null before typing -- no scolding on an empty form.140    val createBlocker = if (state.hasVault || password.isEmpty()) null141                        else createValidationError(password, confirm, createScore)142143    // Keyed on `state.unlocked` only: the autofill nav path in PeachAppNavHost consumes144    // `newEntryRequest` synchronously, which would otherwise flip `autofillPending` to145    // false mid-unlock and re-fire this effect — landing the user on the vault list146    // instead of the new-entry editor. Capturing `autofillPending` at the unlock147    // transition (rather than re-reading it on every key change) makes the two effects148    // cooperative instead of racing.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    // Auto-offer biometric unlock shortly after the lock screen appears.159    LaunchedEffect(canBiometric) {160        if (canBiometric && activity != null && !state.unlocked) {161            kotlinx.coroutines.delay(300)162            vm.biometricUnlock(activity)163        }164    }165166    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).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            // Hero: centered PeachMark · eyebrow · headline.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            // Glass panel holding the credential inputs.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}344345/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */346private 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        // These four barely fit one line at 411dp. A plain Row has nowhere to439        // put the overflow, so it hands the last pill near-zero width — the440        // label stacks one letter per line and then disappears entirely,441        // leaving a bordered box with no text. Wrap instead, and never let a442        // label break mid-word.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}