LockScreen.kt

14.9 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.style.TextAlign48import androidx.compose.ui.unit.IntOffset49import androidx.compose.ui.unit.dp50import androidx.compose.ui.unit.em51import androidx.compose.ui.unit.sp52import androidx.hilt.navigation.compose.hiltViewModel53import androidx.compose.runtime.collectAsState54import androidx.compose.ui.platform.LocalContext55import androidx.fragment.app.FragmentActivity56import com.peachpasswords.ui.PeachNav57import com.peachpasswords.ui.components.LockRuneString58import com.peachpasswords.ui.components.PeachMark59import com.peachpasswords.ui.theme.LocalPeachAccent60import com.peachpasswords.ui.theme.PeachSpacing61import com.peachpasswords.ui.theme.peachColors62import com.peachpasswords.ui.security.StrengthBar63import com.peachpasswords.ui.security.scorePassword64import com.peachpasswords.ui.security.strengthColor65import com.peachpasswords.ui.security.strengthLabel66import com.peachpasswords.ui.theme.PeachType67import com.peachpasswords.ui.theme.ShellBackground6869@Composable70fun LockScreen(71    onUnlocked: (PeachNav) -> Unit,72    onNavigate: (PeachNav) -> Unit,73    vm: LockViewModel = hiltViewModel(),74) {75    val state by vm.state.collectAsState()76    val accent = LocalPeachAccent.current77    val context = LocalContext.current78    val activity = context as? FragmentActivity79    val canBiometric = remember(state.hasVault) { vm.canTryBiometric() }8081    var password by remember { mutableStateOf("") }82    var confirm by remember { mutableStateOf("") }83    val createScore = remember(password) { scorePassword(password) }8485    LaunchedEffect(state.unlocked) {86        if (state.unlocked) {87            // A freshly created vault routes through recovery-word setup before the list;88            // an ordinary unlock lands on the last-viewed entry (brief Fix 3).89            if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())90        }91    }9293    // Auto-offer biometric unlock shortly after the lock screen appears.94    LaunchedEffect(canBiometric) {95        if (canBiometric && activity != null && !state.unlocked) {96            kotlinx.coroutines.delay(300)97            vm.biometricUnlock(activity)98        }99    }100101    // Shake the panel on each failed unlock (brief Phase 4: 0→−12→10→−8→6→0 over ~400ms).102    val shake = remember { Animatable(0f) }103    LaunchedEffect(state.shakeNonce) {104        if (state.shakeNonce > 0) {105            shake.animateTo(106                targetValue = 0f,107                animationSpec = keyframes {108                    durationMillis = 400109                    -12f at 60; 10f at 140; -8f at 230; 6f at 320; 0f at 400110                },111            )112        }113    }114115    ShellBackground {116        // Wordmark, top-left.117        Row(118            modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),119            verticalAlignment = Alignment.CenterVertically,120        ) {121            PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))122            Text(123                text = "peach",124                color = accent,125                style = PeachType.wordmark,126            )127        }128129        Column(130            modifier = Modifier131                // Gate 1 (wide-screen/foldable audit): this card-style content132                // was stretching edge-to-edge on tablet/foldable widths instead133                // of staying a comfortable card width — cap it and center it in134                // the remaining space, same as a phone gets automatically.135                .align(Alignment.Center)136                .fillMaxHeight()137                .widthIn(max = 480.dp)138                .fillMaxWidth()139                .systemBarsPadding()140                .padding(horizontal = 28.dp),141            verticalArrangement = Arrangement.Center,142            horizontalAlignment = Alignment.CenterHorizontally,143        ) {144            // Hero: centered PeachMark · eyebrow · headline.145            PeachMark(size = 80.dp)146            Spacer(Modifier.height(18.dp))147            Text(148                text = if (state.hasVault) "VAULT LOCKED" else "NEW VAULT",149                color = peachColors.T2,150                style = PeachType.lockEyebrow,151            )152            Spacer(Modifier.height(10.dp))153            Text(154                text = if (state.hasVault) "unlock\nyour vault." else "keep your\nsecrets.",155                color = peachColors.T0,156                style = if (state.hasVault) PeachType.lockHeadline else PeachType.createHeadline,157                textAlign = TextAlign.Center,158            )159            Spacer(Modifier.height(14.dp))160            LockRuneString()161            Spacer(Modifier.height(24.dp))162163            // Glass panel holding the credential inputs.164            GlassPanel(165                modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },166            ) {167                FieldLabel(if (state.hasVault) "master password" else "create a master password")168                Spacer(Modifier.height(4.dp))169                UnderlineField(170                    value = password,171                    onChange = { password = it; vm.clearError() },172                    placeholder = "••••••••••",173                    textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),174                    imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,175                    onImeAction = { if (state.hasVault) vm.unlock(password) },176                )177178                if (!state.hasVault) {179                    Spacer(Modifier.height(16.dp))180                    FieldLabel("confirm password")181                    Spacer(Modifier.height(4.dp))182                    UnderlineField(183                        value = confirm,184                        onChange = { confirm = it; vm.clearError() },185                        placeholder = "••••••••••",186                        textStyle = mono18Spaced(),187                        imeAction = ImeAction.Go,188                        onImeAction = { vm.create(password, confirm) },189                    )190191                    if (password.isNotEmpty()) {192                        Spacer(Modifier.height(14.dp))193                        StrengthBar(score = createScore)194                        Spacer(Modifier.height(6.dp))195                        Text(196                            text = "strength: ${strengthLabel(createScore)}",197                            color = strengthColor(createScore),198                            style = PeachType.labelMono,199                            modifier = Modifier.fillMaxWidth(),200                            textAlign = TextAlign.Center,201                        )202                    }203                }204205                Text(206                    text = state.error ?: " ",207                    color = peachColors.Breach,208                    style = PeachType.labelMono.copy(fontSize = 11.sp),209                    textAlign = TextAlign.Center,210                    modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(18.dp),211                )212213                Spacer(Modifier.height(4.dp))214                AccentButton(215                    label = if (state.hasVault) "unlock" else "create vault",216                    enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),217                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },218                )219220                if (state.hasVault && canBiometric && activity != null) {221                    Spacer(Modifier.height(12.dp))222                    Text(223                        text = "Use biometrics",224                        color = accent,225                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),226                        textAlign = TextAlign.Center,227                        modifier = Modifier.fillMaxWidth()228                            .clip(RoundedCornerShape(8.dp))229                            .clickable { vm.biometricUnlock(activity) }230                            .padding(vertical = 8.dp),231                    )232                }233234                if (state.hasVault) {235                    Spacer(Modifier.height(8.dp))236                    Text(237                        text = "Forgot your password?",238                        color = peachColors.T2,239                        style = PeachType.labelMono.copy(fontSize = 11.sp),240                        textAlign = TextAlign.Center,241                        modifier = Modifier.fillMaxWidth()242                            .clip(RoundedCornerShape(8.dp))243                            .clickable { onNavigate(PeachNav.RecoveryEntry) }244                            .padding(vertical = 8.dp),245                    )246                }247            }248249            if (!state.hasVault) {250                Spacer(Modifier.height(24.dp))251                RestoreTabs(onNavigate = onNavigate)252            }253        }254    }255}256257@Composable258private fun FieldLabel(text: String) {259    Text(text.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))260}261262/** IBM Plex Mono 18sp, wide tracking — the create-vault input style (spec §5.2). */263private fun mono18Spaced() = TextStyle(fontFamily = PeachType.Mono, fontSize = 18.sp, letterSpacing = 0.18.em)264265@Composable266private fun GlassPanel(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {267    Column(268        modifier269            .fillMaxWidth()270            .clip(RoundedCornerShape(18.dp))271            .background(Color(0xE0131109))272            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))273            .padding(20.dp),274        content = content,275    )276}277278@Composable279private fun UnderlineField(280    value: String,281    onChange: (String) -> Unit,282    placeholder: String,283    textStyle: TextStyle,284    imeAction: ImeAction,285    onImeAction: () -> Unit,286) {287    val accent = LocalPeachAccent.current288    val interaction = remember { MutableInteractionSource() }289    val focused by interaction.collectIsFocusedAsState()290    val hairline = peachColors.T3291    BasicTextField(292        value = value,293        onValueChange = onChange,294        singleLine = true,295        textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),296        cursorBrush = SolidColor(accent),297        visualTransformation = PasswordVisualTransformation(),298        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),299        keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),300        interactionSource = interaction,301        modifier = Modifier302            .fillMaxWidth()303            .drawBehind {304                drawRect(305                    color = if (focused) accent else hairline,306                    topLeft = Offset(0f, size.height - 1.dp.toPx()),307                    size = Size(size.width, 1.dp.toPx()),308                )309            }310            .padding(vertical = 8.dp),311        decorationBox = { inner ->312            Box(contentAlignment = Alignment.Center) {313                if (value.isEmpty()) {314                    Text(placeholder, style = textStyle.copy(color = peachColors.T2, textAlign = TextAlign.Center))315                }316                inner()317            }318        },319    )320}321322@Composable323private fun AccentButton(label: String, enabled: Boolean, onClick: () -> Unit) {324    val accent = LocalPeachAccent.current325    Box(326        modifier = Modifier327            .fillMaxWidth()328            .alpha(if (enabled) 1f else 0.5f)329            .clip(RoundedCornerShape(10.dp))330            .background(accent)331            .clickable(enabled = enabled, onClick = onClick)332            .padding(vertical = 13.dp),333        contentAlignment = Alignment.Center,334    ) {335        Text(336            label,337            color = Color.White,338            style = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, letterSpacing = 0.06.em),339        )340    }341}342343@Composable344private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {345    val tabs = listOf(346        "PeachScript" to PeachNav.Import,347        "CSV" to PeachNav.Import,348        "From Phone" to PeachNav.ChirpPair,349        "Codex" to PeachNav.CodexRestore,350    )351    Column(horizontalAlignment = Alignment.CenterHorizontally) {352        Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)353        Spacer(Modifier.height(10.dp))354        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {355            tabs.forEach { (label, dest) ->356                Box(357                    modifier = Modifier358                        .border(1.dp, peachColors.T3, RoundedCornerShape(8.dp))359                        .clickable { onNavigate(dest) }360                        .padding(horizontal = 10.dp, vertical = 8.dp),361                ) {362                    Text(label, color = peachColors.T1, style = PeachType.sortPill)363                }364            }365        }366    }367}