SettingsCardBodies.kt

50.0 KB · raw

1package com.peachpasswords.ui.settings23import android.content.Intent4import android.net.Uri5import androidx.compose.animation.AnimatedVisibility6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.FlowRow14import androidx.compose.foundation.layout.ExperimentalLayoutApi15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.RowScope17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.shape.CircleShape24import androidx.compose.foundation.shape.RoundedCornerShape25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.runtime.Composable27import androidx.compose.material3.CircularProgressIndicator28import androidx.compose.runtime.DisposableEffect29import androidx.compose.runtime.collectAsState30import androidx.compose.runtime.getValue31import com.peachpasswords.feedback.FeedbackState32import com.peachpasswords.shared.PeachClipboard33import com.peachpasswords.sync.SyncStatus34import com.peachpasswords.ui.theme.LocalPeachAccent35import com.peachpasswords.vault.DuressAction36import com.peachpasswords.ui.theme.peachColors37import androidx.compose.runtime.mutableStateOf38import androidx.compose.runtime.remember39import androidx.compose.runtime.rememberCoroutineScope40import androidx.compose.runtime.setValue41import androidx.compose.ui.Alignment42import androidx.compose.ui.Modifier43import androidx.compose.ui.draw.clip44import androidx.compose.ui.graphics.Color45import androidx.compose.ui.graphics.SolidColor46import androidx.compose.ui.platform.LocalContext47import androidx.compose.ui.text.TextStyle48import androidx.compose.ui.text.font.FontStyle49import androidx.compose.ui.text.input.PasswordVisualTransformation50import androidx.compose.ui.unit.Dp51import androidx.compose.ui.unit.dp52import androidx.compose.ui.unit.em53import androidx.compose.ui.unit.sp54import androidx.compose.material3.Text55import com.peachpasswords.BuildConfig56import com.peachpasswords.ui.PeachNav57import com.peachpasswords.ui.sync.SyncViewModel58import com.peachpasswords.ui.theme.ACCENT_COLORS59import com.peachpasswords.ui.theme.PeachType60import androidx.hilt.navigation.compose.hiltViewModel61import com.peachpasswords.vault.VaultSettings62import com.peachpasswords.vault.VaultSnapshot63import kotlinx.coroutines.launch64import kotlinx.coroutines.delay65import java.text.SimpleDateFormat66import java.util.Date67import java.util.Locale6869private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"7071/**72 * Body content for the active stack card. Everything is tinted with the card's73 * own foreground [fg] (the browser's `currentColor` convention), so each card's74 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,75 * canary, license activation) render their browser visual but defer the action76 * to a later phase — noted inline where that's the case.77 */78@Composable79fun CardBody(80    id: CardId,81    fg: Color,82    vm: SettingsViewModel,83    settings: VaultSettings,84    entriesCount: Int,85    onNavigate: (PeachNav) -> Unit,86) {87    when (id) {88        CardId.Appearance -> AppearanceBody(fg, vm, settings)89        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)90        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)91        CardId.Sync -> SyncBody(fg, onNavigate)92        CardId.Shortcuts -> ShortcutsBody(fg)93        CardId.Pro -> ProBody(fg, vm)94        CardId.Feedback -> FeedbackBody(fg, vm)95    }96}9798/* ─────────────────────────  shared row vocabulary  ───────────────────────── */99100@Composable101private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {102    Text(103        text,104        modifier = Modifier.padding(top = top, bottom = 4.dp),105        color = fg.copy(alpha = 0.58f),106        fontFamily = PeachType.Mono,107        fontSize = 8.sp,108        letterSpacing = 0.22.em,109    )110}111112@Composable113private fun RowLine(fg: Color, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {114    Row(115        Modifier.fillMaxWidth().then(modifier).padding(vertical = 9.dp),116        verticalAlignment = Alignment.CenterVertically,117        horizontalArrangement = Arrangement.SpaceBetween,118        content = content,119    )120    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))121}122123@Composable124private fun RowScope.Name(text: String, fg: Color) {125    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))126}127128@Composable129private fun ValueText(text: String, fg: Color) {130    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)131}132133@Composable134private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {135    Box(136        Modifier.clip(RoundedCornerShape(8.dp))137            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))138            .background(fg.copy(alpha = 0.08f))139            .clickable(onClick = onClick)140            .padding(horizontal = 12.dp, vertical = 6.dp),141    ) {142        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)143    }144}145146@Composable147private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {148    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")149    Box(150        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))151            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))152            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))153            .clickable { onToggle(!on) },154    ) {155        Box(156            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)157                .padding(start = knob - 2.dp)158                .size(14.dp).clip(CircleShape)159                .background(if (on) fg else fg.copy(alpha = 0.58f)),160        )161    }162}163164@Composable165private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {166    Box(167        Modifier.clip(RoundedCornerShape(999.dp))168            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))169            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)170            .clickable(onClick = onClick)171            .padding(horizontal = 12.dp, vertical = 5.dp),172    ) {173        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)174    }175}176177@Composable178private fun ProBadge(fg: Color) {179    Box(180        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))181            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),182    ) {183        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)184    }185}186187@Composable188private fun Hint(text: String, fg: Color) {189    Text(190        text,191        modifier = Modifier.padding(top = 6.dp),192        color = fg.copy(alpha = 0.6f),193        fontFamily = PeachType.Display,194        fontStyle = FontStyle.Italic,195        fontSize = 11.5.sp,196        lineHeight = 16.sp,197    )198}199200/* ──────────────────────────────  Appearance  ─────────────────────────────── */201202@OptIn(ExperimentalLayoutApi::class)203@Composable204private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {205    SubLabel("THEME", fg, top = 0.dp)206    RowLine(fg) {207        Name("Theme", fg)208        val (label, next) = when (s.theme) {209            "dark" -> "Switch to light" to "light"210            else -> "Switch to dark" to "dark"211        }212        SubtleButton(label, fg) { vm.setTheme(next) }213    }214    SubLabel("ACCENT", fg)215    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {216        ACCENT_COLORS.forEach { (name, color) ->217            Box(218                Modifier.size(22.dp).clip(CircleShape).background(color)219                    .border(220                        if (s.accentColor == name) 2.dp else 1.dp,221                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),222                        CircleShape,223                    )224                    .clickable { vm.setAccent(name) },225            )226        }227    }228    SubLabel("TEXT SIZE", fg)229    RowLine(fg) {230        Name("Size", fg)231        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {232            Pill("Default", s.textSize == "medium", fg) { vm.setTextSize("medium") }233            Pill("Large", s.textSize == "large", fg) { vm.setTextSize("large") }234        }235    }236    SubLabel("MOTION", fg)237    RowLine(fg) {238        Name("Reduce motion", fg)239        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }240    }241}242243/* ──────────────────────────────  Protection  ─────────────────────────────── */244245@Composable246private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {247    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")248    val current = s.autoLockMinutes ?: 0249250    SubLabel("LOCKING", fg, top = 0.dp)251    RowLine(fg) {252        Name("Auto-lock after", fg)253        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)254        SubtleButton(lockOptions[idx].second, fg) {255            val next = lockOptions[(idx + 1) % lockOptions.size].first256            vm.setAutoLockMinutes(if (next == 0) 0 else next)257        }258    }259    SubLabel("BIOMETRICS", fg)260    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity261    RowLine(fg) {262        Name("Unlock with biometrics", fg)263        Toggle(s.biometricsEnabled, fg) { on ->264            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()265        }266    }267    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)268    if (bioError != null) {269        Text(270            bioError,271            color = com.peachpasswords.ui.theme.peachColors.Breach,272            fontFamily = PeachType.Mono, fontSize = 10.sp,273            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),274        )275    }276    SubLabel("PASSKEYS", fg)277    RowLine(fg) {278        Column(Modifier.weight(1f)) {279            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)280            Text(281                "Peach can handle passkey sign-in and creation through Android Credential Manager.",282                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,283                modifier = Modifier.padding(top = 2.dp),284            )285        }286        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }287    }288    RowLine(fg) {289        Name("Status", fg)290        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)291    }292    SubLabel("RECOVERY", fg)293    RowLine(fg) {294        Name("View recovery words", fg)295        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }296    }297    SubLabel("BREACH PROTECTION", fg)298    val checkingBreaches by vm.checkingBreaches.collectAsState()299    val breachResult by vm.breachResult.collectAsState()300    RowLine(fg) {301        Name("Breach monitoring", fg)302        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }303    }304    RowLine(fg) {305        Name("Check all passwords", fg)306        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }307    }308    breachResult?.let { result ->309        RowLine(fg) {310            Name("Last check", fg)311            ValueText(result, fg)312        }313    }314315    SubLabel("MASTER PASSWORD", fg)316    ChangePasswordRow(fg, vm)317318    SubLabel("DURESS PASSWORD", fg)319    val duressIsPro by vm.isPro.collectAsState()320    if (!duressIsPro) {321        RowLine(fg) {322            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {323                Name("Duress password", fg); ProBadge(fg)324            }325            ValueText("off", fg)326        }327        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)328    } else {329        DuressSection(fg, vm)330    }331}332333@Composable334private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {335    var open by remember { mutableStateOf(false) }336    var current by remember { mutableStateOf("") }337    var next by remember { mutableStateOf("") }338    var confirm by remember { mutableStateOf("") }339    var error by remember { mutableStateOf<String?>(null) }340    var done by remember { mutableStateOf(false) }341    val scope = rememberCoroutineScope()342343    RowLine(fg) {344        Name("Master password", fg)345        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }346    }347    AnimatedVisibility(open) {348        Column(Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {349            PwField("current password", current, fg) { current = it }350            PwField("new password (min 12 chars)", next, fg) { next = it }351            PwField("confirm new password", confirm, fg) { confirm = it }352            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }353            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)354            SubtleButton("save new password", fg) {355                error = when {356                    current.isBlank() -> "enter your current password"357                    next.length < 12 -> "new password must be at least 12 characters"358                    next != confirm -> "new passwords do not match"359                    next == current -> "new password must differ from current"360                    else -> null361                }362                if (error == null) scope.launch {363                    if (vm.changePassword(current, next)) {364                        current = ""; next = ""; confirm = ""; open = false; done = true365                    } else error = "incorrect current password"366                }367            }368        }369    }370}371372@Composable373private fun PwField(placeholder: String, value: String, fg: Color, onChange: (String) -> Unit) {374    Box(375        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))376            .background(Color(0x2E000000))377            .border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))378            .padding(horizontal = 10.dp, vertical = 10.dp),379    ) {380        if (value.isEmpty()) {381            Text(placeholder, color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Mono, fontSize = 12.sp)382        }383        BasicTextField(384            value = value,385            onValueChange = onChange,386            singleLine = true,387            textStyle = TextStyle(color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp),388            cursorBrush = SolidColor(fg),389            visualTransformation = PasswordVisualTransformation(),390            modifier = Modifier.fillMaxWidth(),391        )392    }393}394395/* ────────────────────────────────  Vault  ───────────────────────────────── */396397@Composable398private fun VaultBody(399    fg: Color,400    vm: SettingsViewModel,401    s: VaultSettings,402    entriesCount: Int,403    onNavigate: (PeachNav) -> Unit,404) {405    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")406    val current = s.trashRetentionDays ?: 0407    val isPro by vm.isPro.collectAsState()408    val dedupCount by vm.dedupCount.collectAsState()409    val dedupRunning by vm.dedupRunning.collectAsState()410    val dedupDone by vm.dedupDone.collectAsState()411    val history by vm.vaultHistory.collectAsState()412    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()413414    SubLabel("ENTRIES", fg, top = 0.dp)415    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }416    RowLine(fg) {417        Name("Duplicates", fg)418        val done = dedupDone419        if (done != null) {420            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)421        } else {422            val count = dedupCount423            val label = when {424                dedupRunning -> "scanning..."425                count == null -> "scan"426                count == 0 -> "none found"427                else -> "$count found"428            }429            SubtleButton(label, fg) {430                if (count == null || count == 0) vm.scanDuplicates()431            }432        }433    }434    if ((dedupCount ?: 0) > 0 && dedupDone == null) {435        Column(436            Modifier.fillMaxWidth()437                .padding(top = 2.dp, bottom = 8.dp)438                .clip(RoundedCornerShape(8.dp))439                .background(fg.copy(alpha = 0.08f))440                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))441                .padding(10.dp),442            verticalArrangement = Arrangement.spacedBy(8.dp),443        ) {444            val count = dedupCount ?: 0445            Text(446                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",447                color = fg.copy(alpha = 0.82f),448                fontFamily = PeachType.Body,449                fontSize = 11.sp,450                lineHeight = 16.sp,451            )452            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {453                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }454                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }455            }456        }457    }458459    SubLabel("MAINTENANCE", fg)460    RowLine(fg) {461        Name("Trash auto-delete", fg)462        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)463        SubtleButton(trashOptions[idx].second, fg) {464            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)465        }466    }467    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }468    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }469470    SubLabel("DATA", fg)471    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }472    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }473    RowLine(fg) {474        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }475        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }476    }477    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }478479    var historyShowAll by remember { mutableStateOf(false) }480    SubLabel("VAULT HISTORY", fg)481    if (history.isEmpty()) {482        Text(483            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",484            color = fg.copy(alpha = 0.68f),485            fontFamily = PeachType.Body,486            fontSize = 11.sp,487            lineHeight = 16.sp,488            modifier = Modifier.padding(bottom = 6.dp),489        )490    } else {491        Text(492            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",493            color = fg.copy(alpha = 0.68f),494            fontFamily = PeachType.Body,495            fontSize = 11.sp,496            lineHeight = 16.sp,497            modifier = Modifier.padding(bottom = 4.dp),498        )499        history.take(if (historyShowAll) history.size else 3).forEach { snap ->500            RowLine(fg) {501                Column(Modifier.weight(1f)) {502                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)503                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)504                }505                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {506                    vm.restoreVaultSnapshot(snap.id)507                }508            }509        }510        if (history.size > 3) {511            RowLine(fg) {512                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)513                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }514            }515        }516        Text(517            "${history.size} of 5 snapshots",518            color = fg.copy(alpha = 0.58f),519            fontFamily = PeachType.Mono,520            fontSize = 10.sp,521            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),522        )523    }524525    var advancedOpen by remember { mutableStateOf(false) }526    SubLabel("ADVANCED", fg)527    RowLine(fg) {528        Name("Advanced", fg)529        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }530    }531    if (advancedOpen) {532        RowLine(fg) {533            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)534            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }535        }536        RowLine(fg) {537            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)538            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }539        }540    }541}542543private fun snapshotSubLabel(snapshot: VaultSnapshot): String {544    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }545    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")546}547548private fun snapshotReasonLabel(reason: String): String = when (reason) {549    "local_save" -> "saved"550    "sync_received" -> "synced"551    "import" -> "imported"552    "restore" -> "restored"553    else -> reason554}555556private fun snapshotTimeLabel(savedAt: Long): String {557    val diff = System.currentTimeMillis() - savedAt558    if (diff < 60_000) return "Just now"559    if (diff < 3_600_000) return "${diff / 60_000}m ago"560    val date = Date(savedAt)561    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())562    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)563    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)564    if (today == thenDay) return "Today $time"565    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))566    if (yesterday == thenDay) return "Yesterday $time"567    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)568}569570/* ────────────────────────────────  Sync  ────────────────────────────────── */571572@Composable573private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {574    val syncVm: SyncViewModel = hiltViewModel()575    val pairedDevices by syncVm.pairedDevices.collectAsState()576    val pendingVouched by syncVm.pendingVouched.collectAsState()577    val syncStatus by syncVm.status.collectAsState()578    val scope = rememberCoroutineScope()579    var confirmUnpair by remember { mutableStateOf<String?>(null) }580    var rotateConfirmOpen by remember { mutableStateOf(false) }581    var rotating by remember { mutableStateOf(false) }582    var rotateError by remember { mutableStateOf<String?>(null) }583584    SubLabel("PAIRING", fg, top = 0.dp)585    RowLine(fg) {586        Column(Modifier.weight(1f)) {587            Row(verticalAlignment = Alignment.CenterVertically) {588                Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)589            }590            Text(591                "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.",592                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,593                modifier = Modifier.padding(top = 2.dp),594            )595        }596    }597    // The whole row is the tap target — no separate "open" button (browser598    // sync-hub parity: every settings row is tappable, the chevron signals it).599    RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) {600        Name("Sync hub", fg)601        Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)602    }603    RowLine(fg) {604        Column(Modifier.weight(1f)) {605            Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)606            Text(607                "Add a phone, another browser, or your desktop sync helper.",608                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,609                modifier = Modifier.padding(top = 2.dp),610            )611        }612        SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) }613    }614615    SubLabel("PAIRED DEVICES", fg)616    if (pairedDevices.isEmpty()) {617        Text(618            "No paired devices yet.",619            color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp,620            modifier = Modifier.padding(top = 2.dp),621        )622    } else {623        pairedDevices.forEach { d ->624            RowLine(fg) {625                Column(Modifier.weight(1f)) {626                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)627                    Text(628                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",629                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,630                        modifier = Modifier.padding(top = 2.dp),631                    )632                }633                if (confirmUnpair == d.deviceId) {634                    Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {635                        SubtleButton("cancel", fg) { confirmUnpair = null }636                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }637                    }638                } else {639                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }640                }641            }642        }643        RowLine(fg) {644            ValueText(syncFlashLabel(syncStatus), fg)645            SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {646                if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()647            }648        }649    }650651    if (pendingVouched.isNotEmpty()) {652        SubLabel("PENDING DEVICES", fg)653        pendingVouched.forEach { dev ->654            RowLine(fg) {655                Column(Modifier.weight(1f)) {656                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)657                    Text(658                        "Was added by another of your devices. Allow it to sync with this vault?",659                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,660                        modifier = Modifier.padding(top = 2.dp),661                    )662                }663                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {664                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }665                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }666                }667            }668        }669    }670671    SubLabel("RECOVERY", fg)672    if (rotateConfirmOpen) {673        Hint("This will disconnect all paired devices and your 12 recovery words will change. You'll be shown the new words next.", peachColors.Breach)674        rotateError?.let {675            Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))676        }677        Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {678            SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }679            SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {680                if (rotating) return@SubtleButton681                rotating = true682                rotateError = null683                scope.launch {684                    val ok = syncVm.rotateSyncIdentity()685                    rotating = false686                    if (ok) {687                        rotateConfirmOpen = false688                        onNavigate(PeachNav.RecoverySetup)689                    } else {690                        rotateError = "Rotation failed. Unlock your real vault and try again."691                    }692                }693            }694        }695    } else {696        RowLine(fg) {697            Column(Modifier.weight(1f)) {698                Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)699                Text(700                    "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.",701                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,702                    modifier = Modifier.padding(top = 2.dp),703                )704            }705            SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }706        }707    }708709    Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)710}711712private fun deviceTypeLabel(type: String?): String = when (type) {713    "android" -> "Android"714    "phone" -> "Phone"715    "tablet" -> "Tablet"716    "desktop" -> "Desktop"717    "browser" -> "Browser"718    else -> "Device"719}720721private fun syncTimeLabel(lastSyncedAt: Long?): String {722    if (lastSyncedAt == null) return "never synced"723    val diff = System.currentTimeMillis() - lastSyncedAt724    if (diff < 60_000) return "just now"725    if (diff < 3_600_000) return "${diff / 60_000}m ago"726    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"727    return "${diff / 86_400_000}d ago"728}729730private fun isSyncStale(lastSyncedAt: Long?): Boolean =731    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L732733private fun syncFlashLabel(status: SyncStatus): String = when (status) {734    SyncStatus.Idle -> ""735    SyncStatus.Syncing -> "syncing..."736    SyncStatus.Offline -> "offline"737    is SyncStatus.Error -> status.message738    is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}"739}740741/* ──────────────────────────────  Shortcuts  ──────────────────────────────── */742743@Composable744private fun ShortcutsBody(fg: Color) {745    val bindings = listOf(746        "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵",747        "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/",748    )749    SubLabel("KEYBINDINGS", fg, top = 0.dp)750    Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg)751    Spacer(Modifier.height(8.dp))752    bindings.forEach { (label, key) ->753        RowLine(fg) {754            Name(label, fg)755            Box(756                Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f))757                    .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp))758                    .padding(horizontal = 7.dp, vertical = 2.dp),759            ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) }760        }761    }762}763764/* ────────────────────────────────  Pro  ─────────────────────────────────── */765766@Composable767private fun ProBody(fg: Color, vm: SettingsViewModel) {768    val ctx = LocalContext.current769    val isPro by vm.isPro.collectAsState()770    if (isPro) {771        ProActiveBody(fg, vm)772        return773    }774    val features = listOf(775        "Peach Sync" to "Encrypted · peer-to-peer",776        "Peach Codex" to "Restore from paper",777        "Passkeys" to "Zero-knowledge WebAuthn",778        "Advanced Vault Health" to "Reuse · age · exposure",779        "Canary entries" to "Detects vault compromise",780        "Duress vault" to "Decoy under coercion",781    )782    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)783    Text(784        "Everything, everywhere.",785        modifier = Modifier.padding(top = 8.dp),786        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,787    )788    Text(789        "One key unlocks every Pro feature — for the lifetime of the app.",790        modifier = Modifier.padding(top = 6.dp),791        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,792    )793    Spacer(Modifier.height(12.dp))794    features.forEachIndexed { i, (name, desc) ->795        RowLine(fg) {796            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {797                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))798                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)799            }800            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)801        }802    }803    Spacer(Modifier.height(14.dp))804    Text(805        "$39",806        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,807    )808    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)809    Spacer(Modifier.height(10.dp))810    Box(811        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))812            .background(fg.copy(alpha = 0.16f))813            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))814            .clickable {815                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }816            }817            .padding(vertical = 12.dp),818        contentAlignment = Alignment.Center,819    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }820821    Spacer(Modifier.height(16.dp))822    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)823    var key by remember { mutableStateOf("") }824    val activating by vm.activating.collectAsState()825    val activationError by vm.activationError.collectAsState()826    Box(827        Modifier.fillMaxWidth().padding(top = 6.dp)828            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))829            .padding(horizontal = 12.dp, vertical = 10.dp),830    ) {831        BasicTextField(832            value = key,833            onValueChange = { key = it.uppercase(); vm.clearActivationError() },834            singleLine = true,835            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),836            cursorBrush = SolidColor(fg),837            modifier = Modifier.fillMaxWidth(),838            decorationBox = { inner ->839                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)840                inner()841            },842        )843    }844    if (activationError != null) {845        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))846    }847    Spacer(Modifier.height(8.dp))848    Box(849        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))850            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))851            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))852            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }853            .padding(vertical = 12.dp),854        contentAlignment = Alignment.Center,855    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }856}857858@Composable859private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {860    val ctx = LocalContext.current861    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")862    val licenseKey by vm.licenseKey.collectAsState()863    val issuedAt by vm.licenseIssuedAt.collectAsState()864    var showKey by remember { mutableStateOf(false) }865    var copied by remember { mutableStateOf(false) }866    val scope = rememberCoroutineScope()867    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {868        com.peachpasswords.ui.components.PeachMark(size = 60.dp)869    }870    Text(871        "Peach Pro",872        modifier = Modifier.fillMaxWidth(),873        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,874        textAlign = androidx.compose.ui.text.style.TextAlign.Center,875    )876    Text(877        formatLicenseActiveSince(issuedAt),878        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),879        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,880        textAlign = androidx.compose.ui.text.style.TextAlign.Center,881    )882    licenseKey?.let { key ->883        Spacer(Modifier.height(12.dp))884        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)885        RowLine(fg) {886            Text(887                if (showKey) key else "************************",888                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),889                fontFamily = PeachType.Mono,890                fontSize = 10.sp,891                modifier = Modifier.weight(1f),892            )893            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {894                SubtleButton(if (copied) "copied" else "copy", fg) {895                    PeachClipboard.copy(ctx, "peach license", key)896                    copied = true897                    scope.launch {898                        delay(1_500)899                        copied = false900                    }901                }902                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }903            }904        }905    }906    Spacer(Modifier.height(12.dp))907    features.forEachIndexed { i, name ->908        RowLine(fg) {909            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {910                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))911                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)912            }913            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)914        }915    }916    Spacer(Modifier.height(12.dp))917    Box(918        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))919            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))920            .clickable { vm.deactivate() }921            .padding(vertical = 12.dp),922        contentAlignment = Alignment.Center,923    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }924}925926private fun formatLicenseActiveSince(issuedAt: Long?): String {927    if (issuedAt == null) return "Active"928    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))929    return "Active since $monthYear"930}931932/* ──────────────────────────────  Feedback  ───────────────────────────────── */933934@OptIn(ExperimentalLayoutApi::class)935@Composable936private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {937    val categories = listOf("Bug report", "Feature request", "Question", "Other")938    var category by remember { mutableStateOf(categories[0]) }939    var message by remember { mutableStateOf("") }940    val state by vm.feedbackState.collectAsState()941942    // Reset transient state when the card leaves composition so reopening starts fresh.943    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }944945    if (state is FeedbackState.Sent) {946        Box(947            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))948                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),949        ) {950            Column {951                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)952                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))953            }954        }955        return956    }957958    val sending = state is FeedbackState.Sending959960    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)961    SubLabel("TYPE", fg)962    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {963        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }964    }965    SubLabel("MESSAGE", fg)966    Box(967        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))968            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))969            .padding(10.dp),970    ) {971        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)972        BasicTextField(973            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },974            enabled = !sending,975            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),976            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),977        )978    }979    Text(980        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",981        modifier = Modifier.padding(top = 6.dp),982        color = fg.copy(alpha = 0.52f),983        fontFamily = PeachType.Mono,984        fontSize = 10.sp,985        lineHeight = 14.sp,986    )987    Spacer(Modifier.height(8.dp))988989    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {990        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()991        Box(992            Modifier.clip(RoundedCornerShape(8.dp))993                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))994                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))995                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }996                .padding(horizontal = 12.dp, vertical = 6.dp),997        ) {998            Text(999                feedbackSubmitLabel(state),1000                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1001                fontFamily = PeachType.Mono, fontSize = 11.sp,1002            )1003        }1004        if (sending) {1005            CircularProgressIndicator(1006                color = LocalPeachAccent.current,1007                strokeWidth = 2.dp,1008                modifier = Modifier.size(16.dp),1009            )1010        }1011    }10121013    (state as? FeedbackState.Error)?.let { err ->1014        Text(1015            err.message,1016            modifier = Modifier.padding(top = 8.dp),1017            color = peachColors.Breach,1018            fontFamily = PeachType.Mono,1019            fontSize = 11.sp,1020            lineHeight = 15.sp,1021        )1022    }10231024    Hint("Sent anonymously · no account required", fg)1025}10261027/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */10281029@Composable1030internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1031    val configured by vm.duressConfigured.collectAsState()1032    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1033}10341035@Composable1036private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1037    Text(1038        "Duress password is active",1039        color = peachColors.HealthOk,1040        fontFamily = PeachType.Mono,1041        fontSize = 11.sp,1042        modifier = Modifier.padding(top = 6.dp),1043    )1044    Spacer(Modifier.height(8.dp))1045    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1046    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1047}10481049@Composable1050private fun DuressForm(fg: Color, vm: SettingsViewModel) {1051    var enabled by remember { mutableStateOf(false) }1052    var password by remember { mutableStateOf("") }1053    var confirm by remember { mutableStateOf("") }1054    var action by remember { mutableStateOf(DuressAction.DECOY) }1055    var error by remember { mutableStateOf<String?>(null) }1056    val scope = rememberCoroutineScope()10571058    RowLine(fg) {1059        Name("Duress password", fg)1060        Toggle(enabled, fg) { on ->1061            enabled = on1062            if (!on) { password = ""; confirm = ""; error = null }1063        }1064    }1065    Text(1066        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1067        color = fg.copy(alpha = 0.7f),1068        fontFamily = PeachType.Mono,1069        fontSize = 10.sp,1070        modifier = Modifier.padding(top = 6.dp),1071    )10721073    AnimatedVisibility(enabled) {1074        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1075            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1076            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }10771078            Text(1079                "When your duress password is entered at the lock screen, Peach will:",1080                color = fg.copy(alpha = 0.7f),1081                fontFamily = PeachType.Mono,1082                fontSize = 10.sp,1083                modifier = Modifier.padding(top = 4.dp),1084            )10851086            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1087            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }10881089            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }10901091            val canSave = password.isNotBlank() && confirm.isNotBlank()1092            DuressSaveButton("Save duress password", fg, canSave) {1093                error = when {1094                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1095                    password != confirm -> "Passwords do not match"1096                    else -> null1097                }1098                if (error == null) scope.launch {1099                    if (vm.saveDuress(password, action)) {1100                        password = ""; confirm = ""; enabled = false1101                    } else {1102                        error = "Couldn't save — the duress passphrase must differ from your real password"1103                    }1104                }1105            }1106        }1107    }1108}11091110@Composable1111private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1112    val accent = LocalPeachAccent.current1113    Row(1114        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1115        verticalAlignment = Alignment.CenterVertically,1116        horizontalArrangement = Arrangement.spacedBy(10.dp),1117    ) {1118        Box(1119            Modifier.size(16.dp).clip(CircleShape)1120                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1121            contentAlignment = Alignment.Center,1122        ) {1123            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1124        }1125        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1126    }1127}11281129@Composable1130private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1131    val accent = LocalPeachAccent.current1132    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1133    Box(1134        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1135            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1136            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1137            .let { if (enabled) it.clickable(onClick = onClick) else it }1138            .padding(vertical = 11.dp),1139        contentAlignment = Alignment.Center,1140    ) {1141        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1142    }1143}11441145@Composable1146private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1147    Box(1148        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1149            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1150            .clickable(onClick = onClick)1151            .padding(vertical = 11.dp),1152        contentAlignment = Alignment.Center,1153    ) {1154        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1155    }1156}