SettingsCardBodies.kt

38.4 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.ui.theme.LocalPeachAccent33import com.peachpasswords.vault.DuressAction34import com.peachpasswords.ui.theme.peachColors35import androidx.compose.runtime.mutableStateOf36import androidx.compose.runtime.remember37import androidx.compose.runtime.rememberCoroutineScope38import androidx.compose.runtime.setValue39import androidx.compose.ui.Alignment40import androidx.compose.ui.Modifier41import androidx.compose.ui.draw.clip42import androidx.compose.ui.graphics.Color43import androidx.compose.ui.graphics.SolidColor44import androidx.compose.ui.platform.LocalContext45import androidx.compose.ui.text.TextStyle46import androidx.compose.ui.text.font.FontStyle47import androidx.compose.ui.text.input.PasswordVisualTransformation48import androidx.compose.ui.unit.Dp49import androidx.compose.ui.unit.dp50import androidx.compose.ui.unit.em51import androidx.compose.ui.unit.sp52import androidx.compose.material3.Text53import com.peachpasswords.ui.PeachNav54import com.peachpasswords.ui.sync.SyncViewModel55import com.peachpasswords.ui.theme.ACCENT_COLORS56import com.peachpasswords.ui.theme.PeachType57import androidx.hilt.navigation.compose.hiltViewModel58import com.peachpasswords.vault.VaultSettings59import kotlinx.coroutines.launch6061private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"6263/**64 * Body content for the active stack card. Everything is tinted with the card's65 * own foreground [fg] (the browser's `currentColor` convention), so each card's66 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,67 * canary, license activation) render their browser visual but defer the action68 * to a later phase — noted inline where that's the case.69 */70@Composable71fun CardBody(72    id: CardId,73    fg: Color,74    vm: SettingsViewModel,75    settings: VaultSettings,76    entriesCount: Int,77    onNavigate: (PeachNav) -> Unit,78) {79    when (id) {80        CardId.Appearance -> AppearanceBody(fg, vm, settings)81        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)82        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)83        CardId.Sync -> SyncBody(fg, onNavigate)84        CardId.Shortcuts -> ShortcutsBody(fg)85        CardId.Pro -> ProBody(fg, vm)86        CardId.Feedback -> FeedbackBody(fg, vm)87    }88}8990/* ─────────────────────────  shared row vocabulary  ───────────────────────── */9192@Composable93private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {94    Text(95        text,96        modifier = Modifier.padding(top = top, bottom = 4.dp),97        color = fg.copy(alpha = 0.58f),98        fontFamily = PeachType.Mono,99        fontSize = 8.sp,100        letterSpacing = 0.22.em,101    )102}103104@Composable105private fun RowLine(fg: Color, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {106    Row(107        Modifier.fillMaxWidth().then(modifier).padding(vertical = 9.dp),108        verticalAlignment = Alignment.CenterVertically,109        horizontalArrangement = Arrangement.SpaceBetween,110        content = content,111    )112    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))113}114115@Composable116private fun RowScope.Name(text: String, fg: Color) {117    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))118}119120@Composable121private fun ValueText(text: String, fg: Color) {122    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)123}124125@Composable126private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {127    Box(128        Modifier.clip(RoundedCornerShape(8.dp))129            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))130            .background(fg.copy(alpha = 0.08f))131            .clickable(onClick = onClick)132            .padding(horizontal = 12.dp, vertical = 6.dp),133    ) {134        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)135    }136}137138@Composable139private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {140    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")141    Box(142        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))143            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))144            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))145            .clickable { onToggle(!on) },146    ) {147        Box(148            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)149                .padding(start = knob - 2.dp)150                .size(14.dp).clip(CircleShape)151                .background(if (on) fg else fg.copy(alpha = 0.58f)),152        )153    }154}155156@Composable157private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {158    Box(159        Modifier.clip(RoundedCornerShape(999.dp))160            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))161            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)162            .clickable(onClick = onClick)163            .padding(horizontal = 12.dp, vertical = 5.dp),164    ) {165        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)166    }167}168169@Composable170private fun ProBadge(fg: Color) {171    Box(172        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))173            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),174    ) {175        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)176    }177}178179@Composable180private fun Hint(text: String, fg: Color) {181    Text(182        text,183        modifier = Modifier.padding(top = 6.dp),184        color = fg.copy(alpha = 0.6f),185        fontFamily = PeachType.Display,186        fontStyle = FontStyle.Italic,187        fontSize = 11.5.sp,188        lineHeight = 16.sp,189    )190}191192/* ──────────────────────────────  Appearance  ─────────────────────────────── */193194@OptIn(ExperimentalLayoutApi::class)195@Composable196private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {197    SubLabel("THEME", fg, top = 0.dp)198    RowLine(fg) {199        Name("Theme", fg)200        // Cycle dark → light → system → dark. The button shows the current mode;201        // tapping advances to the next.202        val (label, next) = when (s.theme) {203            "light" -> "Light" to "system"204            "system" -> "System" to "dark"205            else -> "Dark" to "light"206        }207        SubtleButton(label, fg) { vm.setTheme(next) }208    }209    SubLabel("ACCENT", fg)210    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {211        ACCENT_COLORS.forEach { (name, color) ->212            Box(213                Modifier.size(22.dp).clip(CircleShape).background(color)214                    .border(215                        if (s.accentColor == name) 2.dp else 1.dp,216                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),217                        CircleShape,218                    )219                    .clickable { vm.setAccent(name) },220            )221        }222    }223    SubLabel("TEXT SIZE", fg)224    RowLine(fg) {225        Name("Size", fg)226        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {227            Pill("Default", s.textSize == "medium", fg) { vm.setTextSize("medium") }228            Pill("Large", s.textSize == "large", fg) { vm.setTextSize("large") }229        }230    }231    SubLabel("MOTION", fg)232    RowLine(fg) {233        Name("Reduce motion", fg)234        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }235    }236}237238/* ──────────────────────────────  Protection  ─────────────────────────────── */239240@Composable241private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {242    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")243    val current = s.autoLockMinutes ?: 0244245    SubLabel("LOCKING", fg, top = 0.dp)246    RowLine(fg) {247        Name("Auto-lock after", fg)248        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)249        SubtleButton(lockOptions[idx].second, fg) {250            val next = lockOptions[(idx + 1) % lockOptions.size].first251            vm.setAutoLockMinutes(if (next == 0) 0 else next)252        }253    }254    SubLabel("BIOMETRICS", fg)255    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity256    RowLine(fg) {257        Name("Unlock with biometrics", fg)258        Toggle(s.biometricsEnabled, fg) { on ->259            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()260        }261    }262    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)263    if (bioError != null) {264        Text(265            bioError,266            color = com.peachpasswords.ui.theme.peachColors.Breach,267            fontFamily = PeachType.Mono, fontSize = 10.sp,268            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),269        )270    }271    SubLabel("RECOVERY", fg)272    RowLine(fg) {273        Name("View recovery words", fg)274        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }275    }276    SubLabel("BREACH PROTECTION", fg)277    RowLine(fg) {278        Name("Breach monitoring", fg)279        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }280    }281282    SubLabel("MASTER PASSWORD", fg)283    ChangePasswordRow(fg, vm)284285    SubLabel("DURESS PASSWORD", fg)286    val duressIsPro by vm.isPro.collectAsState()287    if (!duressIsPro) {288        RowLine(fg) {289            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {290                Name("Duress password", fg); ProBadge(fg)291            }292            ValueText("off", fg)293        }294        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)295    } else {296        DuressSection(fg, vm)297    }298}299300@Composable301private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {302    var open by remember { mutableStateOf(false) }303    var current by remember { mutableStateOf("") }304    var next by remember { mutableStateOf("") }305    var confirm by remember { mutableStateOf("") }306    var error by remember { mutableStateOf<String?>(null) }307    var done by remember { mutableStateOf(false) }308    val scope = rememberCoroutineScope()309310    RowLine(fg) {311        Name("Master password", fg)312        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }313    }314    AnimatedVisibility(open) {315        Column(Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {316            PwField("current password", current, fg) { current = it }317            PwField("new password (min 12 chars)", next, fg) { next = it }318            PwField("confirm new password", confirm, fg) { confirm = it }319            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }320            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)321            SubtleButton("save new password", fg) {322                error = when {323                    current.isBlank() -> "enter your current password"324                    next.length < 12 -> "new password must be at least 12 characters"325                    next != confirm -> "new passwords do not match"326                    next == current -> "new password must differ from current"327                    else -> null328                }329                if (error == null) scope.launch {330                    if (vm.changePassword(current, next)) {331                        current = ""; next = ""; confirm = ""; open = false; done = true332                    } else error = "incorrect current password"333                }334            }335        }336    }337}338339@Composable340private fun PwField(placeholder: String, value: String, fg: Color, onChange: (String) -> Unit) {341    Box(342        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))343            .background(Color(0x2E000000))344            .border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))345            .padding(horizontal = 10.dp, vertical = 10.dp),346    ) {347        if (value.isEmpty()) {348            Text(placeholder, color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Mono, fontSize = 12.sp)349        }350        BasicTextField(351            value = value,352            onValueChange = onChange,353            singleLine = true,354            textStyle = TextStyle(color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp),355            cursorBrush = SolidColor(fg),356            visualTransformation = PasswordVisualTransformation(),357            modifier = Modifier.fillMaxWidth(),358        )359    }360}361362/* ────────────────────────────────  Vault  ───────────────────────────────── */363364@Composable365private fun VaultBody(366    fg: Color,367    vm: SettingsViewModel,368    s: VaultSettings,369    entriesCount: Int,370    onNavigate: (PeachNav) -> Unit,371) {372    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")373    val current = s.trashRetentionDays ?: 0374    val isPro by vm.isPro.collectAsState()375    val dedupCount by vm.dedupCount.collectAsState()376    val dedupRunning by vm.dedupRunning.collectAsState()377    val dedupDone by vm.dedupDone.collectAsState()378379    SubLabel("ENTRIES", fg, top = 0.dp)380    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }381    RowLine(fg) {382        Name("Duplicates", fg)383        val done = dedupDone384        if (done != null) {385            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)386        } else {387            val count = dedupCount388            val label = when {389                dedupRunning -> "scanning..."390                count == null -> "scan"391                count == 0 -> "none found"392                else -> "$count found"393            }394            SubtleButton(label, fg) {395                if (count == null || count == 0) vm.scanDuplicates()396            }397        }398    }399    if ((dedupCount ?: 0) > 0 && dedupDone == null) {400        Column(401            Modifier.fillMaxWidth()402                .padding(top = 2.dp, bottom = 8.dp)403                .clip(RoundedCornerShape(8.dp))404                .background(fg.copy(alpha = 0.08f))405                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))406                .padding(10.dp),407            verticalArrangement = Arrangement.spacedBy(8.dp),408        ) {409            val count = dedupCount ?: 0410            Text(411                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",412                color = fg.copy(alpha = 0.82f),413                fontFamily = PeachType.Body,414                fontSize = 11.sp,415                lineHeight = 16.sp,416            )417            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {418                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }419                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }420            }421        }422    }423424    SubLabel("MAINTENANCE", fg)425    RowLine(fg) {426        Name("Trash auto-delete", fg)427        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)428        SubtleButton(trashOptions[idx].second, fg) {429            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)430        }431    }432    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }433    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }434435    SubLabel("DATA", fg)436    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }437    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }438    RowLine(fg) {439        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }440        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }441    }442    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }443444    var advancedOpen by remember { mutableStateOf(false) }445    SubLabel("ADVANCED", fg)446    RowLine(fg) {447        Name("Advanced", fg)448        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }449    }450    if (advancedOpen) {451        RowLine(fg) {452            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)453            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }454        }455        RowLine(fg) {456            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)457            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }458        }459    }460}461462/* ────────────────────────────────  Sync  ────────────────────────────────── */463464@Composable465private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {466    val syncVm: SyncViewModel = hiltViewModel()467    val pairedDevices by syncVm.pairedDevices.collectAsState()468469    SubLabel("PAIRING", fg, top = 0.dp)470    RowLine(fg) {471        Column(Modifier.weight(1f)) {472            Row(verticalAlignment = Alignment.CenterVertically) {473                Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)474            }475            Text(476                "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.",477                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,478                modifier = Modifier.padding(top = 2.dp),479            )480        }481    }482    // The whole row is the tap target — no separate "open" button (browser483    // sync-hub parity: every settings row is tappable, the chevron signals it).484    RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) {485        Name("Sync hub", fg)486        Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)487    }488    RowLine(fg) {489        Column(Modifier.weight(1f)) {490            Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)491            Text(492                "Add a phone, another browser, or your desktop sync helper.",493                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,494                modifier = Modifier.padding(top = 2.dp),495            )496        }497        SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) }498    }499500    SubLabel("PAIRED DEVICES", fg)501    if (pairedDevices.isEmpty()) {502        Text(503            "No paired devices yet.",504            color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp,505            modifier = Modifier.padding(top = 2.dp),506        )507    } else {508        pairedDevices.forEach { d ->509            RowLine(fg) {510                Name(d.deviceName ?: d.deviceId, fg)511                ValueText(if (d.lastSyncedAt != null) "synced" else "never", fg)512            }513        }514        RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { syncVm.syncNow() } }515    }516517    Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)518}519520/* ──────────────────────────────  Shortcuts  ──────────────────────────────── */521522@Composable523private fun ShortcutsBody(fg: Color) {524    val bindings = listOf(525        "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵",526        "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/",527    )528    SubLabel("KEYBINDINGS", fg, top = 0.dp)529    Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg)530    Spacer(Modifier.height(8.dp))531    bindings.forEach { (label, key) ->532        RowLine(fg) {533            Name(label, fg)534            Box(535                Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f))536                    .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp))537                    .padding(horizontal = 7.dp, vertical = 2.dp),538            ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) }539        }540    }541}542543/* ────────────────────────────────  Pro  ─────────────────────────────────── */544545@Composable546private fun ProBody(fg: Color, vm: SettingsViewModel) {547    val ctx = LocalContext.current548    val isPro by vm.isPro.collectAsState()549    if (isPro) {550        ProActiveBody(fg, vm)551        return552    }553    val features = listOf(554        "Peach Sync" to "Encrypted · peer-to-peer",555        "Peach Codex" to "Restore from paper",556        "Passkeys" to "Zero-knowledge WebAuthn",557        "Advanced Vault Health" to "Reuse · age · exposure",558        "Canary entries" to "Detects vault compromise",559        "Duress vault" to "Decoy under coercion",560    )561    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)562    Text(563        "Everything, everywhere.",564        modifier = Modifier.padding(top = 8.dp),565        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,566    )567    Text(568        "One key unlocks every Pro feature — for the lifetime of the app.",569        modifier = Modifier.padding(top = 6.dp),570        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,571    )572    Spacer(Modifier.height(12.dp))573    features.forEachIndexed { i, (name, desc) ->574        RowLine(fg) {575            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {576                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))577                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)578            }579            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)580        }581    }582    Spacer(Modifier.height(14.dp))583    Text(584        "$39",585        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,586    )587    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)588    Spacer(Modifier.height(10.dp))589    Box(590        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))591            .background(fg.copy(alpha = 0.16f))592            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))593            .clickable {594                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }595            }596            .padding(vertical = 12.dp),597        contentAlignment = Alignment.Center,598    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }599600    Spacer(Modifier.height(16.dp))601    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)602    var key by remember { mutableStateOf("") }603    val activating by vm.activating.collectAsState()604    val activationError by vm.activationError.collectAsState()605    Box(606        Modifier.fillMaxWidth().padding(top = 6.dp)607            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))608            .padding(horizontal = 12.dp, vertical = 10.dp),609    ) {610        BasicTextField(611            value = key,612            onValueChange = { key = it.uppercase(); vm.clearActivationError() },613            singleLine = true,614            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),615            cursorBrush = SolidColor(fg),616            modifier = Modifier.fillMaxWidth(),617            decorationBox = { inner ->618                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)619                inner()620            },621        )622    }623    if (activationError != null) {624        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))625    }626    Spacer(Modifier.height(8.dp))627    Box(628        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))629            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))630            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))631            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }632            .padding(vertical = 12.dp),633        contentAlignment = Alignment.Center,634    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }635}636637@Composable638private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {639    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")640    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {641        com.peachpasswords.ui.components.PeachMark(size = 60.dp)642    }643    Text(644        "Peach Pro",645        modifier = Modifier.fillMaxWidth(),646        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,647        textAlign = androidx.compose.ui.text.style.TextAlign.Center,648    )649    Text(650        "active · yours forever",651        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),652        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,653        textAlign = androidx.compose.ui.text.style.TextAlign.Center,654    )655    Spacer(Modifier.height(12.dp))656    features.forEachIndexed { i, name ->657        RowLine(fg) {658            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {659                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))660                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)661            }662            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)663        }664    }665    Spacer(Modifier.height(12.dp))666    Box(667        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))668            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))669            .clickable { vm.deactivate() }670            .padding(vertical = 12.dp),671        contentAlignment = Alignment.Center,672    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }673}674675/* ──────────────────────────────  Feedback  ───────────────────────────────── */676677@OptIn(ExperimentalLayoutApi::class)678@Composable679private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {680    val categories = listOf("Bug report", "Feature request", "Question", "Other")681    var category by remember { mutableStateOf(categories[0]) }682    var message by remember { mutableStateOf("") }683    val state by vm.feedbackState.collectAsState()684685    // Reset transient state when the card leaves composition so reopening starts fresh.686    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }687688    if (state is FeedbackState.Sent) {689        Box(690            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))691                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),692        ) {693            Column {694                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)695                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))696            }697        }698        return699    }700701    val sending = state is FeedbackState.Sending702703    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)704    SubLabel("TYPE", fg)705    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {706        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }707    }708    SubLabel("MESSAGE", fg)709    Box(710        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))711            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))712            .padding(10.dp),713    ) {714        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)715        BasicTextField(716            value = message, onValueChange = { message = it },717            enabled = !sending,718            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),719            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),720        )721    }722    Spacer(Modifier.height(8.dp))723724    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {725        val canSend = !sending && message.isNotBlank()726        Box(727            Modifier.clip(RoundedCornerShape(8.dp))728                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))729                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))730                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }731                .padding(horizontal = 12.dp, vertical = 6.dp),732        ) {733            Text(734                if (state is FeedbackState.Error) "Retry" else "Send",735                color = fg.copy(alpha = if (canSend) 1f else 0.4f),736                fontFamily = PeachType.Mono, fontSize = 11.sp,737            )738        }739        if (sending) {740            CircularProgressIndicator(741                color = LocalPeachAccent.current,742                strokeWidth = 2.dp,743                modifier = Modifier.size(16.dp),744            )745        }746    }747748    (state as? FeedbackState.Error)?.let { err ->749        Text(750            err.message,751            modifier = Modifier.padding(top = 8.dp),752            color = peachColors.Breach,753            fontFamily = PeachType.Mono,754            fontSize = 11.sp,755            lineHeight = 15.sp,756        )757    }758759    Hint("Sent anonymously · no account required", fg)760}761762/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */763764@Composable765internal fun DuressSection(fg: Color, vm: SettingsViewModel) {766    val configured by vm.duressConfigured.collectAsState()767    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)768}769770@Composable771private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {772    Text(773        "Duress password is active",774        color = peachColors.HealthOk,775        fontFamily = PeachType.Mono,776        fontSize = 11.sp,777        modifier = Modifier.padding(top = 6.dp),778    )779    Spacer(Modifier.height(8.dp))780    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }781    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)782}783784@Composable785private fun DuressForm(fg: Color, vm: SettingsViewModel) {786    var enabled by remember { mutableStateOf(false) }787    var password by remember { mutableStateOf("") }788    var confirm by remember { mutableStateOf("") }789    var action by remember { mutableStateOf(DuressAction.DECOY) }790    var error by remember { mutableStateOf<String?>(null) }791    val scope = rememberCoroutineScope()792793    RowLine(fg) {794        Name("Duress password", fg)795        Toggle(enabled, fg) { on ->796            enabled = on797            if (!on) { password = ""; confirm = ""; error = null }798        }799    }800    Text(801        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",802        color = fg.copy(alpha = 0.7f),803        fontFamily = PeachType.Mono,804        fontSize = 10.sp,805        modifier = Modifier.padding(top = 6.dp),806    )807808    AnimatedVisibility(enabled) {809        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {810            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }811            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }812813            Text(814                "When your duress password is entered at the lock screen, Peach will:",815                color = fg.copy(alpha = 0.7f),816                fontFamily = PeachType.Mono,817                fontSize = 10.sp,818                modifier = Modifier.padding(top = 4.dp),819            )820821            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }822            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }823824            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }825826            val canSave = password.isNotBlank() && confirm.isNotBlank()827            DuressSaveButton("Save duress password", fg, canSave) {828                error = when {829                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"830                    password != confirm -> "Passwords do not match"831                    else -> null832                }833                if (error == null) scope.launch {834                    if (vm.saveDuress(password, action)) {835                        password = ""; confirm = ""; enabled = false836                    } else {837                        error = "Couldn't save — the duress passphrase must differ from your real password"838                    }839                }840            }841        }842    }843}844845@Composable846private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {847    val accent = LocalPeachAccent.current848    Row(849        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),850        verticalAlignment = Alignment.CenterVertically,851        horizontalArrangement = Arrangement.spacedBy(10.dp),852    ) {853        Box(854            Modifier.size(16.dp).clip(CircleShape)855                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),856            contentAlignment = Alignment.Center,857        ) {858            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))859        }860        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)861    }862}863864@Composable865private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {866    val accent = LocalPeachAccent.current867    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)868    Box(869        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))870            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))871            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))872            .let { if (enabled) it.clickable(onClick = onClick) else it }873            .padding(vertical = 11.dp),874        contentAlignment = Alignment.Center,875    ) {876        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)877    }878}879880@Composable881private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {882    Box(883        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))884            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))885            .clickable(onClick = onClick)886            .padding(vertical = 11.dp),887        contentAlignment = Alignment.Center,888    ) {889        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)890    }891}