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 88 89 if (state.created) onNavigate(PeachNav.RecoverySetup) else onUnlocked(vm.postUnlockDestination())90 }91 }9293 94 LaunchedEffect(canBiometric) {95 if (canBiometric && activity != null && !state.unlocked) {96 kotlinx.coroutines.delay(300)97 vm.biometricUnlock(activity)98 }99 }100101 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 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 132 133 134 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 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 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}261262263private 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}