LockScreen.kt

14.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.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.PeachSpacing59import com.peachpasswords.ui.theme.peachColors60import com.peachpasswords.ui.security.StrengthBar61import com.peachpasswords.ui.security.scorePassword62import com.peachpasswords.ui.security.strengthColor63import com.peachpasswords.ui.security.strengthLabel64import com.peachpasswords.ui.theme.PeachType65import com.peachpasswords.ui.theme.ShellBackground6667@Composable68fun LockScreen(69    onUnlocked: (PeachNav) -> Unit,70    onNavigate: (PeachNav) -> Unit,71    vm: LockViewModel = hiltViewModel(),72) {73    val state by vm.state.collectAsState()74    val accent = LocalPeachAccent.current75    val context = LocalContext.current76    val activity = context as? FragmentActivity77    val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }7879    var password by remember { mutableStateOf("") }80    var confirm by remember { mutableStateOf("") }81    val createScore = remember(password) { scorePassword(password) }8283    LaunchedEffect(state.unlocked) {84        if (state.unlocked) {85            // A freshly created vault routes through recovery-word setup before the list;86            // an ordinary unlock lands on the last-viewed entry (brief Fix 3).87            if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())88        }89    }9091    // Auto-offer biometric unlock shortly after the lock screen appears.92    LaunchedEffect(canBiometric) {93        if (canBiometric && activity != null && !state.unlocked) {94            kotlinx.coroutines.delay(300)95            vm.biometricUnlock(activity)96        }97    }9899    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).100    val shake = remember { Animatable(0f) }101    LaunchedEffect(state.shakeNonce) {102        if (state.shakeNonce > 0) {103            shake.animateTo(104                targetValue = 0f,105                animationSpec = keyframes {106                    durationMillis = 400107                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400108                },109            )110        }111    }112113    ShellBackground {114        // Wordmark, top-left.115        Row(116            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),117            verticalAlignment = Alignment.CenterVertically,118        ) {119            PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))120            Text(121                text = "peach",122                color = accent,123                style = PeachType.wordmark,124            )125        }126127        Column(128            modifier = Modifier129                .fillMaxSize()130                .systemBarsPadding()131                .padding(horizontal = 28.dp),132            verticalArrangement = Arrangement.Center,133            horizontalAlignment = Alignment.CenterHorizontally,134        ) {135            // Hero: centered PeachMark · eyebrow · headline.136            PeachMark(size = 80.dp)137            Spacer(Modifier.height(18.dp))138            Text(139                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",140                color = peachColors.T2,141                style = PeachType.lockEyebrow,142            )143            Spacer(Modifier.height(10.dp))144            Text(145                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",146                color = peachColors.T0,147                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,148                textAlign = TextAlign.Center,149            )150            Spacer(Modifier.height(14.dp))151            LockRuneString()152            Spacer(Modifier.height(24.dp))153154            // Glass panel holding the credential inputs.155            GlassPanel(156                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },157            ) {158                FieldLabel(if (state.hasVault) "master password" else "create a master password")159                Spacer(Modifier.height(4.dp))160                UnderlineField(161                    value = password,162                    onChange = { password = it; vm.clearError() },163                    placeholder = "••••••••••",164                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),165                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,166                    onImeAction = { if (state.hasVault) vm.unlock(password) },167                )168169                if (!state.hasVault) {170                    Spacer(Modifier.height(16.dp))171                    FieldLabel("confirm password")172                    Spacer(Modifier.height(4.dp))173                    UnderlineField(174                        value = confirm,175                        onChange = { confirm = it; vm.clearError() },176                        placeholder = "••••••••••",177                        textStyle = mono18Spaced(),178                        imeAction = ImeAction.Go,179                        onImeAction = { vm.create(password, confirm) },180                    )181182                    if (password.isNotEmpty()) {183                        Spacer(Modifier.height(14.dp))184                        StrengthBar(score = createScore)185                        Spacer(Modifier.height(6.dp))186                        Text(187                            text = "strength: ${strengthLabel(createScore)}",188                            color = strengthColor(createScore),189                            style = PeachType.labelMono,190                            modifier = Modifier.fillMaxWidth(),191                            textAlign = TextAlign.Center,192                        )193                    }194                }195196                Text(197                    text = state.error ?: " ",198                    color = peachColors.Breach,199                    style = PeachType.labelMono.copy(fontSize = 11.sp),200                    textAlign = TextAlign.Center,201                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),202                )203204                Spacer(Modifier.height(4.dp))205                AccentButton(206                    label = if (state.hasVault) "unlock" else "create vault",207                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),208                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },209                )210211                if (state.hasVault && canBiometric && activity != null) {212                    Spacer(Modifier.height(12.dp))213                    Text(214                        text = "Use biometrics",215                        color = accent,216                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),217                        textAlign = TextAlign.Center,218                        modifier = Modifier.fillMaxWidth()219                            .clip(RoundedCornerShape(8.dp))220                            .clickable { vm.biometricUnlock(activity) }221                            .padding(vertical = 8.dp),222                    )223                }224225                if (state.hasVault) {226                    Spacer(Modifier.height(8.dp))227                    Text(228                        text = "Forgot your password?",229                        color = peachColors.T2,230                        style = PeachType.labelMono.copy(fontSize = 11.sp),231                        textAlign = TextAlign.Center,232                        modifier = Modifier.fillMaxWidth()233                            .clip(RoundedCornerShape(8.dp))234                            .clickable { onNavigate(PeachNav.RecoveryEntry) }235                            .padding(vertical = 8.dp),236                    )237                }238            }239240            if (!state.hasVault) {241                Spacer(Modifier.height(24.dp))242                RestoreTabs(onNavigate = onNavigate)243            }244        }245    }246}247248@Composable249private fun FieldLabel(text: String) {250    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))251}252253/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */254private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)255256@Composable257private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {258    Column(259        modifier260            .fillMaxWidth()261            .clip(RoundedCornerShape(18.dp))262            .background(Color(0xE0131109))263            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))264            .padding(20.dp),265        content = content,266    )267}268269@Composable270private fun UnderlineField(271    value: String,272    onChange: (String) -> Unit,273    placeholder: String,274    textStyle: TextStyle,275    imeAction: ImeAction,276    onImeAction: () -> Unit,277) {278    val accent = LocalPeachAccent.current279    val interaction = remember { MutableInteractionSource() }280    val focused by interaction.collectIsFocusedAsState()281    val hairline = peachColors.T3282    BasicTextField(283        value = value,284        onValueChange = onChange,285        singleLine = true,286        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),287        cursorBrush = SolidColor(accent),288        visualTransformation = PasswordVisualTransformation(),289        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),290        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),291        interactionSource = interaction,292        modifier = Modifier293            .fillMaxWidth()294            .drawBehind {295                drawRect(296                    color = if (focused) accent else hairline,297                    topLeft = Offset(0f, size.height - 1.dp.toPx()),298                    size = Size(size.width, 1.dp.toPx()),299                )300            }301            .padding(vertical = 8.dp),302        decorationBox = { inner ->303            Box(contentAlignment = Alignment.Center) {304                if (value.isEmpty()) {305                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))306                }307                inner()308            }309        },310    )311}312313@Composable314private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {315    val accent = LocalPeachAccent.current316    Box(317        modifier = Modifier318            .fillMaxWidth()319            .alpha(if (enabled) 1f else 0.5f)320            .clip(RoundedCornerShape(10.dp))321            .background(accent)322            .clickable(enabled = enabled, onClick = onClick)323            .padding(vertical = 13.dp),324        contentAlignment = Alignment.Center,325    ) {326        Text(327            label,328            color = Color.White,329            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),330        )331    }332}333334@Composable335private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {336    val tabs = listOf(337        "PeachScript" to PeachNav.Import,338        "CSV" to PeachNav.Import,339        "From Phone" to PeachNav.ChirpPair,340        "Codex" to PeachNav.CodexRestore,341    )342    Column(horizontalAlignment = Alignment.CenterHorizontally) {343        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)344        Spacer(Modifier.height(10.dp))345        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {346            tabs.forEach { (label, dest) ->347                Box(348                    modifier = Modifier349                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))350                        .clickable { onNavigate(dest) }351                        .padding(horizontal = 10.dp, vertical = 8.dp),352                ) {353                    Text(label, color = peachColors.T1, style = PeachType.sortPill)354                }355            }356        }357    }358}