package com.peachpasswords.ui.settings import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.peachpasswords.feedback.FeedbackState import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.vault.DuressAction import com.peachpasswords.ui.theme.peachColors import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.compose.material3.Text import com.peachpasswords.ui.PeachNav import com.peachpasswords.ui.sync.SyncViewModel import com.peachpasswords.ui.theme.ACCENT_COLORS import com.peachpasswords.ui.theme.PeachType import androidx.hilt.navigation.compose.hiltViewModel import com.peachpasswords.vault.VaultSettings import kotlinx.coroutines.launch private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz" /** * Body content for the active stack card. Everything is tinted with the card's * own foreground [fg] (the browser's `currentColor` convention), so each card's * rows inherit its palette. Rows whose backend doesn't exist yet (duress, * canary, license activation) render their browser visual but defer the action * to a later phase — noted inline where that's the case. */ @Composable fun CardBody( id: CardId, fg: Color, vm: SettingsViewModel, settings: VaultSettings, entriesCount: Int, onNavigate: (PeachNav) -> Unit, ) { when (id) { CardId.Appearance -> AppearanceBody(fg, vm, settings) CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate) CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate) CardId.Sync -> SyncBody(fg, onNavigate) CardId.Shortcuts -> ShortcutsBody(fg) CardId.Pro -> ProBody(fg, vm) CardId.Feedback -> FeedbackBody(fg, vm) } } /* ───────────────────────── shared row vocabulary ───────────────────────── */ @Composable private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) { Text( text, modifier = Modifier.padding(top = top, bottom = 4.dp), color = fg.copy(alpha = 0.58f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em, ) } @Composable private fun RowLine(fg: Color, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { Row( Modifier.fillMaxWidth().then(modifier).padding(vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, content = content, ) Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f))) } @Composable private fun RowScope.Name(text: String, fg: Color) { Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f)) } @Composable private fun ValueText(text: String, fg: Color) { Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp) } @Composable private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) { Box( Modifier.clip(RoundedCornerShape(8.dp)) .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp)) .background(fg.copy(alpha = 0.08f)) .clickable(onClick = onClick) .padding(horizontal = 12.dp, vertical = 6.dp), ) { Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp) } } @Composable private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) { val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob") Box( Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp)) .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f)) .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp)) .clickable { onToggle(!on) }, ) { Box( Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp) .padding(start = knob - 2.dp) .size(14.dp).clip(CircleShape) .background(if (on) fg else fg.copy(alpha = 0.58f)), ) } } @Composable private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) { Box( Modifier.clip(RoundedCornerShape(999.dp)) .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp)) .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent) .clickable(onClick = onClick) .padding(horizontal = 12.dp, vertical = 5.dp), ) { Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) } } @Composable private fun ProBadge(fg: Color) { Box( Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp)) .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp), ) { Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp) } } @Composable private fun Hint(text: String, fg: Color) { Text( text, modifier = Modifier.padding(top = 6.dp), color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.5.sp, lineHeight = 16.sp, ) } /* ────────────────────────────── Appearance ─────────────────────────────── */ @OptIn(ExperimentalLayoutApi::class) @Composable private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) { SubLabel("THEME", fg, top = 0.dp) RowLine(fg) { Name("Theme", fg) // Cycle dark → light → system → dark. The button shows the current mode; // tapping advances to the next. val (label, next) = when (s.theme) { "light" -> "Light" to "system" "system" -> "System" to "dark" else -> "Dark" to "light" } SubtleButton(label, fg) { vm.setTheme(next) } } SubLabel("ACCENT", fg) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { ACCENT_COLORS.forEach { (name, color) -> Box( Modifier.size(22.dp).clip(CircleShape).background(color) .border( if (s.accentColor == name) 2.dp else 1.dp, if (s.accentColor == name) fg else fg.copy(alpha = 0.2f), CircleShape, ) .clickable { vm.setAccent(name) }, ) } } SubLabel("TEXT SIZE", fg) RowLine(fg) { Name("Size", fg) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Pill("Default", s.textSize == "medium", fg) { vm.setTextSize("medium") } Pill("Large", s.textSize == "large", fg) { vm.setTextSize("large") } } } SubLabel("MOTION", fg) RowLine(fg) { Name("Reduce motion", fg) SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) } } } /* ────────────────────────────── Protection ─────────────────────────────── */ @Composable private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) { val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never") val current = s.autoLockMinutes ?: 0 SubLabel("LOCKING", fg, top = 0.dp) RowLine(fg) { Name("Auto-lock after", fg) val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0) SubtleButton(lockOptions[idx].second, fg) { val next = lockOptions[(idx + 1) % lockOptions.size].first vm.setAutoLockMinutes(if (next == 0) 0 else next) } } SubLabel("BIOMETRICS", fg) val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity RowLine(fg) { Name("Unlock with biometrics", fg) Toggle(s.biometricsEnabled, fg) { on -> if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics() } } val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current) if (bioError != null) { Text( bioError, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp), ) } SubLabel("RECOVERY", fg) RowLine(fg) { Name("View recovery words", fg) SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) } } SubLabel("BREACH PROTECTION", fg) RowLine(fg) { Name("Breach monitoring", fg) Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) } } SubLabel("MASTER PASSWORD", fg) ChangePasswordRow(fg, vm) SubLabel("DURESS PASSWORD", fg) val duressIsPro by vm.isPro.collectAsState() if (!duressIsPro) { RowLine(fg) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Duress password", fg); ProBadge(fg) } ValueText("off", fg) } Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg) } else { DuressSection(fg, vm) } } @Composable private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) { var open by remember { mutableStateOf(false) } var current by remember { mutableStateOf("") } var next by remember { mutableStateOf("") } var confirm by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } var done by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() RowLine(fg) { Name("Master password", fg) SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false } } AnimatedVisibility(open) { Column(Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { PwField("current password", current, fg) { current = it } PwField("new password (min 12 chars)", next, fg) { next = it } PwField("confirm new password", confirm, fg) { confirm = it } error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) } if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) SubtleButton("save new password", fg) { error = when { current.isBlank() -> "enter your current password" next.length < 12 -> "new password must be at least 12 characters" next != confirm -> "new passwords do not match" next == current -> "new password must differ from current" else -> null } if (error == null) scope.launch { if (vm.changePassword(current, next)) { current = ""; next = ""; confirm = ""; open = false; done = true } else error = "incorrect current password" } } } } } @Composable private fun PwField(placeholder: String, value: String, fg: Color, onChange: (String) -> Unit) { Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) .background(Color(0x2E000000)) .border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp)) .padding(horizontal = 10.dp, vertical = 10.dp), ) { if (value.isEmpty()) { Text(placeholder, color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Mono, fontSize = 12.sp) } BasicTextField( value = value, onValueChange = onChange, singleLine = true, textStyle = TextStyle(color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp), cursorBrush = SolidColor(fg), visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) } } /* ──────────────────────────────── Vault ───────────────────────────────── */ @Composable private fun VaultBody( fg: Color, vm: SettingsViewModel, s: VaultSettings, entriesCount: Int, onNavigate: (PeachNav) -> Unit, ) { val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never") val current = s.trashRetentionDays ?: 0 val isPro by vm.isPro.collectAsState() val dedupCount by vm.dedupCount.collectAsState() val dedupRunning by vm.dedupRunning.collectAsState() val dedupDone by vm.dedupDone.collectAsState() SubLabel("ENTRIES", fg, top = 0.dp) RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) } RowLine(fg) { Name("Duplicates", fg) val done = dedupDone if (done != null) { ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk) } else { val count = dedupCount val label = when { dedupRunning -> "scanning..." count == null -> "scan" count == 0 -> "none found" else -> "$count found" } SubtleButton(label, fg) { if (count == null || count == 0) vm.scanDuplicates() } } } if ((dedupCount ?: 0) > 0 && dedupDone == null) { Column( Modifier.fillMaxWidth() .padding(top = 2.dp, bottom = 8.dp) .clip(RoundedCornerShape(8.dp)) .background(fg.copy(alpha = 0.08f)) .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp)) .padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { val count = dedupCount ?: 0 Text( "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.", color = fg.copy(alpha = 0.82f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() } SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() } } } } SubLabel("MAINTENANCE", fg) RowLine(fg) { Name("Trash auto-delete", fg) val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0) SubtleButton(trashOptions[idx].second, fg) { vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first) } } RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } } RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } } SubLabel("DATA", fg) RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } } RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } } RowLine(fg) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) } SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) } } RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } } var advancedOpen by remember { mutableStateOf(false) } SubLabel("ADVANCED", fg) RowLine(fg) { Name("Advanced", fg) SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen } } if (advancedOpen) { RowLine(fg) { Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy) SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) } } RowLine(fg) { Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach) SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) } } } } /* ──────────────────────────────── Sync ────────────────────────────────── */ @Composable private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) { val syncVm: SyncViewModel = hiltViewModel() val pairedDevices by syncVm.pairedDevices.collectAsState() SubLabel("PAIRING", fg, top = 0.dp) RowLine(fg) { Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg) } Text( "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } } // The whole row is the tap target — no separate "open" button (browser // sync-hub parity: every settings row is tappable, the chevron signals it). RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) { Name("Sync hub", fg) Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp) } RowLine(fg) { Column(Modifier.weight(1f)) { Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Add a phone, another browser, or your desktop sync helper.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) } } SubLabel("PAIRED DEVICES", fg) if (pairedDevices.isEmpty()) { Text( "No paired devices yet.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp, modifier = Modifier.padding(top = 2.dp), ) } else { pairedDevices.forEach { d -> RowLine(fg) { Name(d.deviceName ?: d.deviceId, fg) ValueText(if (d.lastSyncedAt != null) "synced" else "never", fg) } } RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { syncVm.syncNow() } } } Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg) } /* ────────────────────────────── Shortcuts ──────────────────────────────── */ @Composable private fun ShortcutsBody(fg: Color) { val bindings = listOf( "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵", "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/", ) SubLabel("KEYBINDINGS", fg, top = 0.dp) Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg) Spacer(Modifier.height(8.dp)) bindings.forEach { (label, key) -> RowLine(fg) { Name(label, fg) Box( Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f)) .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp)) .padding(horizontal = 7.dp, vertical = 2.dp), ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) } } } } /* ──────────────────────────────── Pro ─────────────────────────────────── */ @Composable private fun ProBody(fg: Color, vm: SettingsViewModel) { val ctx = LocalContext.current val isPro by vm.isPro.collectAsState() if (isPro) { ProActiveBody(fg, vm) return } val features = listOf( "Peach Sync" to "Encrypted · peer-to-peer", "Peach Codex" to "Restore from paper", "Passkeys" to "Zero-knowledge WebAuthn", "Advanced Vault Health" to "Reuse · age · exposure", "Canary entries" to "Detects vault compromise", "Duress vault" to "Decoy under coercion", ) Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em) Text( "Everything, everywhere.", modifier = Modifier.padding(top = 8.dp), color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp, ) Text( "One key unlocks every Pro feature — for the lifetime of the app.", modifier = Modifier.padding(top = 6.dp), color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp, ) Spacer(Modifier.height(12.dp)) features.forEachIndexed { i, (name, desc) -> RowLine(fg) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp)) Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) } Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp) } } Spacer(Modifier.height(14.dp)) Text( "$39", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp, ) Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em) Spacer(Modifier.height(10.dp)) Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)) .background(fg.copy(alpha = 0.16f)) .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) .clickable { runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) } } .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) } Spacer(Modifier.height(16.dp)) Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em) var key by remember { mutableStateOf("") } val activating by vm.activating.collectAsState() val activationError by vm.activationError.collectAsState() Box( Modifier.fillMaxWidth().padding(top = 6.dp) .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) .padding(horizontal = 12.dp, vertical = 10.dp), ) { BasicTextField( value = key, onValueChange = { key = it.uppercase(); vm.clearActivationError() }, singleLine = true, textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg), cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(), decorationBox = { inner -> if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em) inner() }, ) } if (activationError != null) { Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp)) } Spacer(Modifier.height(8.dp)) Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)) .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f)) .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) } .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) } } @Composable private fun ProActiveBody(fg: Color, vm: SettingsViewModel) { val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault") Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { com.peachpasswords.ui.components.PeachMark(size = 60.dp) } Text( "Peach Pro", modifier = Modifier.fillMaxWidth(), color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) Text( "active · yours forever", modifier = Modifier.fillMaxWidth().padding(top = 4.dp), color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) Spacer(Modifier.height(12.dp)) features.forEachIndexed { i, name -> RowLine(fg) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp)) Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) } Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp) } } Spacer(Modifier.height(12.dp)) Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)) .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) .clickable { vm.deactivate() } .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) } } /* ────────────────────────────── Feedback ───────────────────────────────── */ @OptIn(ExperimentalLayoutApi::class) @Composable private fun FeedbackBody(fg: Color, vm: SettingsViewModel) { val categories = listOf("Bug report", "Feature request", "Question", "Other") var category by remember { mutableStateOf(categories[0]) } var message by remember { mutableStateOf("") } val state by vm.feedbackState.collectAsState() // Reset transient state when the card leaves composition so reopening starts fresh. DisposableEffect(Unit) { onDispose { vm.resetFeedback() } } if (state is FeedbackState.Sent) { Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f)) .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp), ) { Column { Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp) 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)) } } return } val sending = state is FeedbackState.Sending 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) SubLabel("TYPE", fg) FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } } } SubLabel("MESSAGE", fg) Box( Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp)) .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp)) .padding(10.dp), ) { if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp) BasicTextField( value = message, onValueChange = { message = it }, enabled = !sending, textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp), cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(), ) } Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { val canSend = !sending && message.isNotBlank() Box( Modifier.clip(RoundedCornerShape(8.dp)) .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp)) .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f)) .clickable(enabled = canSend) { vm.sendFeedback(category, message) } .padding(horizontal = 12.dp, vertical = 6.dp), ) { Text( if (state is FeedbackState.Error) "Retry" else "Send", color = fg.copy(alpha = if (canSend) 1f else 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp, ) } if (sending) { CircularProgressIndicator( color = LocalPeachAccent.current, strokeWidth = 2.dp, modifier = Modifier.size(16.dp), ) } } (state as? FeedbackState.Error)?.let { err -> Text( err.message, modifier = Modifier.padding(top = 8.dp), color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp, lineHeight = 15.sp, ) } Hint("Sent anonymously · no account required", fg) } /* ────────────────────────────── D13 Duress ─────────────────────────────── */ @Composable internal fun DuressSection(fg: Color, vm: SettingsViewModel) { val configured by vm.duressConfigured.collectAsState() if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm) } @Composable private fun DuressActiveState(fg: Color, vm: SettingsViewModel) { Text( "Duress password is active", color = peachColors.HealthOk, fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp), ) Spacer(Modifier.height(8.dp)) DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() } Hint("Entering your duress password instead of your real one triggers the action you chose.", fg) } @Composable private fun DuressForm(fg: Color, vm: SettingsViewModel) { var enabled by remember { mutableStateOf(false) } var password by remember { mutableStateOf("") } var confirm by remember { mutableStateOf("") } var action by remember { mutableStateOf(DuressAction.DECOY) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() RowLine(fg) { Name("Duress password", fg) Toggle(enabled, fg) { on -> enabled = on if (!on) { password = ""; confirm = ""; error = null } } } Text( "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp), ) AnimatedVisibility(enabled) { Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null } PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null } Text( "When your duress password is entered at the lock screen, Peach will:", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 4.dp), ) DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY } DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY } error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) } val canSave = password.isNotBlank() && confirm.isNotBlank() DuressSaveButton("Save duress password", fg, canSave) { error = when { password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password" password != confirm -> "Passwords do not match" else -> null } if (error == null) scope.launch { if (vm.saveDuress(password, action)) { password = ""; confirm = ""; enabled = false } else { error = "Couldn't save — the duress passphrase must differ from your real password" } } } } } } @Composable private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) { val accent = LocalPeachAccent.current Row( Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Box( Modifier.size(16.dp).clip(CircleShape) .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape), contentAlignment = Alignment.Center, ) { if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent)) } Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp) } } @Composable private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) { val accent = LocalPeachAccent.current val tint = if (enabled) accent else fg.copy(alpha = 0.3f) Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp)) .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f)) .let { if (enabled) it.clickable(onClick = onClick) else it } .padding(vertical = 11.dp), contentAlignment = Alignment.Center, ) { Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp) } } @Composable private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) { Box( Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp)) .clickable(onClick = onClick) .padding(vertical = 11.dp), contentAlignment = Alignment.Center, ) { Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp) } }