LockScreen.kt

14.1 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.fillMaxSize16import androidx.compose.foundation.layout.offset17import androidx.compose.foundation.layout.fillMaxWidth18import androidx.compose.foundation.layout.height19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.systemBarsPadding21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.text.BasicTextField23import androidx.compose.foundation.text.KeyboardActions24import androidx.compose.foundation.text.KeyboardOptions25import androidx.compose.material3.Text26import androidx.compose.runtime.Composable27import androidx.compose.runtime.LaunchedEffect28import androidx.compose.runtime.getValue29import androidx.compose.runtime.mutableStateOf30import androidx.compose.runtime.remember31import androidx.compose.runtime.setValue32import androidx.compose.ui.Alignment33import androidx.compose.ui.Modifier34import androidx.compose.ui.draw.alpha35import androidx.compose.ui.draw.clip36import androidx.compose.ui.draw.drawBehind37import androidx.compose.ui.geometry.Offset38import androidx.compose.ui.geometry.Size39import androidx.compose.ui.graphics.Color40import androidx.compose.ui.graphics.SolidColor41import androidx.compose.ui.text.TextStyle42import androidx.compose.ui.text.input.ImeAction43import androidx.compose.ui.text.input.KeyboardType44import androidx.compose.ui.text.input.PasswordVisualTransformation45import androidx.compose.ui.text.style.TextAlign46import androidx.compose.ui.unit.IntOffset47import androidx.compose.ui.unit.dp48import androidx.compose.ui.unit.em49import androidx.compose.ui.unit.sp50import androidx.hilt.navigation.compose.hiltViewModel51import androidx.compose.runtime.collectAsState52import androidx.compose.ui.platform.LocalContext53import androidx.fragment.app.FragmentActivity54import com.peachpasswords.ui.PeachNav55import com.peachpasswords.ui.components.LockRuneString56import com.peachpasswords.ui.components.PeachMark57import com.peachpasswords.ui.theme.LocalPeachAccent58import com.peachpasswords.ui.theme.peachColors59import com.peachpasswords.ui.security.StrengthBar60import com.peachpasswords.ui.security.scorePassword61import com.peachpasswords.ui.security.strengthColor62import com.peachpasswords.ui.security.strengthLabel63import com.peachpasswords.ui.theme.PeachType64import com.peachpasswords.ui.theme.ShellBackground6566@Composable67fun LockScreen(68    onUnlocked: (PeachNav) -> Unit,69    onNavigate: (PeachNav) -> Unit,70    vm: LockViewModel = hiltViewModel(),71) {72    val state by vm.state.collectAsState()73    val accent = LocalPeachAccent.current74    val context = LocalContext.current75    val activity = context as? FragmentActivity76    val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }7778    var password by remember { mutableStateOf("") }79    var confirm by remember { mutableStateOf("") }80    val createScore = remember(password) { scorePassword(password) }8182    LaunchedEffect(state.unlocked) {83        if (state.unlocked) {84            // A freshly created vault routes through recovery-word setup before the list;85            // an ordinary unlock lands on the last-viewed entry (brief Fix 3).86            if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())87        }88    }8990    // Auto-offer biometric unlock shortly after the lock screen appears.91    LaunchedEffect(canBiometric) {92        if (canBiometric && activity != null && !state.unlocked) {93            kotlinx.coroutines.delay(300)94            vm.biometricUnlock(activity)95        }96    }9798    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).99    val shake = remember { Animatable(0f) }100    LaunchedEffect(state.shakeNonce) {101        if (state.shakeNonce > 0) {102            shake.animateTo(103                targetValue = 0f,104                animationSpec = keyframes {105                    durationMillis = 400106                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400107                },108            )109        }110    }111112    ShellBackground {113        // Wordmark, top-left.114        Text(115            text = "peach",116            color = accent,117            style = PeachType.wordmark,118            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),119        )120121        Column(122            modifier = Modifier123                .fillMaxSize()124                .systemBarsPadding()125                .padding(horizontal = 28.dp),126            verticalArrangement = Arrangement.Center,127            horizontalAlignment = Alignment.CenterHorizontally,128        ) {129            // Hero: centered PeachMark · eyebrow · headline.130            PeachMark(size = 80.dp)131            Spacer(Modifier.height(18.dp))132            Text(133                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",134                color = peachColors.T2,135                style = PeachType.lockEyebrow,136            )137            Spacer(Modifier.height(10.dp))138            Text(139                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",140                color = peachColors.T0,141                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,142                textAlign = TextAlign.Center,143            )144            Spacer(Modifier.height(14.dp))145            LockRuneString()146            Spacer(Modifier.height(24.dp))147148            // Glass panel holding the credential inputs.149            GlassPanel(150                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },151            ) {152                FieldLabel(if (state.hasVault) "master password" else "create a master password")153                Spacer(Modifier.height(4.dp))154                UnderlineField(155                    value = password,156                    onChange = { password = it; vm.clearError() },157                    placeholder = "••••••••••",158                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),159                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,160                    onImeAction = { if (state.hasVault) vm.unlock(password) },161                )162163                if (!state.hasVault) {164                    Spacer(Modifier.height(16.dp))165                    FieldLabel("confirm password")166                    Spacer(Modifier.height(4.dp))167                    UnderlineField(168                        value = confirm,169                        onChange = { confirm = it; vm.clearError() },170                        placeholder = "••••••••••",171                        textStyle = mono18Spaced(),172                        imeAction = ImeAction.Go,173                        onImeAction = { vm.create(password, confirm) },174                    )175176                    if (password.isNotEmpty()) {177                        Spacer(Modifier.height(14.dp))178                        StrengthBar(score = createScore)179                        Spacer(Modifier.height(6.dp))180                        Text(181                            text = "strength: ${strengthLabel(createScore)}",182                            color = strengthColor(createScore),183                            style = PeachType.labelMono,184                            modifier = Modifier.fillMaxWidth(),185                            textAlign = TextAlign.Center,186                        )187                    }188                }189190                Text(191                    text = state.error ?: " ",192                    color = peachColors.Breach,193                    style = PeachType.labelMono.copy(fontSize = 11.sp),194                    textAlign = TextAlign.Center,195                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),196                )197198                Spacer(Modifier.height(4.dp))199                AccentButton(200                    label = if (state.hasVault) "unlock" else "create vault",201                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),202                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },203                )204205                if (state.hasVault && canBiometric && activity != null) {206                    Spacer(Modifier.height(12.dp))207                    Text(208                        text = "Use biometrics",209                        color = accent,210                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),211                        textAlign = TextAlign.Center,212                        modifier = Modifier.fillMaxWidth()213                            .clip(RoundedCornerShape(8.dp))214                            .clickable { vm.biometricUnlock(activity) }215                            .padding(vertical = 8.dp),216                    )217                }218219                if (state.hasVault) {220                    Spacer(Modifier.height(8.dp))221                    Text(222                        text = "Forgot your password?",223                        color = peachColors.T2,224                        style = PeachType.labelMono.copy(fontSize = 11.sp),225                        textAlign = TextAlign.Center,226                        modifier = Modifier.fillMaxWidth()227                            .clip(RoundedCornerShape(8.dp))228                            .clickable { onNavigate(PeachNav.RecoveryEntry) }229                            .padding(vertical = 8.dp),230                    )231                }232            }233234            if (!state.hasVault) {235                Spacer(Modifier.height(24.dp))236                RestoreTabs(onNavigate = onNavigate)237            }238        }239    }240}241242@Composable243private fun FieldLabel(text: String) {244    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))245}246247/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */248private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)249250@Composable251private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {252    Column(253        modifier254            .fillMaxWidth()255            .clip(RoundedCornerShape(18.dp))256            .background(Color(0xE0131109))257            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))258            .padding(20.dp),259        content = content,260    )261}262263@Composable264private fun UnderlineField(265    value: String,266    onChange: (String) -> Unit,267    placeholder: String,268    textStyle: TextStyle,269    imeAction: ImeAction,270    onImeAction: () -> Unit,271) {272    val accent = LocalPeachAccent.current273    val interaction = remember { MutableInteractionSource() }274    val focused by interaction.collectIsFocusedAsState()275    val hairline = peachColors.T3276    BasicTextField(277        value = value,278        onValueChange = onChange,279        singleLine = true,280        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),281        cursorBrush = SolidColor(accent),282        visualTransformation = PasswordVisualTransformation(),283        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),284        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),285        interactionSource = interaction,286        modifier = Modifier287            .fillMaxWidth()288            .drawBehind {289                drawRect(290                    color = if (focused) accent else hairline,291                    topLeft = Offset(0f, size.height - 1.dp.toPx()),292                    size = Size(size.width, 1.dp.toPx()),293                )294            }295            .padding(vertical = 8.dp),296        decorationBox = { inner ->297            Box(contentAlignment = Alignment.Center) {298                if (value.isEmpty()) {299                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))300                }301                inner()302            }303        },304    )305}306307@Composable308private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {309    val accent = LocalPeachAccent.current310    Box(311        modifier = Modifier312            .fillMaxWidth()313            .alpha(if (enabled) 1f else 0.5f)314            .clip(RoundedCornerShape(10.dp))315            .background(accent)316            .clickable(enabled = enabled, onClick = onClick)317            .padding(vertical = 13.dp),318        contentAlignment = Alignment.Center,319    ) {320        Text(321            label,322            color = Color.White,323            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),324        )325    }326}327328@Composable329private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {330    val tabs = listOf(331        "PeachScript" to PeachNav.Import,332        "CSV" to PeachNav.Import,333        "From Phone" to PeachNav.ChirpPair,334        "Codex" to PeachNav.CodexRestore,335    )336    Column(horizontalAlignment = Alignment.CenterHorizontally) {337        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)338        Spacer(Modifier.height(10.dp))339        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {340            tabs.forEach { (label, dest) ->341                Box(342                    modifier = Modifier343                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))344                        .clickable { onNavigate(dest) }345                        .padding(horizontal = 10.dp, vertical = 8.dp),346                ) {347                    Text(label, color = peachColors.T1, style = PeachType.sortPill)348                }349            }350        }351    }352}