LockScreen.kt

16.6 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.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxHeight16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.offset18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.systemBarsPadding22import androidx.compose.foundation.layout.widthIn23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.foundation.text.KeyboardActions26import androidx.compose.foundation.text.KeyboardOptions27import androidx.compose.material3.Text28import androidx.compose.runtime.Composable29import androidx.compose.runtime.LaunchedEffect30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.setValue34import androidx.compose.ui.Alignment35import androidx.compose.ui.Modifier36import androidx.compose.ui.draw.alpha37import androidx.compose.ui.draw.clip38import androidx.compose.ui.draw.drawBehind39import androidx.compose.ui.geometry.Offset40import androidx.compose.ui.geometry.Size41import androidx.compose.ui.graphics.Color42import androidx.compose.ui.graphics.SolidColor43import androidx.compose.ui.text.TextStyle44import androidx.compose.ui.text.input.ImeAction45import androidx.compose.ui.text.input.KeyboardType46import androidx.compose.ui.text.input.PasswordVisualTransformation47import androidx.compose.ui.text.input.VisualTransformation48import androidx.compose.ui.text.style.TextAlign49import androidx.compose.ui.unit.IntOffset50import androidx.compose.ui.unit.dp51import androidx.compose.ui.unit.em52import androidx.compose.ui.unit.sp53import androidx.hilt.navigation.compose.hiltViewModel54import androidx.compose.runtime.collectAsState55import androidx.compose.ui.platform.LocalContext56import androidx.fragment.app.FragmentActivity57import com.peachpasswords.ui.PeachNav58import com.peachpasswords.ui.components.LockRuneString59import com.peachpasswords.ui.components.PeachMark60import com.peachpasswords.ui.components.PeachWordmark61import com.peachpasswords.ui.theme.LocalPeachAccent62import com.peachpasswords.ui.theme.PeachSpacing63import com.peachpasswords.ui.theme.peachColors64import com.peachpasswords.ui.security.StrengthBar65import com.peachpasswords.ui.security.scorePassword66import com.peachpasswords.ui.security.strengthColor67import com.peachpasswords.ui.security.strengthLabel68import com.peachpasswords.ui.theme.PeachType69import com.peachpasswords.ui.theme.ShellBackground7071@Composable72fun LockScreen(73    onUnlocked: (PeachNav) -> Unit,74    onNavigate: (PeachNav) -> Unit,75    vm: LockViewModel = hiltViewModel(),76) {77    val state by vm.state.collectAsState()78    val accent = LocalPeachAccent.current79    val context = LocalContext.current80    val activity = context as? FragmentActivity81    val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }8283    var password by remember { mutableStateOf("") }84    var confirm by remember { mutableStateOf("") }85    var reveal by remember { mutableStateOf(false) }86    val createScore = remember(password) { scorePassword(password) }87    // Why the create button is disabled, shown on the button itself. Without88    // it the button just silently does nothing (browser parity: Lock.tsx's89    // createBlockerMessage). Null before typing — no scolding on an empty form.90    val createBlocker = if (state.hasVault || password.isEmpty()) null91                        else createValidationError(password, confirm, createScore)9293    LaunchedEffect(state.unlocked) {94        if (state.unlocked) {95            // A freshly created vault routes through recovery-word setup before the list;96            // an ordinary unlock lands on the last-viewed entry (brief Fix 3).97            if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())98        }99    }100101    // Auto-offer biometric unlock shortly after the lock screen appears.102    LaunchedEffect(canBiometric) {103        if (canBiometric && activity != null && !state.unlocked) {104            kotlinx.coroutines.delay(300)105            vm.biometricUnlock(activity)106        }107    }108109    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).110    val shake = remember { Animatable(0f) }111    LaunchedEffect(state.shakeNonce) {112        if (state.shakeNonce > 0) {113            shake.animateTo(114                targetValue = 0f,115                animationSpec = keyframes {116                    durationMillis = 400117                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400118                },119            )120        }121    }122123    ShellBackground {124        PeachWordmark(125            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),126        )127128        Column(129            modifier = Modifier130                // Gate 1 (wide-screen/foldable audit): this card-style content131                // was stretching edge-to-edge on tablet/foldable widths instead132                // of staying a comfortable card width — cap it and center it in133                // the remaining space, same as a phone gets automatically.134                .align(Alignment.Center)135                .fillMaxHeight()136                .widthIn(max = 480.dp)137                .fillMaxWidth()138                .systemBarsPadding()139                .padding(horizontal = 28.dp),140            verticalArrangement = Arrangement.Center,141            horizontalAlignment = Alignment.CenterHorizontally,142        ) {143            // Hero: centered PeachMark · eyebrow · headline.144            PeachMark(size = 80.dp)145            Spacer(Modifier.height(18.dp))146            Text(147                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",148                color = peachColors.T2,149                style = PeachType.lockEyebrow,150            )151            Spacer(Modifier.height(10.dp))152            Text(153                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",154                color = peachColors.T0,155                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,156                textAlign = TextAlign.Center,157            )158            Spacer(Modifier.height(14.dp))159            LockRuneString()160            Spacer(Modifier.height(24.dp))161162            // Glass panel holding the credential inputs.163            GlassPanel(164                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },165            ) {166                Row(verticalAlignment = Alignment.CenterVertically) {167                    FieldLabel(if (state.hasVault) "master password" else "create a master password")168                    if (!state.hasVault) {169                        Spacer(Modifier.weight(1f))170                        Text(171                            text = if (reveal) "hide" else "show",172                            color = accent,173                            style = PeachType.labelMono.copy(fontSize = 11.sp),174                            modifier = Modifier175                                .clip(RoundedCornerShape(6.dp))176                                .clickable { reveal = !reveal }177                                .padding(horizontal = 6.dp, vertical = 4.dp),178                        )179                    }180                }181                Spacer(Modifier.height(4.dp))182                UnderlineField(183                    value = password,184                    onChange = { password = it; vm.clearError() },185                    placeholder = "••••••••••",186                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),187                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,188                    onImeAction = { if (state.hasVault) vm.unlock(password) },189                    masked = state.hasVault || !reveal,190                )191192                if (!state.hasVault) {193                    Spacer(Modifier.height(16.dp))194                    FieldLabel("confirm password")195                    Spacer(Modifier.height(4.dp))196                    UnderlineField(197                        value = confirm,198                        onChange = { confirm = it; vm.clearError() },199                        placeholder = "••••••••••",200                        textStyle = mono18Spaced(),201                        imeAction = ImeAction.Go,202                        onImeAction = { vm.create(password, confirm) },203                        masked = !reveal,204                    )205206                    Spacer(Modifier.height(14.dp))207                    if (password.isEmpty()) {208                        Text(209                            text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",210                            color = peachColors.T2,211                            style = PeachType.hint,212                            textAlign = TextAlign.Center,213                        )214                    } else {215                        StrengthBar(score = createScore)216                        Spacer(Modifier.height(6.dp))217                        Text(218                            text = "strength: ${strengthLabel(createScore)}",219                            color = strengthColor(createScore),220                            style = PeachType.labelMono,221                            modifier = Modifier.fillMaxWidth(),222                            textAlign = TextAlign.Center,223                        )224                    }225                }226227                Text(228                    text = state.error ?: " ",229                    color = peachColors.Breach,230                    style = PeachType.labelMono.copy(fontSize = 11.sp),231                    textAlign = TextAlign.Center,232                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),233                )234235                Spacer(Modifier.height(4.dp))236                AccentButton(237                    label = when {238                        state.hasVault -> "unlock"239                        state.loading -> "creating..."240                        createBlocker != null -> createBlocker241                        else -> "create vault"242                    },243                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),244                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },245                )246247                if (state.hasVault && canBiometric && activity != null) {248                    Spacer(Modifier.height(12.dp))249                    Text(250                        text = "Use biometrics",251                        color = accent,252                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),253                        textAlign = TextAlign.Center,254                        modifier = Modifier.fillMaxWidth()255                            .clip(RoundedCornerShape(8.dp))256                            .clickable { vm.biometricUnlock(activity) }257                            .padding(vertical = 8.dp),258                    )259                }260261                if (state.hasVault) {262                    Spacer(Modifier.height(8.dp))263                    Text(264                        text = "Forgot your password?",265                        color = peachColors.T2,266                        style = PeachType.labelMono.copy(fontSize = 11.sp),267                        textAlign = TextAlign.Center,268                        modifier = Modifier.fillMaxWidth()269                            .clip(RoundedCornerShape(8.dp))270                            .clickable { onNavigate(PeachNav.RecoveryEntry) }271                            .padding(vertical = 8.dp),272                    )273                }274            }275276            if (!state.hasVault) {277                Spacer(Modifier.height(24.dp))278                RestoreTabs(onNavigate = onNavigate)279            }280        }281    }282}283284@Composable285private fun FieldLabel(text: String) {286    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))287}288289/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */290private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)291292@Composable293private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {294    Column(295        modifier296            .fillMaxWidth()297            .clip(RoundedCornerShape(18.dp))298            .background(Color(0xE0131109))299            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))300            .padding(20.dp),301        content = content,302    )303}304305@Composable306private fun UnderlineField(307    value: String,308    onChange: (String) -> Unit,309    placeholder: String,310    textStyle: TextStyle,311    imeAction: ImeAction,312    onImeAction: () -> Unit,313    masked: Boolean = true,314) {315    val accent = LocalPeachAccent.current316    val interaction = remember { MutableInteractionSource() }317    val focused by interaction.collectIsFocusedAsState()318    val hairline = peachColors.T3319    BasicTextField(320        value = value,321        onValueChange = onChange,322        singleLine = true,323        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),324        cursorBrush = SolidColor(accent),325        visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,326        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),327        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),328        interactionSource = interaction,329        modifier = Modifier330            .fillMaxWidth()331            .drawBehind {332                drawRect(333                    color = if (focused) accent else hairline,334                    topLeft = Offset(0f, size.height - 1.dp.toPx()),335                    size = Size(size.width, 1.dp.toPx()),336                )337            }338            .padding(vertical = 8.dp),339        decorationBox = { inner ->340            Box(contentAlignment = Alignment.Center) {341                if (value.isEmpty()) {342                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))343                }344                inner()345            }346        },347    )348}349350@Composable351private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {352    val accent = LocalPeachAccent.current353    Box(354        modifier = Modifier355            .fillMaxWidth()356            .alpha(if (enabled) 1f else 0.5f)357            .clip(RoundedCornerShape(10.dp))358            .background(accent)359            .clickable(enabled = enabled, onClick = onClick)360            .padding(vertical = 13.dp),361        contentAlignment = Alignment.Center,362    ) {363        Text(364            label,365            color = Color.White,366            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),367        )368    }369}370371@Composable372private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {373    val tabs = listOf(374        "PeachScript" to PeachNav.Import,375        "CSV" to PeachNav.Import,376        "From Phone" to PeachNav.ChirpPair,377        "Codex" to PeachNav.CodexRestore,378    )379    Column(horizontalAlignment = Alignment.CenterHorizontally) {380        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)381        Spacer(Modifier.height(10.dp))382        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {383            tabs.forEach { (label, dest) ->384                Box(385                    modifier = Modifier386                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))387                        .clickable { onNavigate(dest) }388                        .padding(horizontal = 10.dp, vertical = 8.dp),389                ) {390                    Text(label, color = peachColors.T1, style = PeachType.sortPill)391                }392            }393        }394    }395}