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()375 val dedupCount by vm.dedupCount.collectAsState()376 val dedupRunning by vm.dedupRunning.collectAsState()377 val dedupDone by vm.dedupDone.collectAsState()378379 SubLabel("ENTRIES", fg, top = 0.dp)380 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }381 RowLine(fg) {382 Name("Duplicates", fg)383 val done = dedupDone384 if (done != null) {385 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)386 } else {387 val count = dedupCount388 val label = when {389 dedupRunning -> "scanning..."390 count == null -> "scan"391 count == 0 -> "none found"392 else -> "$count found"393 }394 SubtleButton(label, fg) {395 if (count == null || count == 0) vm.scanDuplicates()396 }397 }398 }399 if ((dedupCount ?: 0) > 0 && dedupDone == null) {400 Column(401 Modifier.fillMaxWidth()402 .padding(top = 2.dp, bottom = 8.dp)403 .clip(RoundedCornerShape(8.dp))404 .background(fg.copy(alpha = 0.08f))405 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))406 .padding(10.dp),407 verticalArrangement = Arrangement.spacedBy(8.dp),408 ) {409 val count = dedupCount ?: 0410 Text(411 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",412 color = fg.copy(alpha = 0.82f),413 fontFamily = PeachType.Body,414 fontSize = 11.sp,415 lineHeight = 16.sp,416 )417 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {418 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }419 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }420 }421 }422 }423424 SubLabel("MAINTENANCE", fg)425 RowLine(fg) {426 Name("Trash auto-delete", fg)427 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)428 SubtleButton(trashOptions[idx].second, fg) {429 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)430 }431 }432 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }433 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }434435 SubLabel("DATA", fg)436 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }437 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }438 RowLine(fg) {439 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }440 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }441 }442 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }443444 var advancedOpen by remember { mutableStateOf(false) }445 SubLabel("ADVANCED", fg)446 RowLine(fg) {447 Name("Advanced", fg)448 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }449 }450 if (advancedOpen) {451 RowLine(fg) {452 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)453 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }454 }455 RowLine(fg) {456 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)457 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }458 }459 }460}461462463464@Composable465private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {466 val syncVm: SyncViewModel = hiltViewModel()467 val pairedDevices by syncVm.pairedDevices.collectAsState()468469 SubLabel("PAIRING", fg, top = 0.dp)470 RowLine(fg) {471 Column(Modifier.weight(1f)) {472 Row(verticalAlignment = Alignment.CenterVertically) {473 Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)474 }475 Text(476 "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.",477 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,478 modifier = Modifier.padding(top = 2.dp),479 )480 }481 }482 483 484 RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) {485 Name("Sync hub", fg)486 Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)487 }488 RowLine(fg) {489 Column(Modifier.weight(1f)) {490 Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)491 Text(492 "Add a phone, another browser, or your desktop sync helper.",493 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,494 modifier = Modifier.padding(top = 2.dp),495 )496 }497 SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) }498 }499500 SubLabel("PAIRED DEVICES", fg)501 if (pairedDevices.isEmpty()) {502 Text(503 "No paired devices yet.",504 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp,505 modifier = Modifier.padding(top = 2.dp),506 )507 } else {508 pairedDevices.forEach { d ->509 RowLine(fg) {510 Name(d.deviceName ?: d.deviceId, fg)511 ValueText(if (d.lastSyncedAt != null) "synced" else "never", fg)512 }513 }514 RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { syncVm.syncNow() } }515 }516517 Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)518}519520521522@Composable523private fun ShortcutsBody(fg: Color) {524 val bindings = listOf(525 "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵",526 "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/",527 )528 SubLabel("KEYBINDINGS", fg, top = 0.dp)529 Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg)530 Spacer(Modifier.height(8.dp))531 bindings.forEach { (label, key) ->532 RowLine(fg) {533 Name(label, fg)534 Box(535 Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f))536 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp))537 .padding(horizontal = 7.dp, vertical = 2.dp),538 ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) }539 }540 }541}542543544545@Composable546private fun ProBody(fg: Color, vm: SettingsViewModel) {547 val ctx = LocalContext.current548 val isPro by vm.isPro.collectAsState()549 if (isPro) {550 ProActiveBody(fg, vm)551 return552 }553 val features = listOf(554 "Peach Sync" to "Encrypted · peer-to-peer",555 "Peach Codex" to "Restore from paper",556 "Passkeys" to "Zero-knowledge WebAuthn",557 "Advanced Vault Health" to "Reuse · age · exposure",558 "Canary entries" to "Detects vault compromise",559 "Duress vault" to "Decoy under coercion",560 )561 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)562 Text(563 "Everything, everywhere.",564 modifier = Modifier.padding(top = 8.dp),565 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,566 )567 Text(568 "One key unlocks every Pro feature — for the lifetime of the app.",569 modifier = Modifier.padding(top = 6.dp),570 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,571 )572 Spacer(Modifier.height(12.dp))573 features.forEachIndexed { i, (name, desc) ->574 RowLine(fg) {575 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {576 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))577 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)578 }579 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)580 }581 }582 Spacer(Modifier.height(14.dp))583 Text(584 "$39",585 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,586 )587 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)588 Spacer(Modifier.height(10.dp))589 Box(590 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))591 .background(fg.copy(alpha = 0.16f))592 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))593 .clickable {594 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }595 }596 .padding(vertical = 12.dp),597 contentAlignment = Alignment.Center,598 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }599600 Spacer(Modifier.height(16.dp))601 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)602 var key by remember { mutableStateOf("") }603 val activating by vm.activating.collectAsState()604 val activationError by vm.activationError.collectAsState()605 Box(606 Modifier.fillMaxWidth().padding(top = 6.dp)607 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))608 .padding(horizontal = 12.dp, vertical = 10.dp),609 ) {610 BasicTextField(611 value = key,612 onValueChange = { key = it.uppercase(); vm.clearActivationError() },613 singleLine = true,614 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),615 cursorBrush = SolidColor(fg),616 modifier = Modifier.fillMaxWidth(),617 decorationBox = { inner ->618 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)619 inner()620 },621 )622 }623 if (activationError != null) {624 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))625 }626 Spacer(Modifier.height(8.dp))627 Box(628 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))629 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))630 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))631 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }632 .padding(vertical = 12.dp),633 contentAlignment = Alignment.Center,634 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }635}636637@Composable638private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {639 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")640 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {641 com.peachpasswords.ui.components.PeachMark(size = 60.dp)642 }643 Text(644 "Peach Pro",645 modifier = Modifier.fillMaxWidth(),646 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,647 textAlign = androidx.compose.ui.text.style.TextAlign.Center,648 )649 Text(650 "active · yours forever",651 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),652 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,653 textAlign = androidx.compose.ui.text.style.TextAlign.Center,654 )655 Spacer(Modifier.height(12.dp))656 features.forEachIndexed { i, name ->657 RowLine(fg) {658 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {659 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))660 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)661 }662 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)663 }664 }665 Spacer(Modifier.height(12.dp))666 Box(667 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))668 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))669 .clickable { vm.deactivate() }670 .padding(vertical = 12.dp),671 contentAlignment = Alignment.Center,672 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }673}674675676677@OptIn(ExperimentalLayoutApi::class)678@Composable679private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {680 val categories = listOf("Bug report", "Feature request", "Question", "Other")681 var category by remember { mutableStateOf(categories[0]) }682 var message by remember { mutableStateOf("") }683 val state by vm.feedbackState.collectAsState()684685 686 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }687688 if (state is FeedbackState.Sent) {689 Box(690 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))691 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),692 ) {693 Column {694 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)695 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))696 }697 }698 return699 }700701 val sending = state is FeedbackState.Sending702703 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)704 SubLabel("TYPE", fg)705 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {706 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }707 }708 SubLabel("MESSAGE", fg)709 Box(710 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))711 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))712 .padding(10.dp),713 ) {714 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)715 BasicTextField(716 value = message, onValueChange = { message = it },717 enabled = !sending,718 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),719 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),720 )721 }722 Spacer(Modifier.height(8.dp))723724 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {725 val canSend = !sending && message.isNotBlank()726 Box(727 Modifier.clip(RoundedCornerShape(8.dp))728 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))729 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))730 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }731 .padding(horizontal = 12.dp, vertical = 6.dp),732 ) {733 Text(734 if (state is FeedbackState.Error) "Retry" else "Send",735 color = fg.copy(alpha = if (canSend) 1f else 0.4f),736 fontFamily = PeachType.Mono, fontSize = 11.sp,737 )738 }739 if (sending) {740 CircularProgressIndicator(741 color = LocalPeachAccent.current,742 strokeWidth = 2.dp,743 modifier = Modifier.size(16.dp),744 )745 }746 }747748 (state as? FeedbackState.Error)?.let { err ->749 Text(750 err.message,751 modifier = Modifier.padding(top = 8.dp),752 color = peachColors.Breach,753 fontFamily = PeachType.Mono,754 fontSize = 11.sp,755 lineHeight = 15.sp,756 )757 }758759 Hint("Sent anonymously · no account required", fg)760}761762763764@Composable765internal fun DuressSection(fg: Color, vm: SettingsViewModel) {766 val configured by vm.duressConfigured.collectAsState()767 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)768}769770@Composable771private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {772 Text(773 "Duress password is active",774 color = peachColors.HealthOk,775 fontFamily = PeachType.Mono,776 fontSize = 11.sp,777 modifier = Modifier.padding(top = 6.dp),778 )779 Spacer(Modifier.height(8.dp))780 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }781 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)782}783784@Composable785private fun DuressForm(fg: Color, vm: SettingsViewModel) {786 var enabled by remember { mutableStateOf(false) }787 var password by remember { mutableStateOf("") }788 var confirm by remember { mutableStateOf("") }789 var action by remember { mutableStateOf(DuressAction.DECOY) }790 var error by remember { mutableStateOf<String?>(null) }791 val scope = rememberCoroutineScope()792793 RowLine(fg) {794 Name("Duress password", fg)795 Toggle(enabled, fg) { on ->796 enabled = on797 if (!on) { password = ""; confirm = ""; error = null }798 }799 }800 Text(801 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",802 color = fg.copy(alpha = 0.7f),803 fontFamily = PeachType.Mono,804 fontSize = 10.sp,805 modifier = Modifier.padding(top = 6.dp),806 )807808 AnimatedVisibility(enabled) {809 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {810 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }811 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }812813 Text(814 "When your duress password is entered at the lock screen, Peach will:",815 color = fg.copy(alpha = 0.7f),816 fontFamily = PeachType.Mono,817 fontSize = 10.sp,818 modifier = Modifier.padding(top = 4.dp),819 )820821 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }822 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }823824 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }825826 val canSave = password.isNotBlank() && confirm.isNotBlank()827 DuressSaveButton("Save duress password", fg, canSave) {828 error = when {829 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"830 password != confirm -> "Passwords do not match"831 else -> null832 }833 if (error == null) scope.launch {834 if (vm.saveDuress(password, action)) {835 password = ""; confirm = ""; enabled = false836 } else {837 error = "Couldn't save — the duress passphrase must differ from your real password"838 }839 }840 }841 }842 }843}844845@Composable846private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {847 val accent = LocalPeachAccent.current848 Row(849 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),850 verticalAlignment = Alignment.CenterVertically,851 horizontalArrangement = Arrangement.spacedBy(10.dp),852 ) {853 Box(854 Modifier.size(16.dp).clip(CircleShape)855 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),856 contentAlignment = Alignment.Center,857 ) {858 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))859 }860 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)861 }862}863864@Composable865private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {866 val accent = LocalPeachAccent.current867 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)868 Box(869 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))870 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))871 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))872 .let { if (enabled) it.clickable(onClick = onClick) else it }873 .padding(vertical = 11.dp),874 contentAlignment = Alignment.Center,875 ) {876 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)877 }878}879880@Composable881private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {882 Box(883 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))884 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))885 .clickable(onClick = onClick)886 .padding(vertical = 11.dp),887 contentAlignment = Alignment.Center,888 ) {889 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)890 }891}