LockScreen.kt

19.7 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.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    /** An autofill nav request 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    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    /**115     * True when an autofill-driven navigation (e.g. the "+ Save to Peach"116     * create chip) is queued and waiting for the vault to unlock. When set,117     * [onUnlocked] is skipped: the autofill nav path at the PeachAppNavHost118     * scope will navigate to its own destination (the new-entry editor with119     * URL prefilled), and we must not clobber it with the last-viewed entry.120     * Two LaunchedEffects firing on the same unlock would otherwise race --121     * the LockScreen one wins because [onUnlocked] uses popUpTo(0) inclusive.122     */123    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    // Why the create button is disabled, shown on the button itself. Without137    // it the button just silently does nothing (browser parity: Lock.tsx's138    // createBlockerMessage). Null before typing -- no scolding on an empty form.139    val createBlocker = if (state.hasVault || password.isEmpty()) null140                        else createValidationError(password, confirm, createScore)141142    // Keyed on `state.unlocked` only: the autofill nav path in PeachAppNavHost consumes143    // `newEntryRequest` synchronously, which would otherwise flip `autofillPending` to144    // false mid-unlock and re-fire this effect — landing the user on the vault list145    // instead of the new-entry editor. Capturing `autofillPending` at the unlock146    // transition (rather than re-reading it on every key change) makes the two effects147    // cooperative instead of racing.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    // Auto-offer biometric unlock shortly after the lock screen appears.158    LaunchedEffect(canBiometric) {159        if (canBiometric && activity != null && !state.unlocked) {160            kotlinx.coroutines.delay(300)161            vm.biometricUnlock(activity)162        }163    }164165    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).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        PeachWordmark(181            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),182        )183184        Column(185            modifier = Modifier186                .align(Alignment.Center)187                .fillMaxHeight()188                .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)189                .fillMaxWidth()190                .systemBarsPadding()191                .padding(horizontal = 28.dp),192            verticalArrangement = Arrangement.Center,193            horizontalAlignment = Alignment.CenterHorizontally,194        ) {195            // Hero: centered PeachMark · eyebrow · headline.196            PeachMark(size = 80.dp)197            Spacer(Modifier.height(18.dp))198            Text(199                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",200                color = peachColors.T2,201                style = PeachType.lockEyebrow,202            )203            Spacer(Modifier.height(10.dp))204            Text(205                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",206                color = peachColors.T0,207                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,208                textAlign = TextAlign.Center,209            )210            Spacer(Modifier.height(14.dp))211            LockRuneString()212            Spacer(Modifier.height(24.dp))213214            // Glass panel holding the credential inputs.215            GlassPanel(216                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },217            ) {218                Row(verticalAlignment = Alignment.CenterVertically) {219                    FieldLabel(if (state.hasVault) "master password" else "create a master password")220                    if (!state.hasVault) {221                        Spacer(Modifier.weight(1f))222                        Text(223                            text = if (reveal) "hide" else "show",224                            color = accent,225                            style = PeachType.labelMono.copy(fontSize = 11.sp),226                            modifier = Modifier227                                .clip(RoundedCornerShape(6.dp))228                                .clickable { reveal = !reveal }229                                .padding(horizontal = 6.dp, vertical = 4.dp),230                        )231                    }232                }233                Spacer(Modifier.height(4.dp))234                UnderlineField(235                    value = password,236                    onChange = { password = it; vm.clearError() },237                    placeholder = "••••••••••",238                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),239                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,240                    onImeAction = { if (state.hasVault) vm.unlock(password) },241                    masked = state.hasVault || !reveal,242                )243244                if (!state.hasVault) {245                    Spacer(Modifier.height(16.dp))246                    FieldLabel("confirm password")247                    Spacer(Modifier.height(4.dp))248                    UnderlineField(249                        value = confirm,250                        onChange = { confirm = it; vm.clearError() },251                        placeholder = "••••••••••",252                        textStyle = mono18Spaced(),253                        imeAction = ImeAction.Go,254                        onImeAction = { vm.create(password, confirm) },255                        masked = !reveal,256                    )257258                    Spacer(Modifier.height(14.dp))259                    if (password.isEmpty()) {260                        Text(261                            text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",262                            color = peachColors.T2,263                            style = PeachType.hint,264                            textAlign = TextAlign.Center,265                        )266                    } else {267                        StrengthBar(score = createScore)268                        Spacer(Modifier.height(6.dp))269                        Text(270                            text = "strength: ${strengthLabel(createScore)}",271                            color = strengthColor(createScore),272                            style = PeachType.labelMono,273                            modifier = Modifier.fillMaxWidth(),274                            textAlign = TextAlign.Center,275                        )276                    }277                }278279                Text(280                    text = state.error ?: " ",281                    color = peachColors.Breach,282                    style = PeachType.labelMono.copy(fontSize = 11.sp),283                    textAlign = TextAlign.Center,284                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),285                )286287                Spacer(Modifier.height(4.dp))288                AccentButton(289                    label = when {290                        state.hasVault -> "unlock"291                        state.loading -> "creating..."292                        createBlocker != null -> createBlocker293                        else -> "create vault"294                    },295                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),296                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },297                )298299                if (state.hasVault && canBiometric && activity != null) {300                    Spacer(Modifier.height(12.dp))301                    Text(302                        text = "Use biometrics",303                        color = accent,304                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),305                        textAlign = TextAlign.Center,306                        modifier = Modifier.fillMaxWidth()307                            .clip(RoundedCornerShape(8.dp))308                            .clickable { vm.biometricUnlock(activity) }309                            .padding(vertical = 8.dp),310                    )311                }312313                if (state.hasVault) {314                    Spacer(Modifier.height(8.dp))315                    Text(316                        text = "Forgot your password?",317                        color = peachColors.T2,318                        style = PeachType.labelMono.copy(fontSize = 11.sp),319                        textAlign = TextAlign.Center,320                        modifier = Modifier.fillMaxWidth()321                            .clip(RoundedCornerShape(8.dp))322                            .clickable { onNavigate(PeachNav.RecoveryEntry) }323                            .padding(vertical = 8.dp),324                    )325                }326            }327328            if (!state.hasVault) {329                Spacer(Modifier.height(24.dp))330                RestoreTabs(onNavigate = onNavigate)331            }332        }333    }334}335336@Composable337private fun FieldLabel(text: String) {338    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))339}340341/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */342private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)343344@Composable345private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {346    Column(347        modifier348            .fillMaxWidth()349            .clip(RoundedCornerShape(18.dp))350            .background(Color(0xE0131109))351            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))352            .padding(20.dp),353        content = content,354    )355}356357@Composable358private fun UnderlineField(359    value: String,360    onChange: (String) -> Unit,361    placeholder: String,362    textStyle: TextStyle,363    imeAction: ImeAction,364    onImeAction: () -> Unit,365    masked: Boolean = true,366) {367    val accent = LocalPeachAccent.current368    val interaction = remember { MutableInteractionSource() }369    val focused by interaction.collectIsFocusedAsState()370    val hairline = peachColors.T3371    BasicTextField(372        value = value,373        onValueChange = onChange,374        singleLine = true,375        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),376        cursorBrush = SolidColor(accent),377        visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,378        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),379        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),380        interactionSource = interaction,381        modifier = Modifier382            .fillMaxWidth()383            .drawBehind {384                drawRect(385                    color = if (focused) accent else hairline,386                    topLeft = Offset(0f, size.height - 1.dp.toPx()),387                    size = Size(size.width, 1.dp.toPx()),388                )389            }390            .padding(vertical = 8.dp),391        decorationBox = { inner ->392            Box(contentAlignment = Alignment.Center) {393                if (value.isEmpty()) {394                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))395                }396                inner()397            }398        },399    )400}401402@Composable403private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {404    val accent = LocalPeachAccent.current405    Box(406        modifier = Modifier407            .fillMaxWidth()408            .alpha(if (enabled) 1f else 0.5f)409            .clip(RoundedCornerShape(10.dp))410            .background(accent)411            .clickable(enabled = enabled, onClick = onClick)412            .padding(vertical = 13.dp),413        contentAlignment = Alignment.Center,414    ) {415        Text(416            label,417            color = Color.White,418            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),419        )420    }421}422423@OptIn(ExperimentalLayoutApi::class)424@Composable425private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {426    val tabs = listOf(427        "PeachScript" to PeachNav.Import,428        "CSV" to PeachNav.Import,429        "Codex" to PeachNav.CodexRestore,430    )431    Column(horizontalAlignment = Alignment.CenterHorizontally) {432        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)433        Spacer(Modifier.height(10.dp))434        // These four barely fit one line at 411dp. A plain Row has nowhere to435        // put the overflow, so it hands the last pill near-zero width — the436        // label stacks one letter per line and then disappears entirely,437        // leaving a bordered box with no text. Wrap instead, and never let a438        // label break mid-word.439        FlowRow(440            horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),441            verticalArrangement = Arrangement.spacedBy(6.dp),442        ) {443            tabs.forEach { (label, dest) ->444                Box(445                    modifier = Modifier446                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))447                        .clickable { onNavigate(dest) }448                        .padding(horizontal = 10.dp, vertical = 8.dp),449                ) {450                    Text(451                        label,452                        color = peachColors.T1,453                        style = PeachType.sortPill,454                        maxLines = 1,455                        softWrap = false,456                    )457                }458            }459        }460    }461}