SettingsCardBodies.kt

48.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.sync.SyncStatus33import com.peachpasswords.ui.theme.LocalPeachAccent34import com.peachpasswords.vault.DuressAction35import com.peachpasswords.ui.theme.peachColors36import androidx.compose.runtime.mutableStateOf37import androidx.compose.runtime.remember38import androidx.compose.runtime.rememberCoroutineScope39import androidx.compose.runtime.setValue40import androidx.compose.ui.Alignment41import androidx.compose.ui.Modifier42import androidx.compose.ui.draw.clip43import androidx.compose.ui.graphics.Color44import androidx.compose.ui.graphics.SolidColor45import androidx.compose.ui.platform.LocalContext46import androidx.compose.ui.text.TextStyle47import androidx.compose.ui.text.font.FontStyle48import androidx.compose.ui.text.input.PasswordVisualTransformation49import androidx.compose.ui.unit.Dp50import androidx.compose.ui.unit.dp51import androidx.compose.ui.unit.em52import androidx.compose.ui.unit.sp53import androidx.compose.material3.Text54import com.peachpasswords.ui.PeachNav55import com.peachpasswords.ui.sync.SyncViewModel56import com.peachpasswords.ui.theme.ACCENT_COLORS57import com.peachpasswords.ui.theme.PeachType58import androidx.hilt.navigation.compose.hiltViewModel59import com.peachpasswords.vault.VaultSettings60import com.peachpasswords.vault.VaultSnapshot61import kotlinx.coroutines.launch62import java.text.SimpleDateFormat63import java.util.Date64import java.util.Locale6566private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"6768/**69 * Body content for the active stack card. Everything is tinted with the card's70 * own foreground [fg] (the browser's `currentColor` convention), so each card's71 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,72 * canary, license activation) render their browser visual but defer the action73 * to a later phase — noted inline where that's the case.74 */75@Composable76fun CardBody(77    id: CardId,78    fg: Color,79    vm: SettingsViewModel,80    settings: VaultSettings,81    entriesCount: Int,82    onNavigate: (PeachNav) -> Unit,83) {84    when (id) {85        CardId.Appearance -> AppearanceBody(fg, vm, settings)86        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)87        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)88        CardId.Sync -> SyncBody(fg, onNavigate)89        CardId.Shortcuts -> ShortcutsBody(fg)90        CardId.Pro -> ProBody(fg, vm)91        CardId.Feedback -> FeedbackBody(fg, vm)92    }93}9495/* ─────────────────────────  shared row vocabulary  ───────────────────────── */9697@Composable98private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {99    Text(100        text,101        modifier = Modifier.padding(top = top, bottom = 4.dp),102        color = fg.copy(alpha = 0.58f),103        fontFamily = PeachType.Mono,104        fontSize = 8.sp,105        letterSpacing = 0.22.em,106    )107}108109@Composable110private fun RowLine(fg: Color, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {111    Row(112        Modifier.fillMaxWidth().then(modifier).padding(vertical = 9.dp),113        verticalAlignment = Alignment.CenterVertically,114        horizontalArrangement = Arrangement.SpaceBetween,115        content = content,116    )117    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))118}119120@Composable121private fun RowScope.Name(text: String, fg: Color) {122    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))123}124125@Composable126private fun ValueText(text: String, fg: Color) {127    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)128}129130@Composable131private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {132    Box(133        Modifier.clip(RoundedCornerShape(8.dp))134            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))135            .background(fg.copy(alpha = 0.08f))136            .clickable(onClick = onClick)137            .padding(horizontal = 12.dp, vertical = 6.dp),138    ) {139        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)140    }141}142143@Composable144private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {145    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")146    Box(147        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))148            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))149            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))150            .clickable { onToggle(!on) },151    ) {152        Box(153            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)154                .padding(start = knob - 2.dp)155                .size(14.dp).clip(CircleShape)156                .background(if (on) fg else fg.copy(alpha = 0.58f)),157        )158    }159}160161@Composable162private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {163    Box(164        Modifier.clip(RoundedCornerShape(999.dp))165            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))166            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)167            .clickable(onClick = onClick)168            .padding(horizontal = 12.dp, vertical = 5.dp),169    ) {170        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)171    }172}173174@Composable175private fun ProBadge(fg: Color) {176    Box(177        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))178            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),179    ) {180        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)181    }182}183184@Composable185private fun Hint(text: String, fg: Color) {186    Text(187        text,188        modifier = Modifier.padding(top = 6.dp),189        color = fg.copy(alpha = 0.6f),190        fontFamily = PeachType.Display,191        fontStyle = FontStyle.Italic,192        fontSize = 11.5.sp,193        lineHeight = 16.sp,194    )195}196197/* ──────────────────────────────  Appearance  ─────────────────────────────── */198199@OptIn(ExperimentalLayoutApi::class)200@Composable201private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {202    SubLabel("THEME", fg, top = 0.dp)203    RowLine(fg) {204        Name("Theme", fg)205        val (label, next) = when (s.theme) {206            "dark" -> "Switch to light" to "light"207            else -> "Switch to dark" to "dark"208        }209        SubtleButton(label, fg) { vm.setTheme(next) }210    }211    SubLabel("ACCENT", fg)212    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {213        ACCENT_COLORS.forEach { (name, color) ->214            Box(215                Modifier.size(22.dp).clip(CircleShape).background(color)216                    .border(217                        if (s.accentColor == name) 2.dp else 1.dp,218                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),219                        CircleShape,220                    )221                    .clickable { vm.setAccent(name) },222            )223        }224    }225    SubLabel("TEXT SIZE", fg)226    RowLine(fg) {227        Name("Size", fg)228        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {229            Pill("Default", s.textSize == "medium", fg) { vm.setTextSize("medium") }230            Pill("Large", s.textSize == "large", fg) { vm.setTextSize("large") }231        }232    }233    SubLabel("MOTION", fg)234    RowLine(fg) {235        Name("Reduce motion", fg)236        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }237    }238}239240/* ──────────────────────────────  Protection  ─────────────────────────────── */241242@Composable243private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {244    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")245    val current = s.autoLockMinutes ?: 0246247    SubLabel("LOCKING", fg, top = 0.dp)248    RowLine(fg) {249        Name("Auto-lock after", fg)250        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)251        SubtleButton(lockOptions[idx].second, fg) {252            val next = lockOptions[(idx + 1) % lockOptions.size].first253            vm.setAutoLockMinutes(if (next == 0) 0 else next)254        }255    }256    SubLabel("BIOMETRICS", fg)257    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity258    RowLine(fg) {259        Name("Unlock with biometrics", fg)260        Toggle(s.biometricsEnabled, fg) { on ->261            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()262        }263    }264    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)265    if (bioError != null) {266        Text(267            bioError,268            color = com.peachpasswords.ui.theme.peachColors.Breach,269            fontFamily = PeachType.Mono, fontSize = 10.sp,270            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),271        )272    }273    SubLabel("PASSKEYS", fg)274    RowLine(fg) {275        Column(Modifier.weight(1f)) {276            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)277            Text(278                "Peach can handle passkey sign-in and creation through Android Credential Manager.",279                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,280                modifier = Modifier.padding(top = 2.dp),281            )282        }283        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }284    }285    RowLine(fg) {286        Name("Status", fg)287        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)288    }289    SubLabel("RECOVERY", fg)290    RowLine(fg) {291        Name("View recovery words", fg)292        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }293    }294    SubLabel("BREACH PROTECTION", fg)295    val checkingBreaches by vm.checkingBreaches.collectAsState()296    val breachResult by vm.breachResult.collectAsState()297    RowLine(fg) {298        Name("Breach monitoring", fg)299        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }300    }301    RowLine(fg) {302        Name("Check all passwords", fg)303        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }304    }305    breachResult?.let { result ->306        RowLine(fg) {307            Name("Last check", fg)308            ValueText(result, fg)309        }310    }311312    SubLabel("MASTER PASSWORD", fg)313    ChangePasswordRow(fg, vm)314315    SubLabel("DURESS PASSWORD", fg)316    val duressIsPro by vm.isPro.collectAsState()317    if (!duressIsPro) {318        RowLine(fg) {319            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {320                Name("Duress password", fg); ProBadge(fg)321            }322            ValueText("off", fg)323        }324        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)325    } else {326        DuressSection(fg, vm)327    }328}329330@Composable331private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {332    var open by remember { mutableStateOf(false) }333    var current by remember { mutableStateOf("") }334    var next by remember { mutableStateOf("") }335    var confirm by remember { mutableStateOf("") }336    var error by remember { mutableStateOf<String?>(null) }337    var done by remember { mutableStateOf(false) }338    val scope = rememberCoroutineScope()339340    RowLine(fg) {341        Name("Master password", fg)342        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }343    }344    AnimatedVisibility(open) {345        Column(Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {346            PwField("current password", current, fg) { current = it }347            PwField("new password (min 12 chars)", next, fg) { next = it }348            PwField("confirm new password", confirm, fg) { confirm = it }349            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }350            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)351            SubtleButton("save new password", fg) {352                error = when {353                    current.isBlank() -> "enter your current password"354                    next.length < 12 -> "new password must be at least 12 characters"355                    next != confirm -> "new passwords do not match"356                    next == current -> "new password must differ from current"357                    else -> null358                }359                if (error == null) scope.launch {360                    if (vm.changePassword(current, next)) {361                        current = ""; next = ""; confirm = ""; open = false; done = true362                    } else error = "incorrect current password"363                }364            }365        }366    }367}368369@Composable370private fun PwField(placeholder: String, value: String, fg: Color, onChange: (String) -> Unit) {371    Box(372        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))373            .background(Color(0x2E000000))374            .border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))375            .padding(horizontal = 10.dp, vertical = 10.dp),376    ) {377        if (value.isEmpty()) {378            Text(placeholder, color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Mono, fontSize = 12.sp)379        }380        BasicTextField(381            value = value,382            onValueChange = onChange,383            singleLine = true,384            textStyle = TextStyle(color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp),385            cursorBrush = SolidColor(fg),386            visualTransformation = PasswordVisualTransformation(),387            modifier = Modifier.fillMaxWidth(),388        )389    }390}391392/* ────────────────────────────────  Vault  ───────────────────────────────── */393394@Composable395private fun VaultBody(396    fg: Color,397    vm: SettingsViewModel,398    s: VaultSettings,399    entriesCount: Int,400    onNavigate: (PeachNav) -> Unit,401) {402    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")403    val current = s.trashRetentionDays ?: 0404    val isPro by vm.isPro.collectAsState()405    val dedupCount by vm.dedupCount.collectAsState()406    val dedupRunning by vm.dedupRunning.collectAsState()407    val dedupDone by vm.dedupDone.collectAsState()408    val history by vm.vaultHistory.collectAsState()409    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()410411    SubLabel("ENTRIES", fg, top = 0.dp)412    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }413    RowLine(fg) {414        Name("Duplicates", fg)415        val done = dedupDone416        if (done != null) {417            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)418        } else {419            val count = dedupCount420            val label = when {421                dedupRunning -> "scanning..."422                count == null -> "scan"423                count == 0 -> "none found"424                else -> "$count found"425            }426            SubtleButton(label, fg) {427                if (count == null || count == 0) vm.scanDuplicates()428            }429        }430    }431    if ((dedupCount ?: 0) > 0 && dedupDone == null) {432        Column(433            Modifier.fillMaxWidth()434                .padding(top = 2.dp, bottom = 8.dp)435                .clip(RoundedCornerShape(8.dp))436                .background(fg.copy(alpha = 0.08f))437                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))438                .padding(10.dp),439            verticalArrangement = Arrangement.spacedBy(8.dp),440        ) {441            val count = dedupCount ?: 0442            Text(443                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",444                color = fg.copy(alpha = 0.82f),445                fontFamily = PeachType.Body,446                fontSize = 11.sp,447                lineHeight = 16.sp,448            )449            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {450                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }451                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }452            }453        }454    }455456    SubLabel("MAINTENANCE", fg)457    RowLine(fg) {458        Name("Trash auto-delete", fg)459        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)460        SubtleButton(trashOptions[idx].second, fg) {461            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)462        }463    }464    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }465    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }466467    SubLabel("DATA", fg)468    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }469    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }470    RowLine(fg) {471        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }472        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }473    }474    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }475476    var historyShowAll by remember { mutableStateOf(false) }477    SubLabel("VAULT HISTORY", fg)478    if (history.isEmpty()) {479        Text(480            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",481            color = fg.copy(alpha = 0.68f),482            fontFamily = PeachType.Body,483            fontSize = 11.sp,484            lineHeight = 16.sp,485            modifier = Modifier.padding(bottom = 6.dp),486        )487    } else {488        Text(489            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",490            color = fg.copy(alpha = 0.68f),491            fontFamily = PeachType.Body,492            fontSize = 11.sp,493            lineHeight = 16.sp,494            modifier = Modifier.padding(bottom = 4.dp),495        )496        history.take(if (historyShowAll) history.size else 3).forEach { snap ->497            RowLine(fg) {498                Column(Modifier.weight(1f)) {499                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)500                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)501                }502                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {503                    vm.restoreVaultSnapshot(snap.id)504                }505            }506        }507        if (history.size > 3) {508            RowLine(fg) {509                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)510                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }511            }512        }513        Text(514            "${history.size} of 5 snapshots",515            color = fg.copy(alpha = 0.58f),516            fontFamily = PeachType.Mono,517            fontSize = 10.sp,518            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),519        )520    }521522    var advancedOpen by remember { mutableStateOf(false) }523    SubLabel("ADVANCED", fg)524    RowLine(fg) {525        Name("Advanced", fg)526        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }527    }528    if (advancedOpen) {529        RowLine(fg) {530            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)531            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }532        }533        RowLine(fg) {534            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)535            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }536        }537    }538}539540private fun snapshotSubLabel(snapshot: VaultSnapshot): String {541    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }542    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")543}544545private fun snapshotReasonLabel(reason: String): String = when (reason) {546    "local_save" -> "saved"547    "sync_received" -> "synced"548    "import" -> "imported"549    "restore" -> "restored"550    else -> reason551}552553private fun snapshotTimeLabel(savedAt: Long): String {554    val diff = System.currentTimeMillis() - savedAt555    if (diff < 60_000) return "Just now"556    if (diff < 3_600_000) return "${diff / 60_000}m ago"557    val date = Date(savedAt)558    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())559    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)560    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)561    if (today == thenDay) return "Today $time"562    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))563    if (yesterday == thenDay) return "Yesterday $time"564    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)565}566567/* ────────────────────────────────  Sync  ────────────────────────────────── */568569@Composable570private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {571    val syncVm: SyncViewModel = hiltViewModel()572    val pairedDevices by syncVm.pairedDevices.collectAsState()573    val pendingVouched by syncVm.pendingVouched.collectAsState()574    val syncStatus by syncVm.status.collectAsState()575    val scope = rememberCoroutineScope()576    var confirmUnpair by remember { mutableStateOf<String?>(null) }577    var rotateConfirmOpen by remember { mutableStateOf(false) }578    var rotating by remember { mutableStateOf(false) }579    var rotateError by remember { mutableStateOf<String?>(null) }580581    SubLabel("PAIRING", fg, top = 0.dp)582    RowLine(fg) {583        Column(Modifier.weight(1f)) {584            Row(verticalAlignment = Alignment.CenterVertically) {585                Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)586            }587            Text(588                "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.",589                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,590                modifier = Modifier.padding(top = 2.dp),591            )592        }593    }594    // The whole row is the tap target — no separate "open" button (browser595    // sync-hub parity: every settings row is tappable, the chevron signals it).596    RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) {597        Name("Sync hub", fg)598        Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)599    }600    RowLine(fg) {601        Column(Modifier.weight(1f)) {602            Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)603            Text(604                "Add a phone, another browser, or your desktop sync helper.",605                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,606                modifier = Modifier.padding(top = 2.dp),607            )608        }609        SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) }610    }611612    SubLabel("PAIRED DEVICES", fg)613    if (pairedDevices.isEmpty()) {614        Text(615            "No paired devices yet.",616            color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp,617            modifier = Modifier.padding(top = 2.dp),618        )619    } else {620        pairedDevices.forEach { d ->621            RowLine(fg) {622                Column(Modifier.weight(1f)) {623                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)624                    Text(625                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",626                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,627                        modifier = Modifier.padding(top = 2.dp),628                    )629                }630                if (confirmUnpair == d.deviceId) {631                    Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {632                        SubtleButton("cancel", fg) { confirmUnpair = null }633                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }634                    }635                } else {636                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }637                }638            }639        }640        RowLine(fg) {641            ValueText(syncFlashLabel(syncStatus), fg)642            SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {643                if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()644            }645        }646    }647648    if (pendingVouched.isNotEmpty()) {649        SubLabel("PENDING DEVICES", fg)650        pendingVouched.forEach { dev ->651            RowLine(fg) {652                Column(Modifier.weight(1f)) {653                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)654                    Text(655                        "Was added by another of your devices. Allow it to sync with this vault?",656                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,657                        modifier = Modifier.padding(top = 2.dp),658                    )659                }660                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {661                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }662                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }663                }664            }665        }666    }667668    SubLabel("RECOVERY", fg)669    if (rotateConfirmOpen) {670        Hint("This will disconnect all paired devices and your 12 recovery words will change. You'll be shown the new words next.", peachColors.Breach)671        rotateError?.let {672            Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))673        }674        Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {675            SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }676            SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {677                if (rotating) return@SubtleButton678                rotating = true679                rotateError = null680                scope.launch {681                    val ok = syncVm.rotateSyncIdentity()682                    rotating = false683                    if (ok) {684                        rotateConfirmOpen = false685                        onNavigate(PeachNav.RecoverySetup)686                    } else {687                        rotateError = "Rotation failed. Unlock your real vault and try again."688                    }689                }690            }691        }692    } else {693        RowLine(fg) {694            Column(Modifier.weight(1f)) {695                Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)696                Text(697                    "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.",698                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,699                    modifier = Modifier.padding(top = 2.dp),700                )701            }702            SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }703        }704    }705706    Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)707}708709private fun deviceTypeLabel(type: String?): String = when (type) {710    "android" -> "Android"711    "phone" -> "Phone"712    "tablet" -> "Tablet"713    "desktop" -> "Desktop"714    "browser" -> "Browser"715    else -> "Device"716}717718private fun syncTimeLabel(lastSyncedAt: Long?): String {719    if (lastSyncedAt == null) return "never synced"720    val diff = System.currentTimeMillis() - lastSyncedAt721    if (diff < 60_000) return "just now"722    if (diff < 3_600_000) return "${diff / 60_000}m ago"723    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"724    return "${diff / 86_400_000}d ago"725}726727private fun isSyncStale(lastSyncedAt: Long?): Boolean =728    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L729730private fun syncFlashLabel(status: SyncStatus): String = when (status) {731    SyncStatus.Idle -> ""732    SyncStatus.Syncing -> "syncing..."733    SyncStatus.Offline -> "offline"734    is SyncStatus.Error -> status.message735    is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}"736}737738/* ──────────────────────────────  Shortcuts  ──────────────────────────────── */739740@Composable741private fun ShortcutsBody(fg: Color) {742    val bindings = listOf(743        "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵",744        "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/",745    )746    SubLabel("KEYBINDINGS", fg, top = 0.dp)747    Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg)748    Spacer(Modifier.height(8.dp))749    bindings.forEach { (label, key) ->750        RowLine(fg) {751            Name(label, fg)752            Box(753                Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f))754                    .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp))755                    .padding(horizontal = 7.dp, vertical = 2.dp),756            ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) }757        }758    }759}760761/* ────────────────────────────────  Pro  ─────────────────────────────────── */762763@Composable764private fun ProBody(fg: Color, vm: SettingsViewModel) {765    val ctx = LocalContext.current766    val isPro by vm.isPro.collectAsState()767    if (isPro) {768        ProActiveBody(fg, vm)769        return770    }771    val features = listOf(772        "Peach Sync" to "Encrypted · peer-to-peer",773        "Peach Codex" to "Restore from paper",774        "Passkeys" to "Zero-knowledge WebAuthn",775        "Advanced Vault Health" to "Reuse · age · exposure",776        "Canary entries" to "Detects vault compromise",777        "Duress vault" to "Decoy under coercion",778    )779    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)780    Text(781        "Everything, everywhere.",782        modifier = Modifier.padding(top = 8.dp),783        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,784    )785    Text(786        "One key unlocks every Pro feature — for the lifetime of the app.",787        modifier = Modifier.padding(top = 6.dp),788        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,789    )790    Spacer(Modifier.height(12.dp))791    features.forEachIndexed { i, (name, desc) ->792        RowLine(fg) {793            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {794                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))795                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)796            }797            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)798        }799    }800    Spacer(Modifier.height(14.dp))801    Text(802        "$39",803        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,804    )805    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)806    Spacer(Modifier.height(10.dp))807    Box(808        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))809            .background(fg.copy(alpha = 0.16f))810            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))811            .clickable {812                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }813            }814            .padding(vertical = 12.dp),815        contentAlignment = Alignment.Center,816    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }817818    Spacer(Modifier.height(16.dp))819    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)820    var key by remember { mutableStateOf("") }821    val activating by vm.activating.collectAsState()822    val activationError by vm.activationError.collectAsState()823    Box(824        Modifier.fillMaxWidth().padding(top = 6.dp)825            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))826            .padding(horizontal = 12.dp, vertical = 10.dp),827    ) {828        BasicTextField(829            value = key,830            onValueChange = { key = it.uppercase(); vm.clearActivationError() },831            singleLine = true,832            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),833            cursorBrush = SolidColor(fg),834            modifier = Modifier.fillMaxWidth(),835            decorationBox = { inner ->836                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)837                inner()838            },839        )840    }841    if (activationError != null) {842        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))843    }844    Spacer(Modifier.height(8.dp))845    Box(846        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))847            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))848            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))849            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }850            .padding(vertical = 12.dp),851        contentAlignment = Alignment.Center,852    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }853}854855@Composable856private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {857    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")858    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {859        com.peachpasswords.ui.components.PeachMark(size = 60.dp)860    }861    Text(862        "Peach Pro",863        modifier = Modifier.fillMaxWidth(),864        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,865        textAlign = androidx.compose.ui.text.style.TextAlign.Center,866    )867    Text(868        "active · yours forever",869        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),870        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,871        textAlign = androidx.compose.ui.text.style.TextAlign.Center,872    )873    Spacer(Modifier.height(12.dp))874    features.forEachIndexed { i, name ->875        RowLine(fg) {876            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {877                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))878                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)879            }880            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)881        }882    }883    Spacer(Modifier.height(12.dp))884    Box(885        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))886            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))887            .clickable { vm.deactivate() }888            .padding(vertical = 12.dp),889        contentAlignment = Alignment.Center,890    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }891}892893/* ──────────────────────────────  Feedback  ───────────────────────────────── */894895@OptIn(ExperimentalLayoutApi::class)896@Composable897private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {898    val categories = listOf("Bug report", "Feature request", "Question", "Other")899    var category by remember { mutableStateOf(categories[0]) }900    var message by remember { mutableStateOf("") }901    val state by vm.feedbackState.collectAsState()902903    // Reset transient state when the card leaves composition so reopening starts fresh.904    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }905906    if (state is FeedbackState.Sent) {907        Box(908            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))909                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),910        ) {911            Column {912                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)913                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))914            }915        }916        return917    }918919    val sending = state is FeedbackState.Sending920921    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)922    SubLabel("TYPE", fg)923    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {924        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }925    }926    SubLabel("MESSAGE", fg)927    Box(928        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))929            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))930            .padding(10.dp),931    ) {932        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)933        BasicTextField(934            value = message, onValueChange = { message = it },935            enabled = !sending,936            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),937            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),938        )939    }940    Spacer(Modifier.height(8.dp))941942    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {943        val canSend = !sending && message.isNotBlank()944        Box(945            Modifier.clip(RoundedCornerShape(8.dp))946                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))947                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))948                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }949                .padding(horizontal = 12.dp, vertical = 6.dp),950        ) {951            Text(952                if (state is FeedbackState.Error) "Retry" else "Send",953                color = fg.copy(alpha = if (canSend) 1f else 0.4f),954                fontFamily = PeachType.Mono, fontSize = 11.sp,955            )956        }957        if (sending) {958            CircularProgressIndicator(959                color = LocalPeachAccent.current,960                strokeWidth = 2.dp,961                modifier = Modifier.size(16.dp),962            )963        }964    }965966    (state as? FeedbackState.Error)?.let { err ->967        Text(968            err.message,969            modifier = Modifier.padding(top = 8.dp),970            color = peachColors.Breach,971            fontFamily = PeachType.Mono,972            fontSize = 11.sp,973            lineHeight = 15.sp,974        )975    }976977    Hint("Sent anonymously · no account required", fg)978}979980/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */981982@Composable983internal fun DuressSection(fg: Color, vm: SettingsViewModel) {984    val configured by vm.duressConfigured.collectAsState()985    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)986}987988@Composable989private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {990    Text(991        "Duress password is active",992        color = peachColors.HealthOk,993        fontFamily = PeachType.Mono,994        fontSize = 11.sp,995        modifier = Modifier.padding(top = 6.dp),996    )997    Spacer(Modifier.height(8.dp))998    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }999    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1000}10011002@Composable1003private fun DuressForm(fg: Color, vm: SettingsViewModel) {1004    var enabled by remember { mutableStateOf(false) }1005    var password by remember { mutableStateOf("") }1006    var confirm by remember { mutableStateOf("") }1007    var action by remember { mutableStateOf(DuressAction.DECOY) }1008    var error by remember { mutableStateOf<String?>(null) }1009    val scope = rememberCoroutineScope()10101011    RowLine(fg) {1012        Name("Duress password", fg)1013        Toggle(enabled, fg) { on ->1014            enabled = on1015            if (!on) { password = ""; confirm = ""; error = null }1016        }1017    }1018    Text(1019        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1020        color = fg.copy(alpha = 0.7f),1021        fontFamily = PeachType.Mono,1022        fontSize = 10.sp,1023        modifier = Modifier.padding(top = 6.dp),1024    )10251026    AnimatedVisibility(enabled) {1027        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1028            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1029            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }10301031            Text(1032                "When your duress password is entered at the lock screen, Peach will:",1033                color = fg.copy(alpha = 0.7f),1034                fontFamily = PeachType.Mono,1035                fontSize = 10.sp,1036                modifier = Modifier.padding(top = 4.dp),1037            )10381039            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1040            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }10411042            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }10431044            val canSave = password.isNotBlank() && confirm.isNotBlank()1045            DuressSaveButton("Save duress password", fg, canSave) {1046                error = when {1047                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1048                    password != confirm -> "Passwords do not match"1049                    else -> null1050                }1051                if (error == null) scope.launch {1052                    if (vm.saveDuress(password, action)) {1053                        password = ""; confirm = ""; enabled = false1054                    } else {1055                        error = "Couldn't save — the duress passphrase must differ from your real password"1056                    }1057                }1058            }1059        }1060    }1061}10621063@Composable1064private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1065    val accent = LocalPeachAccent.current1066    Row(1067        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1068        verticalAlignment = Alignment.CenterVertically,1069        horizontalArrangement = Arrangement.spacedBy(10.dp),1070    ) {1071        Box(1072            Modifier.size(16.dp).clip(CircleShape)1073                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1074            contentAlignment = Alignment.Center,1075        ) {1076            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1077        }1078        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1079    }1080}10811082@Composable1083private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1084    val accent = LocalPeachAccent.current1085    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1086    Box(1087        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1088            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1089            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1090            .let { if (enabled) it.clickable(onClick = onClick) else it }1091            .padding(vertical = 11.dp),1092        contentAlignment = Alignment.Center,1093    ) {1094        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1095    }1096}10971098@Composable1099private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1100    Box(1101        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1102            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1103            .clickable(onClick = onClick)1104            .padding(vertical = 11.dp),1105        contentAlignment = Alignment.Center,1106    ) {1107        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1108    }1109}