1package com.peachpasswords.ui.settings23import android.content.Intent4import android.net.Uri5import androidx.compose.animation.AnimatedVisibility6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.FlowRow14import androidx.compose.foundation.layout.ExperimentalLayoutApi15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.RowScope17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.shape.CircleShape24import androidx.compose.foundation.shape.RoundedCornerShape25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.runtime.Composable27import androidx.compose.material3.CircularProgressIndicator28import androidx.compose.runtime.DisposableEffect29import androidx.compose.runtime.collectAsState30import androidx.compose.runtime.getValue31import com.peachpasswords.feedback.FeedbackState32import com.peachpasswords.ui.theme.LocalPeachAccent33import com.peachpasswords.vault.DuressAction34import com.peachpasswords.ui.theme.peachColors35import androidx.compose.runtime.mutableStateOf36import androidx.compose.runtime.remember37import androidx.compose.runtime.rememberCoroutineScope38import androidx.compose.runtime.setValue39import androidx.compose.ui.Alignment40import androidx.compose.ui.Modifier41import androidx.compose.ui.draw.clip42import androidx.compose.ui.graphics.Color43import androidx.compose.ui.graphics.SolidColor44import androidx.compose.ui.platform.LocalContext45import androidx.compose.ui.text.TextStyle46import androidx.compose.ui.text.font.FontStyle47import androidx.compose.ui.text.input.PasswordVisualTransformation48import androidx.compose.ui.unit.Dp49import androidx.compose.ui.unit.dp50import androidx.compose.ui.unit.em51import androidx.compose.ui.unit.sp52import androidx.compose.material3.Text53import com.peachpasswords.ui.PeachNav54import com.peachpasswords.ui.sync.SyncViewModel55import com.peachpasswords.ui.theme.ACCENT_COLORS56import com.peachpasswords.ui.theme.PeachType57import androidx.hilt.navigation.compose.hiltViewModel58import com.peachpasswords.vault.VaultSettings59import kotlinx.coroutines.launch6061private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"626364656667686970@Composable71fun CardBody(72 id: CardId,73 fg: Color,74 vm: SettingsViewModel,75 settings: VaultSettings,76 entriesCount: Int,77 onNavigate: (PeachNav) -> Unit,78) {79 when (id) {80 CardId.Appearance -> AppearanceBody(fg, vm, settings)81 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)82 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)83 CardId.Sync -> SyncBody(fg, onNavigate)84 CardId.Shortcuts -> ShortcutsBody(fg)85 CardId.Pro -> ProBody(fg, vm)86 CardId.Feedback -> FeedbackBody(fg, vm)87 }88}89909192@Composable93private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {94 Text(95 text,96 modifier = Modifier.padding(top = top, bottom = 4.dp),97 color = fg.copy(alpha = 0.58f),98 fontFamily = PeachType.Mono,99 fontSize = 8.sp,100 letterSpacing = 0.22.em,101 )102}103104@Composable105private fun RowLine(fg: Color, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {106 Row(107 Modifier.fillMaxWidth().then(modifier).padding(vertical = 9.dp),108 verticalAlignment = Alignment.CenterVertically,109 horizontalArrangement = Arrangement.SpaceBetween,110 content = content,111 )112 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))113}114115@Composable116private fun RowScope.Name(text: String, fg: Color) {117 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))118}119120@Composable121private fun ValueText(text: String, fg: Color) {122 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)123}124125@Composable126private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {127 Box(128 Modifier.clip(RoundedCornerShape(8.dp))129 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))130 .background(fg.copy(alpha = 0.08f))131 .clickable(onClick = onClick)132 .padding(horizontal = 12.dp, vertical = 6.dp),133 ) {134 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)135 }136}137138@Composable139private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {140 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")141 Box(142 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))143 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))144 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))145 .clickable { onToggle(!on) },146 ) {147 Box(148 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)149 .padding(start = knob - 2.dp)150 .size(14.dp).clip(CircleShape)151 .background(if (on) fg else fg.copy(alpha = 0.58f)),152 )153 }154}155156@Composable157private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {158 Box(159 Modifier.clip(RoundedCornerShape(999.dp))160 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))161 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)162 .clickable(onClick = onClick)163 .padding(horizontal = 12.dp, vertical = 5.dp),164 ) {165 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)166 }167}168169@Composable170private fun ProBadge(fg: Color) {171 Box(172 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))173 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),174 ) {175 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)176 }177}178179@Composable180private fun Hint(text: String, fg: Color) {181 Text(182 text,183 modifier = Modifier.padding(top = 6.dp),184 color = fg.copy(alpha = 0.6f),185 fontFamily = PeachType.Display,186 fontStyle = FontStyle.Italic,187 fontSize = 11.5.sp,188 lineHeight = 16.sp,189 )190}191192193194@OptIn(ExperimentalLayoutApi::class)195@Composable196private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {197 SubLabel("THEME", fg, top = 0.dp)198 RowLine(fg) {199 Name("Theme", fg)200 201 202 val (label, next) = when (s.theme) {203 "light" -> "Light" to "system"204 "system" -> "System" to "dark"205 else -> "Dark" to "light"206 }207 SubtleButton(label, fg) { vm.setTheme(next) }208 }209 SubLabel("ACCENT", fg)210 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {211 ACCENT_COLORS.forEach { (name, color) ->212 Box(213 Modifier.size(22.dp).clip(CircleShape).background(color)214 .border(215 if (s.accentColor == name) 2.dp else 1.dp,216 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),217 CircleShape,218 )219 .clickable { vm.setAccent(name) },220 )221 }222 }223 SubLabel("TEXT SIZE", fg)224 RowLine(fg) {225 Name("Size", fg)226 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {227 Pill("Default", s.textSize == "medium", fg) { vm.setTextSize("medium") }228 Pill("Large", s.textSize == "large", fg) { vm.setTextSize("large") }229 }230 }231 SubLabel("MOTION", fg)232 RowLine(fg) {233 Name("Reduce motion", fg)234 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }235 }236}237238239240@Composable241private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {242 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")243 val current = s.autoLockMinutes ?: 0244245 SubLabel("LOCKING", fg, top = 0.dp)246 RowLine(fg) {247 Name("Auto-lock after", fg)248 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)249 SubtleButton(lockOptions[idx].second, fg) {250 val next = lockOptions[(idx + 1) % lockOptions.size].first251 vm.setAutoLockMinutes(if (next == 0) 0 else next)252 }253 }254 SubLabel("BIOMETRICS", fg)255 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity256 RowLine(fg) {257 Name("Unlock with biometrics", fg)258 Toggle(s.biometricsEnabled, fg) { on ->259 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()260 }261 }262 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)263 if (bioError != null) {264 Text(265 bioError,266 color = com.peachpasswords.ui.theme.peachColors.Breach,267 fontFamily = PeachType.Mono, fontSize = 10.sp,268 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),269 )270 }271 SubLabel("RECOVERY", fg)272 RowLine(fg) {273 Name("View recovery words", fg)274 SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }275 }276 SubLabel("BREACH PROTECTION", fg)277 RowLine(fg) {278 Name("Breach monitoring", fg)279 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }280 }281282 SubLabel("MASTER PASSWORD", fg)283 ChangePasswordRow(fg, vm)284285 SubLabel("DURESS PASSWORD", fg)286 val duressIsPro by vm.isPro.collectAsState()287 if (!duressIsPro) {288 RowLine(fg) {289 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {290 Name("Duress password", fg); ProBadge(fg)291 }292 ValueText("off", fg)293 }294 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)295 } else {296 DuressSection(fg, vm)297 }298}299300@Composable301private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {302 var open by remember { mutableStateOf(false) }303 var current by remember { mutableStateOf("") }304 var next by remember { mutableStateOf("") }305 var confirm by remember { mutableStateOf("") }306 var error by remember { mutableStateOf<String?>(null) }307 var done by remember { mutableStateOf(false) }308 val scope = rememberCoroutineScope()309310 RowLine(fg) {311 Name("Master password", fg)312 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }313 }314 AnimatedVisibility(open) {315 Column(Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {316 PwField("current password", current, fg) { current = it }317 PwField("new password (min 12 chars)", next, fg) { next = it }318 PwField("confirm new password", confirm, fg) { confirm = it }319 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }320 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)321 SubtleButton("save new password", fg) {322 error = when {323 current.isBlank() -> "enter your current password"324 next.length < 12 -> "new password must be at least 12 characters"325 next != confirm -> "new passwords do not match"326 next == current -> "new password must differ from current"327 else -> null328 }329 if (error == null) scope.launch {330 if (vm.changePassword(current, next)) {331 current = ""; next = ""; confirm = ""; open = false; done = true332 } else error = "incorrect current password"333 }334 }335 }336 }337}338339@Composable340private fun PwField(placeholder: String, value: String, fg: Color, onChange: (String) -> Unit) {341 Box(342 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))343 .background(Color(0x2E000000))344 .border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))345 .padding(horizontal = 10.dp, vertical = 10.dp),346 ) {347 if (value.isEmpty()) {348 Text(placeholder, color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Mono, fontSize = 12.sp)349 }350 BasicTextField(351 value = value,352 onValueChange = onChange,353 singleLine = true,354 textStyle = TextStyle(color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp),355 cursorBrush = SolidColor(fg),356 visualTransformation = PasswordVisualTransformation(),357 modifier = Modifier.fillMaxWidth(),358 )359 }360}361362363364@Composable365private fun VaultBody(366 fg: Color,367 vm: SettingsViewModel,368 s: VaultSettings,369 entriesCount: Int,370 onNavigate: (PeachNav) -> Unit,371) {372 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")373 val current = s.trashRetentionDays ?: 0374 val isPro by vm.isPro.collectAsState()375376 SubLabel("ENTRIES", fg, top = 0.dp)377 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }378379 SubLabel("MAINTENANCE", fg)380 RowLine(fg) {381 Name("Trash auto-delete", fg)382 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)383 SubtleButton(trashOptions[idx].second, fg) {384 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)385 }386 }387 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }388 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }389390 SubLabel("DATA", fg)391 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }392 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }393 RowLine(fg) {394 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }395 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }396 }397 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }398399 var advancedOpen by remember { mutableStateOf(false) }400 SubLabel("ADVANCED", fg)401 RowLine(fg) {402 Name("Advanced", fg)403 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }404 }405 if (advancedOpen) {406 RowLine(fg) {407 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)408 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }409 }410 RowLine(fg) {411 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)412 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }413 }414 }415}416417418419@Composable420private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {421 val syncVm: SyncViewModel = hiltViewModel()422 val pairedDevices by syncVm.pairedDevices.collectAsState()423424 SubLabel("PAIRING", fg, top = 0.dp)425 RowLine(fg) {426 Column(Modifier.weight(1f)) {427 Row(verticalAlignment = Alignment.CenterVertically) {428 Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)429 }430 Text(431 "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.",432 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,433 modifier = Modifier.padding(top = 2.dp),434 )435 }436 }437 438 439 RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) {440 Name("Sync hub", fg)441 Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)442 }443 RowLine(fg) {444 Column(Modifier.weight(1f)) {445 Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)446 Text(447 "Add a phone, another browser, or your desktop sync helper.",448 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,449 modifier = Modifier.padding(top = 2.dp),450 )451 }452 SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) }453 }454455 SubLabel("PAIRED DEVICES", fg)456 if (pairedDevices.isEmpty()) {457 Text(458 "No paired devices yet.",459 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp,460 modifier = Modifier.padding(top = 2.dp),461 )462 } else {463 pairedDevices.forEach { d ->464 RowLine(fg) {465 Name(d.deviceName ?: d.deviceId, fg)466 ValueText(if (d.lastSyncedAt != null) "synced" else "never", fg)467 }468 }469 RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { syncVm.syncNow() } }470 }471472 Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)473}474475476477@Composable478private fun ShortcutsBody(fg: Color) {479 val bindings = listOf(480 "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵",481 "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/",482 )483 SubLabel("KEYBINDINGS", fg, top = 0.dp)484 Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg)485 Spacer(Modifier.height(8.dp))486 bindings.forEach { (label, key) ->487 RowLine(fg) {488 Name(label, fg)489 Box(490 Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f))491 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp))492 .padding(horizontal = 7.dp, vertical = 2.dp),493 ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) }494 }495 }496}497498499500@Composable501private fun ProBody(fg: Color, vm: SettingsViewModel) {502 val ctx = LocalContext.current503 val isPro by vm.isPro.collectAsState()504 if (isPro) {505 ProActiveBody(fg, vm)506 return507 }508 val features = listOf(509 "Peach Sync" to "Encrypted · peer-to-peer",510 "Peach Codex" to "Restore from paper",511 "Passkeys" to "Zero-knowledge WebAuthn",512 "Advanced Vault Health" to "Reuse · age · exposure",513 "Canary entries" to "Detects vault compromise",514 "Duress vault" to "Decoy under coercion",515 )516 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)517 Text(518 "Everything, everywhere.",519 modifier = Modifier.padding(top = 8.dp),520 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,521 )522 Text(523 "One key unlocks every Pro feature — for the lifetime of the app.",524 modifier = Modifier.padding(top = 6.dp),525 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,526 )527 Spacer(Modifier.height(12.dp))528 features.forEachIndexed { i, (name, desc) ->529 RowLine(fg) {530 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {531 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))532 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)533 }534 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)535 }536 }537 Spacer(Modifier.height(14.dp))538 Text(539 "$39",540 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,541 )542 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)543 Spacer(Modifier.height(10.dp))544 Box(545 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))546 .background(fg.copy(alpha = 0.16f))547 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))548 .clickable {549 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }550 }551 .padding(vertical = 12.dp),552 contentAlignment = Alignment.Center,553 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }554555 Spacer(Modifier.height(16.dp))556 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)557 var key by remember { mutableStateOf("") }558 val activating by vm.activating.collectAsState()559 val activationError by vm.activationError.collectAsState()560 Box(561 Modifier.fillMaxWidth().padding(top = 6.dp)562 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))563 .padding(horizontal = 12.dp, vertical = 10.dp),564 ) {565 BasicTextField(566 value = key,567 onValueChange = { key = it.uppercase(); vm.clearActivationError() },568 singleLine = true,569 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),570 cursorBrush = SolidColor(fg),571 modifier = Modifier.fillMaxWidth(),572 decorationBox = { inner ->573 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)574 inner()575 },576 )577 }578 if (activationError != null) {579 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))580 }581 Spacer(Modifier.height(8.dp))582 Box(583 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))584 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))585 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))586 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }587 .padding(vertical = 12.dp),588 contentAlignment = Alignment.Center,589 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }590}591592@Composable593private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {594 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")595 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {596 com.peachpasswords.ui.components.PeachMark(size = 60.dp)597 }598 Text(599 "Peach Pro",600 modifier = Modifier.fillMaxWidth(),601 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,602 textAlign = androidx.compose.ui.text.style.TextAlign.Center,603 )604 Text(605 "active · yours forever",606 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),607 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,608 textAlign = androidx.compose.ui.text.style.TextAlign.Center,609 )610 Spacer(Modifier.height(12.dp))611 features.forEachIndexed { i, name ->612 RowLine(fg) {613 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {614 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))615 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)616 }617 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)618 }619 }620 Spacer(Modifier.height(12.dp))621 Box(622 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))623 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))624 .clickable { vm.deactivate() }625 .padding(vertical = 12.dp),626 contentAlignment = Alignment.Center,627 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }628}629630631632@OptIn(ExperimentalLayoutApi::class)633@Composable634private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {635 val categories = listOf("Bug report", "Feature request", "Question", "Other")636 var category by remember { mutableStateOf(categories[0]) }637 var message by remember { mutableStateOf("") }638 val state by vm.feedbackState.collectAsState()639640 641 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }642643 if (state is FeedbackState.Sent) {644 Box(645 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))646 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),647 ) {648 Column {649 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)650 Text("Your note is in the queue. Someone will process it soon.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp))651 }652 }653 return654 }655656 val sending = state is FeedbackState.Sending657658 Text("Help us improve Peach. Every report goes directly to our inbox.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp)659 SubLabel("TYPE", fg)660 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {661 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }662 }663 SubLabel("MESSAGE", fg)664 Box(665 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))666 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))667 .padding(10.dp),668 ) {669 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)670 BasicTextField(671 value = message, onValueChange = { message = it },672 enabled = !sending,673 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),674 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),675 )676 }677 Spacer(Modifier.height(8.dp))678679 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {680 val canSend = !sending && message.isNotBlank()681 Box(682 Modifier.clip(RoundedCornerShape(8.dp))683 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))684 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))685 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }686 .padding(horizontal = 12.dp, vertical = 6.dp),687 ) {688 Text(689 if (state is FeedbackState.Error) "Retry" else "Send",690 color = fg.copy(alpha = if (canSend) 1f else 0.4f),691 fontFamily = PeachType.Mono, fontSize = 11.sp,692 )693 }694 if (sending) {695 CircularProgressIndicator(696 color = LocalPeachAccent.current,697 strokeWidth = 2.dp,698 modifier = Modifier.size(16.dp),699 )700 }701 }702703 (state as? FeedbackState.Error)?.let { err ->704 Text(705 err.message,706 modifier = Modifier.padding(top = 8.dp),707 color = peachColors.Breach,708 fontFamily = PeachType.Mono,709 fontSize = 11.sp,710 lineHeight = 15.sp,711 )712 }713714 Hint("Sent anonymously · no account required", fg)715}716717718719@Composable720internal fun DuressSection(fg: Color, vm: SettingsViewModel) {721 val configured by vm.duressConfigured.collectAsState()722 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)723}724725@Composable726private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {727 Text(728 "Duress password is active",729 color = peachColors.HealthOk,730 fontFamily = PeachType.Mono,731 fontSize = 11.sp,732 modifier = Modifier.padding(top = 6.dp),733 )734 Spacer(Modifier.height(8.dp))735 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }736 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)737}738739@Composable740private fun DuressForm(fg: Color, vm: SettingsViewModel) {741 var enabled by remember { mutableStateOf(false) }742 var password by remember { mutableStateOf("") }743 var confirm by remember { mutableStateOf("") }744 var action by remember { mutableStateOf(DuressAction.DECOY) }745 var error by remember { mutableStateOf<String?>(null) }746 val scope = rememberCoroutineScope()747748 RowLine(fg) {749 Name("Duress password", fg)750 Toggle(enabled, fg) { on ->751 enabled = on752 if (!on) { password = ""; confirm = ""; error = null }753 }754 }755 Text(756 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",757 color = fg.copy(alpha = 0.7f),758 fontFamily = PeachType.Mono,759 fontSize = 10.sp,760 modifier = Modifier.padding(top = 6.dp),761 )762763 AnimatedVisibility(enabled) {764 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {765 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }766 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }767768 Text(769 "When your duress password is entered at the lock screen, Peach will:",770 color = fg.copy(alpha = 0.7f),771 fontFamily = PeachType.Mono,772 fontSize = 10.sp,773 modifier = Modifier.padding(top = 4.dp),774 )775776 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }777 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }778779 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }780781 val canSave = password.isNotBlank() && confirm.isNotBlank()782 DuressSaveButton("Save duress password", fg, canSave) {783 error = when {784 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"785 password != confirm -> "Passwords do not match"786 else -> null787 }788 if (error == null) scope.launch {789 if (vm.saveDuress(password, action)) {790 password = ""; confirm = ""; enabled = false791 } else {792 error = "Couldn't save — the duress passphrase must differ from your real password"793 }794 }795 }796 }797 }798}799800@Composable801private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {802 val accent = LocalPeachAccent.current803 Row(804 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),805 verticalAlignment = Alignment.CenterVertically,806 horizontalArrangement = Arrangement.spacedBy(10.dp),807 ) {808 Box(809 Modifier.size(16.dp).clip(CircleShape)810 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),811 contentAlignment = Alignment.Center,812 ) {813 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))814 }815 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)816 }817}818819@Composable820private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {821 val accent = LocalPeachAccent.current822 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)823 Box(824 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))825 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))826 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))827 .let { if (enabled) it.clickable(onClick = onClick) else it }828 .padding(vertical = 11.dp),829 contentAlignment = Alignment.Center,830 ) {831 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)832 }833}834835@Composable836private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {837 Box(838 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))839 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))840 .clickable(onClick = onClick)841 .padding(vertical = 11.dp),842 contentAlignment = Alignment.Center,843 ) {844 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)845 }846}