LockScreen.kt

16.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.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                .align(Alignment.Center)131                .fillMaxHeight()132                .widthIn(max = com.peachpasswords.ui.theme.CONTENT_MAX_WIDTH)133                .fillMaxWidth()134                .systemBarsPadding()135                .padding(horizontal = 28.dp),136            verticalArrangement = Arrangement.Center,137            horizontalAlignment = Alignment.CenterHorizontally,138        ) {139            // Hero: centered PeachMark · eyebrow · headline.140            PeachMark(size = 80.dp)141            Spacer(Modifier.height(18.dp))142            Text(143                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",144                color = peachColors.T2,145                style = PeachType.lockEyebrow,146            )147            Spacer(Modifier.height(10.dp))148            Text(149                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",150                color = peachColors.T0,151                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,152                textAlign = TextAlign.Center,153            )154            Spacer(Modifier.height(14.dp))155            LockRuneString()156            Spacer(Modifier.height(24.dp))157158            // Glass panel holding the credential inputs.159            GlassPanel(160                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },161            ) {162                Row(verticalAlignment = Alignment.CenterVertically) {163                    FieldLabel(if (state.hasVault) "master password" else "create a master password")164                    if (!state.hasVault) {165                        Spacer(Modifier.weight(1f))166                        Text(167                            text = if (reveal) "hide" else "show",168                            color = accent,169                            style = PeachType.labelMono.copy(fontSize = 11.sp),170                            modifier = Modifier171                                .clip(RoundedCornerShape(6.dp))172                                .clickable { reveal = !reveal }173                                .padding(horizontal = 6.dp, vertical = 4.dp),174                        )175                    }176                }177                Spacer(Modifier.height(4.dp))178                UnderlineField(179                    value = password,180                    onChange = { password = it; vm.clearError() },181                    placeholder = "••••••••••",182                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),183                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,184                    onImeAction = { if (state.hasVault) vm.unlock(password) },185                    masked = state.hasVault || !reveal,186                )187188                if (!state.hasVault) {189                    Spacer(Modifier.height(16.dp))190                    FieldLabel("confirm password")191                    Spacer(Modifier.height(4.dp))192                    UnderlineField(193                        value = confirm,194                        onChange = { confirm = it; vm.clearError() },195                        placeholder = "••••••••••",196                        textStyle = mono18Spaced(),197                        imeAction = ImeAction.Go,198                        onImeAction = { vm.create(password, confirm) },199                        masked = !reveal,200                    )201202                    Spacer(Modifier.height(14.dp))203                    if (password.isEmpty()) {204                        Text(205                            text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",206                            color = peachColors.T2,207                            style = PeachType.hint,208                            textAlign = TextAlign.Center,209                        )210                    } else {211                        StrengthBar(score = createScore)212                        Spacer(Modifier.height(6.dp))213                        Text(214                            text = "strength: ${strengthLabel(createScore)}",215                            color = strengthColor(createScore),216                            style = PeachType.labelMono,217                            modifier = Modifier.fillMaxWidth(),218                            textAlign = TextAlign.Center,219                        )220                    }221                }222223                Text(224                    text = state.error ?: " ",225                    color = peachColors.Breach,226                    style = PeachType.labelMono.copy(fontSize = 11.sp),227                    textAlign = TextAlign.Center,228                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),229                )230231                Spacer(Modifier.height(4.dp))232                AccentButton(233                    label = when {234                        state.hasVault -> "unlock"235                        state.loading -> "creating..."236                        createBlocker != null -> createBlocker237                        else -> "create vault"238                    },239                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),240                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },241                )242243                if (state.hasVault && canBiometric && activity != null) {244                    Spacer(Modifier.height(12.dp))245                    Text(246                        text = "Use biometrics",247                        color = accent,248                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),249                        textAlign = TextAlign.Center,250                        modifier = Modifier.fillMaxWidth()251                            .clip(RoundedCornerShape(8.dp))252                            .clickable { vm.biometricUnlock(activity) }253                            .padding(vertical = 8.dp),254                    )255                }256257                if (state.hasVault) {258                    Spacer(Modifier.height(8.dp))259                    Text(260                        text = "Forgot your password?",261                        color = peachColors.T2,262                        style = PeachType.labelMono.copy(fontSize = 11.sp),263                        textAlign = TextAlign.Center,264                        modifier = Modifier.fillMaxWidth()265                            .clip(RoundedCornerShape(8.dp))266                            .clickable { onNavigate(PeachNav.RecoveryEntry) }267                            .padding(vertical = 8.dp),268                    )269                }270            }271272            if (!state.hasVault) {273                Spacer(Modifier.height(24.dp))274                RestoreTabs(onNavigate = onNavigate)275            }276        }277    }278}279280@Composable281private fun FieldLabel(text: String) {282    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))283}284285/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */286private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)287288@Composable289private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {290    Column(291        modifier292            .fillMaxWidth()293            .clip(RoundedCornerShape(18.dp))294            .background(Color(0xE0131109))295            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))296            .padding(20.dp),297        content = content,298    )299}300301@Composable302private fun UnderlineField(303    value: String,304    onChange: (String) -> Unit,305    placeholder: String,306    textStyle: TextStyle,307    imeAction: ImeAction,308    onImeAction: () -> Unit,309    masked: Boolean = true,310) {311    val accent = LocalPeachAccent.current312    val interaction = remember { MutableInteractionSource() }313    val focused by interaction.collectIsFocusedAsState()314    val hairline = peachColors.T3315    BasicTextField(316        value = value,317        onValueChange = onChange,318        singleLine = true,319        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),320        cursorBrush = SolidColor(accent),321        visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,322        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),323        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),324        interactionSource = interaction,325        modifier = Modifier326            .fillMaxWidth()327            .drawBehind {328                drawRect(329                    color = if (focused) accent else hairline,330                    topLeft = Offset(0f, size.height - 1.dp.toPx()),331                    size = Size(size.width, 1.dp.toPx()),332                )333            }334            .padding(vertical = 8.dp),335        decorationBox = { inner ->336            Box(contentAlignment = Alignment.Center) {337                if (value.isEmpty()) {338                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))339                }340                inner()341            }342        },343    )344}345346@Composable347private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {348    val accent = LocalPeachAccent.current349    Box(350        modifier = Modifier351            .fillMaxWidth()352            .alpha(if (enabled) 1f else 0.5f)353            .clip(RoundedCornerShape(10.dp))354            .background(accent)355            .clickable(enabled = enabled, onClick = onClick)356            .padding(vertical = 13.dp),357        contentAlignment = Alignment.Center,358    ) {359        Text(360            label,361            color = Color.White,362            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),363        )364    }365}366367@Composable368private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {369    val tabs = listOf(370        "PeachScript" to PeachNav.Import,371        "CSV" to PeachNav.Import,372        "From Phone" to PeachNav.ChirpPair,373        "Codex" to PeachNav.CodexRestore,374    )375    Column(horizontalAlignment = Alignment.CenterHorizontally) {376        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)377        Spacer(Modifier.height(10.dp))378        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {379            tabs.forEach { (label, dest) ->380                Box(381                    modifier = Modifier382                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))383                        .clickable { onNavigate(dest) }384                        .padding(horizontal = 10.dp, vertical = 8.dp),385                ) {386                    Text(label, color = peachColors.T1, style = PeachType.sortPill)387                }388            }389        }390    }391}