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.shared.PeachClipboard import com.peachpasswords.sync.SyncMode import com.peachpasswords.sync.SyncStatus 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.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.UpdatePhase import com.peachpasswords.ui.updates.UpdateViewModel 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 -> { // The closest thing Peach has to an About surface, and where the brief // put the update entry point: version first, then the feedback form. AboutUpdatesBody(fg, onNavigate) FeedbackBody(fg, vm) } } } /* ──────────────────────────── about / updates ─────────────────────────── */ @Composable private fun AboutUpdatesBody(fg: Color, onNavigate: (PeachNav) -> Unit) { val vm: UpdateViewModel = hiltViewModel() val available by vm.available.collectAsState() val state by vm.state.collectAsState() SubLabel("THIS BUILD", fg, top = 0.dp) 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), ) } if (available != null) { SubtleButton("update", fg) { onNavigate(PeachNav.Update) } } else { SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) { vm.check() } } } } /* ───────────────────────── 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, 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) { 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("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("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("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) } } } 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) { 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 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() 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 historyShowAll by remember { mutableStateOf(false) } SubLabel("VAULT HISTORY", fg) 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), ) } 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) } } } } 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 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.relayUrl.collectAsState() val syncMode by syncVm.syncMode.collectAsState() val scope = rememberCoroutineScope() var confirmUnpair by remember { mutableStateOf(null) } var rotateConfirmOpen by remember { mutableStateOf(false) } var rotating by remember { mutableStateOf(false) } var rotateError by remember { mutableStateOf(null) } var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(configuredRelayUrl) } var relayUrlStatus by remember { mutableStateOf(null) } // One pairing block, two buttons. The old "Sync hub" row went to the same // place as "show", and the description described a LAN-code-only design // that no longer exists. SubLabel("PAIR A DEVICE", 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( "End-to-end encrypted, through a relay that can read none of it.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } } RowLine(fg) { Column(Modifier.weight(1f)) { Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "For the other device to scan. Always use this one to pair a browser.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp, modifier = Modifier.padding(top = 2.dp), ) } SubtleButton("show", fg) { onNavigate(PeachNav.CodePair) } } RowLine(fg) { Column(Modifier.weight(1f)) { Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp) Text( "Joins that vault. Your entries are kept and the two merge; this phone's " + "recovery words are replaced, and shown to you first.", 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) } } 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)) { Text(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) { 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 } } } } } RowLine(fg) { ValueText(syncFlashLabel(syncStatus), fg) SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) { if (syncStatus != SyncStatus.Syncing) syncVm.syncNow() } } } // Three named choices, not a toggle with an implied default. LAN sync is // retired (docs/sync-lan-retirement.md), so "no relay" now means no sync // rather than a peer-to-peer mode that never reliably worked. The // phone-as-relay-for-two-browsers mechanism went with it: browsers dial the // real relay directly now, so this handset has no forwarding role. SubLabel("HOW THIS VAULT SYNCS", fg) SyncModeRow( title = "Public relay", detail = "Peach's own relay. Works anywhere, nothing to set up. It never sees " + "your vault — everything is encrypted before it leaves this device.", selected = syncMode == SyncMode.PUBLIC, fg = fg, ) { syncVm.setSyncMode(SyncMode.PUBLIC) } SyncModeRow( title = "Private relay", detail = "Your own peach-relay, on your own domain. Same open-source code.", 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(), ) } RowLine(fg) { ValueText(relayUrlStatus ?: "Only secure wss:// relays are accepted.", fg) SubtleButton("save", fg) { relayUrlStatus = if (syncVm.setPrivateRelayUrl(relayUrlDraft)) { relayUrlDraft = syncVm.relayUrl.value "Saved" } else { "Use a valid wss:// relay URL" } } } } SyncModeRow( title = "No sync", detail = "This device keeps to itself. Move your vault by hand with Codex or " + "CSV export and import.", selected = syncMode == SyncMode.OFF, fg = fg, ) { syncVm.setSyncMode(SyncMode.OFF) } 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 of your devices. Allow it to sync with this vault?", 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) } } } } } SubLabel("RECOVERY", fg) if (rotateConfirmOpen) { Hint("This will disconnect all paired devices and your 12 recovery words will change. 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( "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.", 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 } } } Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg) } 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 private fun syncFlashLabel(status: SyncStatus): String = when (status) { SyncStatus.Idle -> "" SyncStatus.Syncing -> "syncing..." SyncStatus.Offline -> "offline" is SyncStatus.Error -> status.message is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}" } /* ──────────────────────────────── 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(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), ) } }