LockScreen.kt

18.3 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@Composable74fun LockScreen(75    onUnlocked: (PeachNav) -> Unit,76    onNavigate: (PeachNav) -> Unit,77    /**78     * True when an autofill-driven navigation (e.g. the "+ Save to Peach"79     * create chip) is queued and waiting for the vault to unlock. When set,80     * [onUnlocked] is skipped: the autofill nav path at the PeachAppNavHost81     * scope will navigate to its own destination (the new-entry editor with82     * URL prefilled), and we must not clobber it with the last-viewed entry.83     * Two LaunchedEffects firing on the same unlock would otherwise race --84     * the LockScreen one wins because [onUnlocked] uses popUpTo(0) inclusive.85     */86    autofillPending: Boolean = false,87    vm: LockViewModel = hiltViewModel(),88) {89    val state by vm.state.collectAsState()90    val accent = LocalPeachAccent.current91    val context = LocalContext.current92    val activity = context as? FragmentActivity93    val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }9495    var password by remember { mutableStateOf("") }96    var confirm by remember { mutableStateOf("") }97    var reveal by remember { mutableStateOf(false) }98    val createScore = remember(password) { scorePassword(password) }99    // Why the create button is disabled, shown on the button itself. Without100    // it the button just silently does nothing (browser parity: Lock.tsx's101    // createBlockerMessage). Null before typing -- no scolding on an empty form.102    val createBlocker = if (state.hasVault || password.isEmpty()) null103                        else createValidationError(password, confirm, createScore)104105    // Keyed on `state.unlocked` only: the autofill nav path in PeachAppNavHost consumes106    // `newEntryRequest` synchronously, which would otherwise flip `autofillPending` to107    // false mid-unlock and re-fire this effect — landing the user on the vault list108    // instead of the new-entry editor. Capturing `autofillPending` at the unlock109    // transition (rather than re-reading it on every key change) makes the two effects110    // cooperative instead of racing.111    LaunchedEffect(state.unlocked) {112        if (state.unlocked) {113            // A freshly created vault routes through recovery-word setup before the list;114            // an ordinary unlock lands on the last-viewed entry (brief Fix 3). The autofill115            // nav host handles its own destinations when autofillPending is set; stepping in116            // here would clobber that navigation with `popUpTo(0)`.117            if (state.created) onNavigate(PeachNav.RecoverySetup)118            else if (autofillPending) Unit119            else onUnlocked(vm.postUnlockDestination())120        }121    }122123    // Auto-offer biometric unlock shortly after the lock screen appears.124    LaunchedEffect(canBiometric) {125        if (canBiometric && activity != null && !state.unlocked) {126            kotlinx.coroutines.delay(300)127            vm.biometricUnlock(activity)128        }129    }130131    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).132    val shake = remember { Animatable(0f) }133    LaunchedEffect(state.shakeNonce) {134        if (state.shakeNonce > 0) {135            shake.animateTo(136                targetValue = 0f,137                animationSpec = keyframes {138                    durationMillis = 400139                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400140                },141            )142        }143    }144145    ShellBackground {146        PeachWordmark(147            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),148        )149150        Column(151            modifier = Modifier152                .align(Alignment.Center)153                .fillMaxHeight()154                .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)155                .fillMaxWidth()156                .systemBarsPadding()157                .padding(horizontal = 28.dp),158            verticalArrangement = Arrangement.Center,159            horizontalAlignment = Alignment.CenterHorizontally,160        ) {161            // Hero: centered PeachMark · eyebrow · headline.162            PeachMark(size = 80.dp)163            Spacer(Modifier.height(18.dp))164            Text(165                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",166                color = peachColors.T2,167                style = PeachType.lockEyebrow,168            )169            Spacer(Modifier.height(10.dp))170            Text(171                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",172                color = peachColors.T0,173                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,174                textAlign = TextAlign.Center,175            )176            Spacer(Modifier.height(14.dp))177            LockRuneString()178            Spacer(Modifier.height(24.dp))179180            // Glass panel holding the credential inputs.181            GlassPanel(182                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },183            ) {184                Row(verticalAlignment = Alignment.CenterVertically) {185                    FieldLabel(if (state.hasVault) "master password" else "create a master password")186                    if (!state.hasVault) {187                        Spacer(Modifier.weight(1f))188                        Text(189                            text = if (reveal) "hide" else "show",190                            color = accent,191                            style = PeachType.labelMono.copy(fontSize = 11.sp),192                            modifier = Modifier193                                .clip(RoundedCornerShape(6.dp))194                                .clickable { reveal = !reveal }195                                .padding(horizontal = 6.dp, vertical = 4.dp),196                        )197                    }198                }199                Spacer(Modifier.height(4.dp))200                UnderlineField(201                    value = password,202                    onChange = { password = it; vm.clearError() },203                    placeholder = "••••••••••",204                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),205                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,206                    onImeAction = { if (state.hasVault) vm.unlock(password) },207                    masked = state.hasVault || !reveal,208                )209210                if (!state.hasVault) {211                    Spacer(Modifier.height(16.dp))212                    FieldLabel("confirm password")213                    Spacer(Modifier.height(4.dp))214                    UnderlineField(215                        value = confirm,216                        onChange = { confirm = it; vm.clearError() },217                        placeholder = "••••••••••",218                        textStyle = mono18Spaced(),219                        imeAction = ImeAction.Go,220                        onImeAction = { vm.create(password, confirm) },221                        masked = !reveal,222                    )223224                    Spacer(Modifier.height(14.dp))225                    if (password.isEmpty()) {226                        Text(227                            text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",228                            color = peachColors.T2,229                            style = PeachType.hint,230                            textAlign = TextAlign.Center,231                        )232                    } else {233                        StrengthBar(score = createScore)234                        Spacer(Modifier.height(6.dp))235                        Text(236                            text = "strength: ${strengthLabel(createScore)}",237                            color = strengthColor(createScore),238                            style = PeachType.labelMono,239                            modifier = Modifier.fillMaxWidth(),240                            textAlign = TextAlign.Center,241                        )242                    }243                }244245                Text(246                    text = state.error ?: " ",247                    color = peachColors.Breach,248                    style = PeachType.labelMono.copy(fontSize = 11.sp),249                    textAlign = TextAlign.Center,250                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),251                )252253                Spacer(Modifier.height(4.dp))254                AccentButton(255                    label = when {256                        state.hasVault -> "unlock"257                        state.loading -> "creating..."258                        createBlocker != null -> createBlocker259                        else -> "create vault"260                    },261                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),262                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },263                )264265                if (state.hasVault && canBiometric && activity != null) {266                    Spacer(Modifier.height(12.dp))267                    Text(268                        text = "Use biometrics",269                        color = accent,270                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),271                        textAlign = TextAlign.Center,272                        modifier = Modifier.fillMaxWidth()273                            .clip(RoundedCornerShape(8.dp))274                            .clickable { vm.biometricUnlock(activity) }275                            .padding(vertical = 8.dp),276                    )277                }278279                if (state.hasVault) {280                    Spacer(Modifier.height(8.dp))281                    Text(282                        text = "Forgot your password?",283                        color = peachColors.T2,284                        style = PeachType.labelMono.copy(fontSize = 11.sp),285                        textAlign = TextAlign.Center,286                        modifier = Modifier.fillMaxWidth()287                            .clip(RoundedCornerShape(8.dp))288                            .clickable { onNavigate(PeachNav.RecoveryEntry) }289                            .padding(vertical = 8.dp),290                    )291                }292            }293294            if (!state.hasVault) {295                Spacer(Modifier.height(24.dp))296                RestoreTabs(onNavigate = onNavigate)297            }298        }299    }300}301302@Composable303private fun FieldLabel(text: String) {304    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))305}306307/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */308private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)309310@Composable311private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {312    Column(313        modifier314            .fillMaxWidth()315            .clip(RoundedCornerShape(18.dp))316            .background(Color(0xE0131109))317            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))318            .padding(20.dp),319        content = content,320    )321}322323@Composable324private fun UnderlineField(325    value: String,326    onChange: (String) -> Unit,327    placeholder: String,328    textStyle: TextStyle,329    imeAction: ImeAction,330    onImeAction: () -> Unit,331    masked: Boolean = true,332) {333    val accent = LocalPeachAccent.current334    val interaction = remember { MutableInteractionSource() }335    val focused by interaction.collectIsFocusedAsState()336    val hairline = peachColors.T3337    BasicTextField(338        value = value,339        onValueChange = onChange,340        singleLine = true,341        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),342        cursorBrush = SolidColor(accent),343        visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,344        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),345        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),346        interactionSource = interaction,347        modifier = Modifier348            .fillMaxWidth()349            .drawBehind {350                drawRect(351                    color = if (focused) accent else hairline,352                    topLeft = Offset(0f, size.height - 1.dp.toPx()),353                    size = Size(size.width, 1.dp.toPx()),354                )355            }356            .padding(vertical = 8.dp),357        decorationBox = { inner ->358            Box(contentAlignment = Alignment.Center) {359                if (value.isEmpty()) {360                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))361                }362                inner()363            }364        },365    )366}367368@Composable369private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {370    val accent = LocalPeachAccent.current371    Box(372        modifier = Modifier373            .fillMaxWidth()374            .alpha(if (enabled) 1f else 0.5f)375            .clip(RoundedCornerShape(10.dp))376            .background(accent)377            .clickable(enabled = enabled, onClick = onClick)378            .padding(vertical = 13.dp),379        contentAlignment = Alignment.Center,380    ) {381        Text(382            label,383            color = Color.White,384            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),385        )386    }387}388389@OptIn(ExperimentalLayoutApi::class)390@Composable391private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {392    val tabs = listOf(393        "PeachScript" to PeachNav.Import,394        "CSV" to PeachNav.Import,395        "Codex" to PeachNav.CodexRestore,396    )397    Column(horizontalAlignment = Alignment.CenterHorizontally) {398        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)399        Spacer(Modifier.height(10.dp))400        // These four barely fit one line at 411dp. A plain Row has nowhere to401        // put the overflow, so it hands the last pill near-zero width — the402        // label stacks one letter per line and then disappears entirely,403        // leaving a bordered box with no text. Wrap instead, and never let a404        // label break mid-word.405        FlowRow(406            horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),407            verticalArrangement = Arrangement.spacedBy(6.dp),408        ) {409            tabs.forEach { (label, dest) ->410                Box(411                    modifier = Modifier412                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))413                        .clickable { onNavigate(dest) }414                        .padding(horizontal = 10.dp, vertical = 8.dp),415                ) {416                    Text(417                        label,418                        color = peachColors.T1,419                        style = PeachType.sortPill,420                        maxLines = 1,421                        softWrap = false,422                    )423                }424            }425        }426    }427}