LockScreen.kt

17.0 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    vm: LockViewModel = hiltViewModel(),78) {79    val state by vm.state.collectAsState()80    val accent = LocalPeachAccent.current81    val context = LocalContext.current82    val activity = context as? FragmentActivity83    val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }8485    var password by remember { mutableStateOf("") }86    var confirm by remember { mutableStateOf("") }87    var reveal by remember { mutableStateOf(false) }88    val createScore = remember(password) { scorePassword(password) }89    // Why the create button is disabled, shown on the button itself. Without90    // it the button just silently does nothing (browser parity: Lock.tsx's91    // createBlockerMessage). Null before typing — no scolding on an empty form.92    val createBlocker = if (state.hasVault || password.isEmpty()) null93                        else createValidationError(password, confirm, createScore)9495    LaunchedEffect(state.unlocked) {96        if (state.unlocked) {97            // A freshly created vault routes through recovery-word setup before the list;98            // an ordinary unlock lands on the last-viewed entry (brief Fix 3).99            if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())100        }101    }102103    // Auto-offer biometric unlock shortly after the lock screen appears.104    LaunchedEffect(canBiometric) {105        if (canBiometric && activity != null && !state.unlocked) {106            kotlinx.coroutines.delay(300)107            vm.biometricUnlock(activity)108        }109    }110111    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).112    val shake = remember { Animatable(0f) }113    LaunchedEffect(state.shakeNonce) {114        if (state.shakeNonce > 0) {115            shake.animateTo(116                targetValue = 0f,117                animationSpec = keyframes {118                    durationMillis = 400119                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400120                },121            )122        }123    }124125    ShellBackground {126        PeachWordmark(127            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),128        )129130        Column(131            modifier = Modifier132                .align(Alignment.Center)133                .fillMaxHeight()134                .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)135                .fillMaxWidth()136                .systemBarsPadding()137                .padding(horizontal = 28.dp),138            verticalArrangement = Arrangement.Center,139            horizontalAlignment = Alignment.CenterHorizontally,140        ) {141            // Hero: centered PeachMark · eyebrow · headline.142            PeachMark(size = 80.dp)143            Spacer(Modifier.height(18.dp))144            Text(145                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",146                color = peachColors.T2,147                style = PeachType.lockEyebrow,148            )149            Spacer(Modifier.height(10.dp))150            Text(151                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",152                color = peachColors.T0,153                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,154                textAlign = TextAlign.Center,155            )156            Spacer(Modifier.height(14.dp))157            LockRuneString()158            Spacer(Modifier.height(24.dp))159160            // Glass panel holding the credential inputs.161            GlassPanel(162                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },163            ) {164                Row(verticalAlignment = Alignment.CenterVertically) {165                    FieldLabel(if (state.hasVault) "master password" else "create a master password")166                    if (!state.hasVault) {167                        Spacer(Modifier.weight(1f))168                        Text(169                            text = if (reveal) "hide" else "show",170                            color = accent,171                            style = PeachType.labelMono.copy(fontSize = 11.sp),172                            modifier = Modifier173                                .clip(RoundedCornerShape(6.dp))174                                .clickable { reveal = !reveal }175                                .padding(horizontal = 6.dp, vertical = 4.dp),176                        )177                    }178                }179                Spacer(Modifier.height(4.dp))180                UnderlineField(181                    value = password,182                    onChange = { password = it; vm.clearError() },183                    placeholder = "••••••••••",184                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),185                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,186                    onImeAction = { if (state.hasVault) vm.unlock(password) },187                    masked = state.hasVault || !reveal,188                )189190                if (!state.hasVault) {191                    Spacer(Modifier.height(16.dp))192                    FieldLabel("confirm password")193                    Spacer(Modifier.height(4.dp))194                    UnderlineField(195                        value = confirm,196                        onChange = { confirm = it; vm.clearError() },197                        placeholder = "••••••••••",198                        textStyle = mono18Spaced(),199                        imeAction = ImeAction.Go,200                        onImeAction = { vm.create(password, confirm) },201                        masked = !reveal,202                    )203204                    Spacer(Modifier.height(14.dp))205                    if (password.isEmpty()) {206                        Text(207                            text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",208                            color = peachColors.T2,209                            style = PeachType.hint,210                            textAlign = TextAlign.Center,211                        )212                    } else {213                        StrengthBar(score = createScore)214                        Spacer(Modifier.height(6.dp))215                        Text(216                            text = "strength: ${strengthLabel(createScore)}",217                            color = strengthColor(createScore),218                            style = PeachType.labelMono,219                            modifier = Modifier.fillMaxWidth(),220                            textAlign = TextAlign.Center,221                        )222                    }223                }224225                Text(226                    text = state.error ?: " ",227                    color = peachColors.Breach,228                    style = PeachType.labelMono.copy(fontSize = 11.sp),229                    textAlign = TextAlign.Center,230                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),231                )232233                Spacer(Modifier.height(4.dp))234                AccentButton(235                    label = when {236                        state.hasVault -> "unlock"237                        state.loading -> "creating..."238                        createBlocker != null -> createBlocker239                        else -> "create vault"240                    },241                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),242                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },243                )244245                if (state.hasVault && canBiometric && activity != null) {246                    Spacer(Modifier.height(12.dp))247                    Text(248                        text = "Use biometrics",249                        color = accent,250                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),251                        textAlign = TextAlign.Center,252                        modifier = Modifier.fillMaxWidth()253                            .clip(RoundedCornerShape(8.dp))254                            .clickable { vm.biometricUnlock(activity) }255                            .padding(vertical = 8.dp),256                    )257                }258259                if (state.hasVault) {260                    Spacer(Modifier.height(8.dp))261                    Text(262                        text = "Forgot your password?",263                        color = peachColors.T2,264                        style = PeachType.labelMono.copy(fontSize = 11.sp),265                        textAlign = TextAlign.Center,266                        modifier = Modifier.fillMaxWidth()267                            .clip(RoundedCornerShape(8.dp))268                            .clickable { onNavigate(PeachNav.RecoveryEntry) }269                            .padding(vertical = 8.dp),270                    )271                }272            }273274            if (!state.hasVault) {275                Spacer(Modifier.height(24.dp))276                RestoreTabs(onNavigate = onNavigate)277            }278        }279    }280}281282@Composable283private fun FieldLabel(text: String) {284    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))285}286287/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */288private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)289290@Composable291private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {292    Column(293        modifier294            .fillMaxWidth()295            .clip(RoundedCornerShape(18.dp))296            .background(Color(0xE0131109))297            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))298            .padding(20.dp),299        content = content,300    )301}302303@Composable304private fun UnderlineField(305    value: String,306    onChange: (String) -> Unit,307    placeholder: String,308    textStyle: TextStyle,309    imeAction: ImeAction,310    onImeAction: () -> Unit,311    masked: Boolean = true,312) {313    val accent = LocalPeachAccent.current314    val interaction = remember { MutableInteractionSource() }315    val focused by interaction.collectIsFocusedAsState()316    val hairline = peachColors.T3317    BasicTextField(318        value = value,319        onValueChange = onChange,320        singleLine = true,321        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),322        cursorBrush = SolidColor(accent),323        visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,324        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),325        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),326        interactionSource = interaction,327        modifier = Modifier328            .fillMaxWidth()329            .drawBehind {330                drawRect(331                    color = if (focused) accent else hairline,332                    topLeft = Offset(0f, size.height - 1.dp.toPx()),333                    size = Size(size.width, 1.dp.toPx()),334                )335            }336            .padding(vertical = 8.dp),337        decorationBox = { inner ->338            Box(contentAlignment = Alignment.Center) {339                if (value.isEmpty()) {340                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))341                }342                inner()343            }344        },345    )346}347348@Composable349private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {350    val accent = LocalPeachAccent.current351    Box(352        modifier = Modifier353            .fillMaxWidth()354            .alpha(if (enabled) 1f else 0.5f)355            .clip(RoundedCornerShape(10.dp))356            .background(accent)357            .clickable(enabled = enabled, onClick = onClick)358            .padding(vertical = 13.dp),359        contentAlignment = Alignment.Center,360    ) {361        Text(362            label,363            color = Color.White,364            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),365        )366    }367}368369@OptIn(ExperimentalLayoutApi::class)370@Composable371private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {372    val tabs = listOf(373        "PeachScript" to PeachNav.Import,374        "CSV" to PeachNav.Import,375        "Codex" to PeachNav.CodexRestore,376    )377    Column(horizontalAlignment = Alignment.CenterHorizontally) {378        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)379        Spacer(Modifier.height(10.dp))380        // These four barely fit one line at 411dp. A plain Row has nowhere to381        // put the overflow, so it hands the last pill near-zero width — the382        // label stacks one letter per line and then disappears entirely,383        // leaving a bordered box with no text. Wrap instead, and never let a384        // label break mid-word.385        FlowRow(386            horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),387            verticalArrangement = Arrangement.spacedBy(6.dp),388        ) {389            tabs.forEach { (label, dest) ->390                Box(391                    modifier = Modifier392                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))393                        .clickable { onNavigate(dest) }394                        .padding(horizontal = 10.dp, vertical = 8.dp),395                ) {396                    Text(397                        label,398                        color = peachColors.T1,399                        style = PeachType.sortPill,400                        maxLines = 1,401                        softWrap = false,402                    )403                }404            }405        }406    }407}