LockScreen.kt

16.8 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.theme.LocalPeachAccent61import com.peachpasswords.ui.theme.PeachSpacing62import com.peachpasswords.ui.theme.peachColors63import com.peachpasswords.ui.security.StrengthBar64import com.peachpasswords.ui.security.scorePassword65import com.peachpasswords.ui.security.strengthColor66import com.peachpasswords.ui.security.strengthLabel67import com.peachpasswords.ui.theme.PeachType68import com.peachpasswords.ui.theme.ShellBackground6970@Composable71fun LockScreen(72    onUnlocked: (PeachNav) -> Unit,73    onNavigate: (PeachNav) -> Unit,74    vm: LockViewModel = hiltViewModel(),75) {76    val state by vm.state.collectAsState()77    val accent = LocalPeachAccent.current78    val context = LocalContext.current79    val activity = context as? FragmentActivity80    val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }8182    var password by remember { mutableStateOf("") }83    var confirm by remember { mutableStateOf("") }84    var reveal by remember { mutableStateOf(false) }85    val createScore = remember(password) { scorePassword(password) }86    // Why the create button is disabled, shown on the button itself. Without87    // it the button just silently does nothing (browser parity: Lock.tsx's88    // createBlockerMessage). Null before typing — no scolding on an empty form.89    val createBlocker = if (state.hasVault || password.isEmpty()) null90                        else createValidationError(password, confirm, createScore)9192    LaunchedEffect(state.unlocked) {93        if (state.unlocked) {94            // A freshly created vault routes through recovery-word setup before the list;95            // an ordinary unlock lands on the last-viewed entry (brief Fix 3).96            if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())97        }98    }99100    // Auto-offer biometric unlock shortly after the lock screen appears.101    LaunchedEffect(canBiometric) {102        if (canBiometric && activity != null && !state.unlocked) {103            kotlinx.coroutines.delay(300)104            vm.biometricUnlock(activity)105        }106    }107108    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).109    val shake = remember { Animatable(0f) }110    LaunchedEffect(state.shakeNonce) {111        if (state.shakeNonce > 0) {112            shake.animateTo(113                targetValue = 0f,114                animationSpec = keyframes {115                    durationMillis = 400116                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400117                },118            )119        }120    }121122    ShellBackground {123        // Wordmark, top-left.124        Row(125            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),126            verticalAlignment = Alignment.CenterVertically,127        ) {128            PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))129            Text(130                text = "peach",131                color = accent,132                style = PeachType.wordmark,133            )134        }135136        Column(137            modifier = Modifier138                // Gate 1 (wide-screen/foldable audit): this card-style content139                // was stretching edge-to-edge on tablet/foldable widths instead140                // of staying a comfortable card width — cap it and center it in141                // the remaining space, same as a phone gets automatically.142                .align(Alignment.Center)143                .fillMaxHeight()144                .widthIn(max = 480.dp)145                .fillMaxWidth()146                .systemBarsPadding()147                .padding(horizontal = 28.dp),148            verticalArrangement = Arrangement.Center,149            horizontalAlignment = Alignment.CenterHorizontally,150        ) {151            // Hero: centered PeachMark · eyebrow · headline.152            PeachMark(size = 80.dp)153            Spacer(Modifier.height(18.dp))154            Text(155                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",156                color = peachColors.T2,157                style = PeachType.lockEyebrow,158            )159            Spacer(Modifier.height(10.dp))160            Text(161                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",162                color = peachColors.T0,163                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,164                textAlign = TextAlign.Center,165            )166            Spacer(Modifier.height(14.dp))167            LockRuneString()168            Spacer(Modifier.height(24.dp))169170            // Glass panel holding the credential inputs.171            GlassPanel(172                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },173            ) {174                Row(verticalAlignment = Alignment.CenterVertically) {175                    FieldLabel(if (state.hasVault) "master password" else "create a master password")176                    if (!state.hasVault) {177                        Spacer(Modifier.weight(1f))178                        Text(179                            text = if (reveal) "hide" else "show",180                            color = accent,181                            style = PeachType.labelMono.copy(fontSize = 11.sp),182                            modifier = Modifier183                                .clip(RoundedCornerShape(6.dp))184                                .clickable { reveal = !reveal }185                                .padding(horizontal = 6.dp, vertical = 4.dp),186                        )187                    }188                }189                Spacer(Modifier.height(4.dp))190                UnderlineField(191                    value = password,192                    onChange = { password = it; vm.clearError() },193                    placeholder = "••••••••••",194                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),195                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,196                    onImeAction = { if (state.hasVault) vm.unlock(password) },197                    masked = state.hasVault || !reveal,198                )199200                if (!state.hasVault) {201                    Spacer(Modifier.height(16.dp))202                    FieldLabel("confirm password")203                    Spacer(Modifier.height(4.dp))204                    UnderlineField(205                        value = confirm,206                        onChange = { confirm = it; vm.clearError() },207                        placeholder = "••••••••••",208                        textStyle = mono18Spaced(),209                        imeAction = ImeAction.Go,210                        onImeAction = { vm.create(password, confirm) },211                        masked = !reveal,212                    )213214                    Spacer(Modifier.height(14.dp))215                    if (password.isEmpty()) {216                        Text(217                            text = "At least $MIN_PASSWORD_LENGTH characters. The vault is created once both fields match and pass the strength check.",218                            color = peachColors.T2,219                            style = PeachType.hint,220                            textAlign = TextAlign.Center,221                        )222                    } else {223                        StrengthBar(score = createScore)224                        Spacer(Modifier.height(6.dp))225                        Text(226                            text = "strength: ${strengthLabel(createScore)}",227                            color = strengthColor(createScore),228                            style = PeachType.labelMono,229                            modifier = Modifier.fillMaxWidth(),230                            textAlign = TextAlign.Center,231                        )232                    }233                }234235                Text(236                    text = state.error ?: " ",237                    color = peachColors.Breach,238                    style = PeachType.labelMono.copy(fontSize = 11.sp),239                    textAlign = TextAlign.Center,240                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),241                )242243                Spacer(Modifier.height(4.dp))244                AccentButton(245                    label = when {246                        state.hasVault -> "unlock"247                        state.loading -> "creating..."248                        createBlocker != null -> createBlocker249                        else -> "create vault"250                    },251                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),252                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },253                )254255                if (state.hasVault && canBiometric && activity != null) {256                    Spacer(Modifier.height(12.dp))257                    Text(258                        text = "Use biometrics",259                        color = accent,260                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),261                        textAlign = TextAlign.Center,262                        modifier = Modifier.fillMaxWidth()263                            .clip(RoundedCornerShape(8.dp))264                            .clickable { vm.biometricUnlock(activity) }265                            .padding(vertical = 8.dp),266                    )267                }268269                if (state.hasVault) {270                    Spacer(Modifier.height(8.dp))271                    Text(272                        text = "Forgot your password?",273                        color = peachColors.T2,274                        style = PeachType.labelMono.copy(fontSize = 11.sp),275                        textAlign = TextAlign.Center,276                        modifier = Modifier.fillMaxWidth()277                            .clip(RoundedCornerShape(8.dp))278                            .clickable { onNavigate(PeachNav.RecoveryEntry) }279                            .padding(vertical = 8.dp),280                    )281                }282            }283284            if (!state.hasVault) {285                Spacer(Modifier.height(24.dp))286                RestoreTabs(onNavigate = onNavigate)287            }288        }289    }290}291292@Composable293private fun FieldLabel(text: String) {294    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))295}296297/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */298private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)299300@Composable301private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {302    Column(303        modifier304            .fillMaxWidth()305            .clip(RoundedCornerShape(18.dp))306            .background(Color(0xE0131109))307            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))308            .padding(20.dp),309        content = content,310    )311}312313@Composable314private fun UnderlineField(315    value: String,316    onChange: (String) -> Unit,317    placeholder: String,318    textStyle: TextStyle,319    imeAction: ImeAction,320    onImeAction: () -> Unit,321    masked: Boolean = true,322) {323    val accent = LocalPeachAccent.current324    val interaction = remember { MutableInteractionSource() }325    val focused by interaction.collectIsFocusedAsState()326    val hairline = peachColors.T3327    BasicTextField(328        value = value,329        onValueChange = onChange,330        singleLine = true,331        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),332        cursorBrush = SolidColor(accent),333        visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,334        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),335        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),336        interactionSource = interaction,337        modifier = Modifier338            .fillMaxWidth()339            .drawBehind {340                drawRect(341                    color = if (focused) accent else hairline,342                    topLeft = Offset(0f, size.height - 1.dp.toPx()),343                    size = Size(size.width, 1.dp.toPx()),344                )345            }346            .padding(vertical = 8.dp),347        decorationBox = { inner ->348            Box(contentAlignment = Alignment.Center) {349                if (value.isEmpty()) {350                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))351                }352                inner()353            }354        },355    )356}357358@Composable359private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {360    val accent = LocalPeachAccent.current361    Box(362        modifier = Modifier363            .fillMaxWidth()364            .alpha(if (enabled) 1f else 0.5f)365            .clip(RoundedCornerShape(10.dp))366            .background(accent)367            .clickable(enabled = enabled, onClick = onClick)368            .padding(vertical = 13.dp),369        contentAlignment = Alignment.Center,370    ) {371        Text(372            label,373            color = Color.White,374            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),375        )376    }377}378379@Composable380private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {381    val tabs = listOf(382        "PeachScript" to PeachNav.Import,383        "CSV" to PeachNav.Import,384        "From Phone" to PeachNav.ChirpPair,385        "Codex" to PeachNav.CodexRestore,386    )387    Column(horizontalAlignment = Alignment.CenterHorizontally) {388        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)389        Spacer(Modifier.height(10.dp))390        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {391            tabs.forEach { (label, dest) ->392                Box(393                    modifier = Modifier394                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))395                        .clickable { onNavigate(dest) }396                        .padding(horizontal = 10.dp, vertical = 8.dp),397                ) {398                    Text(label, color = peachColors.T1, style = PeachType.sortPill)399                }400            }401        }402    }403}