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.heightIn 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.shared.PeachClipboard import com.peachpasswords.sync.SyncMode import com.peachpasswords.sync.canShowBackgroundSyncNotification import com.peachpasswords.sync.describeSyncOutcome import com.peachpasswords.sync.privateRelayDraftOf import com.peachpasswords.sync.syncIndicator import com.peachpasswords.sync.syncIndicatorLabel import com.peachpasswords.ui.components.SyncIndicatorDot 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.BuildConfig import com.peachpasswords.autofill.AutofillEnablement import com.peachpasswords.ui.PeachNav import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachField import com.peachpasswords.ui.pairing.BackgroundSyncDialog import com.peachpasswords.ui.sync.SyncViewModel import com.peachpasswords.ui.theme.ACCENT_COLORS import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.updates.ChangelogPhase import com.peachpasswords.ui.updates.UpdatePhase import com.peachpasswords.ui.updates.UpdateViewModel import com.peachpasswords.updates.compareVersions import androidx.hilt.navigation.compose.hiltViewModel import com.peachpasswords.vault.VaultSettings import com.peachpasswords.vault.VaultSnapshot import kotlinx.coroutines.launch import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Date import java.util.Locale 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.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, ) } /** * Collapsible group inside a card body: the uppercase heading row toggles its * rows, so busy cards can fold rarely-used sections away. Groups holding * dangerous or rarely-used actions start collapsed. */ @Composable private fun SettingsGroup( label: String, fg: Color, initiallyOpen: Boolean = true, top: Dp = 14.dp, content: @Composable () -> Unit, ) { var open by remember { mutableStateOf(initiallyOpen) } Row( Modifier.fillMaxWidth() .clip(RoundedCornerShape(6.dp)) .clickable { open = !open } .padding(top = top, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( label, modifier = Modifier.weight(1f), color = fg.copy(alpha = 0.58f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em, ) Text( if (open) "–" else "+", color = fg.copy(alpha = 0.58f), fontFamily = PeachType.Mono, fontSize = 10.sp, ) } // AnimatedVisibility stacks sibling children like a Box, so multi-row group // content must sit in an explicit Column or the rows overlap. AnimatedVisibility(visible = open) { Column(Modifier.fillMaxWidth()) { content() } } } @Composable private fun RowLine( fg: Color, modifier: Modifier = Modifier, verticalPadding: Dp = 9.dp, content: @Composable RowScope.() -> Unit, ) { Row( Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding), 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, modifier: Modifier = Modifier) { Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier) } @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, maxLines = 1) } } @Composable private fun SyncDisclosure( title: String, summary: String, expanded: Boolean, fg: Color, onToggle: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) .clip(RoundedCornerShape(8.dp)) .clickable(onClick = onToggle) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { Text( title.uppercase(), color = fg.copy(alpha = 0.58f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em, ) Text( summary, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 3.dp), ) } Text( if (expanded) "hide" else "show", color = fg, fontFamily = PeachType.Mono, fontSize = 10.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("ACCENT", fg, top = 0.dp) 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("Text size", fg) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") } Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") } Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") } } } SubLabel("ICONS", fg) RowLine(fg) { Name("Site icons", fg) SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) { vm.setFaviconsEnabled(!s.faviconsEnabled) } } 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("Allow screenshots", fg) Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) } } 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("AUTOFILL", fg) run { // Read live rather than cached: the user leaves for system settings and // comes back, so a remembered value would show the old answer. val ctx = LocalContext.current val enabled = AutofillEnablement.isEnabled(ctx) RowLine(fg) { Column(Modifier.weight(1f)) { Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( if (enabled) { "Peach is your autofill service." } else { "Android decides which app fills passwords. Turn Peach on in system settings." }, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } if (!enabled) { SubtleButton("enable", fg) { // Straight to the autofill picker with Peach preselected; // some OEM builds ship no handler, so fall back rather than // dead-ending on a button that does nothing. runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) } .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } } } } else { ValueText("on", fg) } } } RowLine(fg) { Column(Modifier.weight(1f)) { Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) } } SubLabel("PASSKEYS", fg) RowLine(fg) { Column(Modifier.weight(1f)) { Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Peach can handle passkey sign-in and creation through Android Credential Manager.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) } } RowLine(fg) { Name("Status", fg) ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg) } SubLabel("RECOVERY", fg) RowLine(fg) { Column(Modifier.weight(1f)) { Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Keep the paper copy current before rotating sync identity or replacing devices.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } SubtleButton("view", fg) { if (bioActivity != null) { com.peachpasswords.ui.lock.Biometrics.authenticate( activity = bioActivity, title = "View recovery words", subtitle = "Confirm your identity to view your recovery words", onSuccess = { onNavigate(PeachNav.RecoveryView) }, ) } else onNavigate(PeachNav.RecoveryView) } } SubLabel("BREACH PROTECTION", fg) val checkingBreaches by vm.checkingBreaches.collectAsState() val breachResult by vm.breachResult.collectAsState() RowLine(fg) { Name("Breach monitoring", fg) Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) } } RowLine(fg) { Name("Check all passwords", fg) SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() } } breachResult?.let { result -> RowLine(fg) { Name("Last check", fg) ValueText(result, fg) } } 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 = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) { 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, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) { PeachField( label = placeholder, value = value, onValueChange = onChange, keyboardType = androidx.compose.ui.text.input.KeyboardType.Password, visualTransformation = PasswordVisualTransformation(), ) } /* ──────────────────────────────── Vault ───────────────────────────────── */ @Composable private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) { val vm: UpdateViewModel = hiltViewModel() val available by vm.available.collectAsState() val state by vm.state.collectAsState() val changelog by vm.changelog.collectAsState() var changelogOpen by remember { mutableStateOf(false) } RowLine(fg) { Name("Version", fg) ValueText(BuildConfig.VERSION_NAME, fg) } RowLine(fg) { Column(Modifier.weight(1f)) { Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( when { available != null -> "v${available?.version} is available" state.phase == UpdatePhase.Checking -> "Checking…" state.phase == UpdatePhase.UpToDate -> "You're on the latest version" state.phase == UpdatePhase.Failed -> "Couldn't reach the update server" else -> "Peach checks once a day and tells you when there's a new version." }, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { SubtleButton("changelog", fg) { changelogOpen = !changelogOpen if (changelogOpen) vm.loadChangelog() } if (available != null) { SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) } } else { SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) { vm.check() } } } } if (changelogOpen) { when (changelog.phase) { ChangelogPhase.Idle -> {} ChangelogPhase.Loading -> Text( "Loading changelog…", color = fg.copy(alpha = 0.68f), fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(vertical = 8.dp), ) ChangelogPhase.Failed -> RowLine(fg) { Name("Couldn't reach the update server", fg) SubtleButton("retry", fg) { vm.loadChangelog() } } ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) { changelog.entries.forEachIndexed { index, entry -> Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp) if (compareVersions(entry.version, vm.currentVersion) == 0) { Text(" current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp) } } Text( entry.notes ?: "No notes for this release.", color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 3.dp), ) } if (index < changelog.entries.lastIndex) { Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f))) } } } } } } @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() val history by vm.vaultHistory.collectAsState() val restoringSnapshotId by vm.restoringSnapshotId.collectAsState() SettingsGroup("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() } } } } } SettingsGroup("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) } } } SettingsGroup("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) } } } SettingsGroup("UPDATES", fg) { UpdatesRows(fg, onNavigate) } SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) { var historyShowAll by remember { mutableStateOf(false) } if (history.isEmpty()) { Text( "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.", color = fg.copy(alpha = 0.68f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(bottom = 6.dp), ) } else { Text( "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.", color = fg.copy(alpha = 0.68f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(bottom = 4.dp), ) history.take(if (historyShowAll) history.size else 3).forEach { snap -> RowLine(fg) { Column(Modifier.weight(1f)) { Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp) Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp) } SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) { vm.restoreVaultSnapshot(snap.id) } } } if (history.size > 3) { RowLine(fg) { Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg) SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll } } } Text( "${history.size} of 5 snapshots", color = fg.copy(alpha = 0.58f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 2.dp, bottom = 6.dp), ) } } SettingsGroup("ADVANCED", fg, initiallyOpen = false) { 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) } } } } private fun snapshotSubLabel(snapshot: VaultSnapshot): String { val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" } return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ") } private fun snapshotReasonLabel(reason: String): String = when (reason) { "local_save" -> "saved" "sync_received" -> "synced" "import" -> "imported" "restore" -> "restored" else -> reason } private fun snapshotTimeLabel(savedAt: Long): String { val diff = System.currentTimeMillis() - savedAt if (diff < 60_000) return "Just now" if (diff < 3_600_000) return "${diff / 60_000}m ago" val date = Date(savedAt) val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date()) val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date) val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date) if (today == thenDay) return "Today $time" val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000)) if (yesterday == thenDay) return "Yesterday $time" return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date) } /* ──────────────────────────────── Sync ────────────────────────────────── */ @Composable private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) { val context = LocalContext.current val syncVm: SyncViewModel = hiltViewModel() val pairedDevices by syncVm.pairedDevices.collectAsState() val pendingVouched by syncVm.pendingVouched.collectAsState() val syncStatus by syncVm.status.collectAsState() val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState() val syncMode by syncVm.syncMode.collectAsState() val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState() var notificationsAvailable by remember { mutableStateOf(canShowBackgroundSyncNotification(context)) } var showBackgroundSyncPrompt by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() var confirmUnpair by remember { mutableStateOf(null) } var renamingDeviceId by remember { mutableStateOf(null) } var renameDraft by remember { mutableStateOf("") } var rotateConfirmOpen by remember { mutableStateOf(false) } var rotating by remember { mutableStateOf(false) } var rotateError by remember { mutableStateOf(null) } var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(privateRelayDraftOf(configuredRelayUrl)) } var relayUrlStatus by remember { mutableStateOf(null) } var showConnection by remember { mutableStateOf(false) } var showRecovery by remember { mutableStateOf(false) } SubLabel("ADD A DEVICE", fg, top = 0.dp) RowLine(fg) { Column(Modifier.weight(1f)) { Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } // "Scan" opens the camera-driven AdoptVault flow: the phone is the // receiver. "Show a code" opens CodePair: the phone is the source. // Both are valid pairing entry points; without scan, a phone that wants // to join an existing browser-only vault has no path. SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) } Spacer(Modifier.width(PeachSpacing.xs)) SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) } } SubLabel("SYNC STATUS", fg) val indicator = syncIndicator(syncStatus, pairedDevices.size) RowLine(fg) { SyncIndicatorDot( indicator = indicator, label = syncIndicatorLabel(indicator, syncStatus), modifier = Modifier.padding(end = PeachSpacing.sm), size = 8.dp, ) Text( describeSyncOutcome(syncStatus), color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.weight(1f), ) SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) { if (!syncStatus.syncing) syncVm.syncNow() } } SubLabel("PAIRED DEVICES", fg) if (pairedDevices.isEmpty()) { PeachEmptyOrEndState( message = "No paired devices yet.", detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.", modifier = Modifier.padding(top = PeachSpacing.xs), ) } else { pairedDevices.forEach { d -> RowLine(fg) { Column(Modifier.weight(1f)) { if (renamingDeviceId == d.deviceId) { Box( Modifier.fillMaxWidth() .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) .padding(horizontal = 10.dp, vertical = 8.dp), ) { BasicTextField( value = renameDraft, onValueChange = { renameDraft = it.take(64) }, singleLine = true, textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg), cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(), ) } } else { Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) } Text( "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } if (confirmUnpair != d.deviceId) { if (renamingDeviceId == d.deviceId) { SubtleButton("cancel", fg) { renamingDeviceId = null } Spacer(Modifier.width(PeachSpacing.xs)) SubtleButton("save", fg) { if (renameDraft.isNotBlank()) { syncVm.renameDevice(d.deviceId, renameDraft) renamingDeviceId = null } } } else { SubtleButton("rename", fg) { renameDraft = d.localAlias ?: d.deviceName ?: "" renamingDeviceId = d.deviceId } } if (renamingDeviceId != d.deviceId) { Spacer(Modifier.width(PeachSpacing.xs)) SubtleButton("remove", fg) { confirmUnpair = d.deviceId } } } } // HOLE 4: revocation stops this device receiving future changes. It // does NOT secure the vault against it — that device may already // hold the recovery words, which are master-password-equivalent and // restore the whole vault anywhere, forever, with no sync involved. // Say so here rather than leaving the user falsely reassured. if (confirmUnpair == d.deviceId) { Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) { Text( "Stops this device receiving future changes. It doesn't lock it " + "out of what it already has - if it's lost or stolen, also " + "change your master password and generate new recovery words.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, ) Row( modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), horizontalArrangement = Arrangement.End, ) { SubtleButton("cancel", fg) { confirmUnpair = null } Spacer(Modifier.width(PeachSpacing.sm)) SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null } } } } } } SyncDisclosure( title = "Connection", summary = "Background sync, relay, and privacy choices", expanded = showConnection, fg = fg, onToggle = { showConnection = !showConnection }, ) if (showConnection) { RowLine(fg) { Column(Modifier.weight(1f)) { Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( when { backgroundSyncEnabled && !notificationsAvailable -> "Action needed — Android notifications are off, so background sync is not visible." backgroundSyncEnabled -> "On — keeps this phone reachable for paired devices." else -> "Off — Peach syncs only while you are using the app." }, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } SubtleButton( if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change", if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg, ) { showBackgroundSyncPrompt = true } } SyncModeRow( title = "Public relay", detail = "Works anywhere with nothing to set up. The relay cannot read your vault.", selected = syncMode == SyncMode.PUBLIC, fg = fg, ) { syncVm.setSyncMode(SyncMode.PUBLIC) } SyncModeRow( title = "Private relay", detail = "Use your own peach-relay on your own domain.", selected = syncMode == SyncMode.PRIVATE, fg = fg, ) { syncVm.setSyncMode(SyncMode.PRIVATE) } if (syncMode == SyncMode.PRIVATE) { Text( "RELAY URL", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em, modifier = Modifier.padding(top = 8.dp), ) 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 = relayUrlDraft, onValueChange = { relayUrlDraft = it; relayUrlStatus = null }, singleLine = true, textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg), cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(), ) } val needsRelayUrl = configuredRelayUrl.isEmpty() RowLine(fg) { val message = relayUrlStatus ?: if (needsRelayUrl) { "Sync is paused until you enter your relay URL." } else { "Only secure wss:// relays are accepted." } val warn = relayUrlStatus?.startsWith("Use") == true || (relayUrlStatus == null && needsRelayUrl) ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f)) Spacer(Modifier.width(PeachSpacing.xs)) SubtleButton("save", fg) { relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) { relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value) "Saved" } else { "Use a valid wss:// relay URL" } } } } SyncModeRow( title = "No sync", detail = "Keep this device separate and move backups by hand.", selected = syncMode == SyncMode.OFF, fg = fg, ) { syncVm.setSyncMode(SyncMode.OFF) } Hint("Pairing authorizes a device, not a cloud account.", fg) } if (pendingVouched.isNotEmpty()) { SubLabel("PENDING DEVICES", fg) pendingVouched.forEach { dev -> RowLine(fg) { Column(Modifier.weight(1f)) { Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Was added by another device. Allow it to sync?", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) } SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) } } } } } SyncDisclosure( title = "Recovery & security", summary = "For a lost or stolen device", expanded = showRecovery, fg = fg, onToggle = { showRecovery = !showRecovery }, ) if (showRecovery) { RowLine(fg) { Column(Modifier.weight(1f)) { Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Scan only when this populated vault previously trusted the source device.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) } } if (rotateConfirmOpen) { Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach) rotateError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp)) } Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false } SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) { if (rotating) return@SubtleButton rotating = true rotateError = null scope.launch { val ok = syncVm.rotateSyncIdentity() rotating = false if (ok) { rotateConfirmOpen = false onNavigate(PeachNav.RecoverySetup) } else { rotateError = "Rotation failed. Unlock your real vault and try again." } } } } } else { RowLine(fg) { Column(Modifier.weight(1f)) { Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Disconnects every device and replaces the keys used for sync.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true } } } } if (showBackgroundSyncPrompt) { BackgroundSyncDialog { enabled -> showBackgroundSyncPrompt = false notificationsAvailable = canShowBackgroundSyncNotification(context) syncVm.setBackgroundSyncEnabled(enabled) } } } private fun deviceTypeLabel(type: String?): String = when (type) { "android" -> "Android" "phone" -> "Phone" "tablet" -> "Tablet" "browser" -> "Browser" else -> "Device" } private fun syncTimeLabel(lastSyncedAt: Long?): String { if (lastSyncedAt == null) return "never synced" val diff = System.currentTimeMillis() - lastSyncedAt if (diff < 60_000) return "just now" if (diff < 3_600_000) return "${diff / 60_000}m ago" if (diff < 86_400_000) return "${diff / 3_600_000}h ago" return "${diff / 86_400_000}d ago" } private fun isSyncStale(lastSyncedAt: Long?): Boolean = lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L /* ──────────────────────────────── 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 "End-to-end encrypted", "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(10.dp)) features.forEachIndexed { i, (name, desc) -> // Compact rows (6dp vs. the shared 9dp default) — this card is the // tallest in the deck (6 features + price + CTA) and used to leave // its button with no breathing room before the fanned tab stacked // right beneath it in preview mode. RowLine(fg, verticalPadding = 6.dp) { 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(12.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) } // Trailing breathing room so the button clears the fanned tab stacked // beneath this card in preview mode, where the scrollable viewport clips // right around here (the license-key section below is already out of // view at that height). Spacer(Modifier.height(44.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, // NOT .uppercase() — base58 (the license payload encoding) is // case-sensitive; forcing the whole string to uppercase silently // corrupted every real key before it ever reached signature // verification, so activation always failed with a real key. onValueChange = { key = it; 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 ctx = LocalContext.current val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault") val licenseKey by vm.licenseKey.collectAsState() val issuedAt by vm.licenseIssuedAt.collectAsState() var showKey by remember { mutableStateOf(false) } var copied by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() 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( formatLicenseActiveSince(issuedAt), 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, ) licenseKey?.let { key -> Spacer(Modifier.height(12.dp)) Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em) RowLine(fg) { Text( if (showKey) key else "************************", color = fg.copy(alpha = if (showKey) 0.82f else 0.58f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.weight(1f), ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { SubtleButton(if (copied) "copied" else "copy", fg) { PeachClipboard.copy(ctx, "peach license", key) copied = true scope.launch { delay(1_500) copied = false } } SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey } } } } 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) } } private fun formatLicenseActiveSince(issuedAt: Long?): String { if (issuedAt == null) return "Active" val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt)) return "Active since $monthYear" } /* ────────────────────────────── 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.take(MAX_FEEDBACK_MESSAGE_LENGTH) }, enabled = !sending, textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp), cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(), ) } Text( "Version ${BuildConfig.VERSION_NAME} · will be included automatically", modifier = Modifier.padding(top = 6.dp), color = fg.copy(alpha = 0.52f), fontFamily = PeachType.Mono, fontSize = 10.sp, lineHeight = 14.sp, ) Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { val canSend = !sending && sanitizeFeedbackMessage(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( feedbackSubmitLabel(state), 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) } } /** One choice in the three-way sync selector. */ @Composable private fun SyncModeRow( title: String, detail: String, selected: Boolean, fg: Color, onSelect: () -> Unit, ) { RowLine(fg, Modifier.clickable(onClick = onSelect)) { Column(Modifier.weight(1f)) { Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( detail, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } Text( if (selected) "●" else "○", color = if (selected) fg else fg.copy(alpha = 0.45f), fontFamily = PeachType.Mono, fontSize = 14.sp, modifier = Modifier.padding(start = 10.dp), ) } }