SettingsCardBodies.kt

36.5 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()375376    SubLabel("ENTRIES", fg, top = 0.dp)377    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }378379    SubLabel("MAINTENANCE", fg)380    RowLine(fg) {381        Name("Trash auto-delete", fg)382        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)383        SubtleButton(trashOptions[idx].second, fg) {384            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)385        }386    }387    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }388    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }389390    SubLabel("DATA", fg)391    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }392    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }393    RowLine(fg) {394        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }395        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }396    }397    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }398399    var advancedOpen by remember { mutableStateOf(false) }400    SubLabel("ADVANCED", fg)401    RowLine(fg) {402        Name("Advanced", fg)403        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }404    }405    if (advancedOpen) {406        RowLine(fg) {407            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)408            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }409        }410        RowLine(fg) {411            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)412            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }413        }414    }415}416417/* ────────────────────────────────  Sync  ────────────────────────────────── */418419@Composable420private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {421    val syncVm: SyncViewModel = hiltViewModel()422    val pairedDevices by syncVm.pairedDevices.collectAsState()423424    SubLabel("PAIRING", fg, top = 0.dp)425    RowLine(fg) {426        Column(Modifier.weight(1f)) {427            Row(verticalAlignment = Alignment.CenterVertically) {428                Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)429            }430            Text(431                "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.",432                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,433                modifier = Modifier.padding(top = 2.dp),434            )435        }436    }437    // The whole row is the tap target — no separate "open" button (browser438    // sync-hub parity: every settings row is tappable, the chevron signals it).439    RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) {440        Name("Sync hub", fg)441        Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)442    }443    RowLine(fg) {444        Column(Modifier.weight(1f)) {445            Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)446            Text(447                "Add a phone, another browser, or your desktop sync helper.",448                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,449                modifier = Modifier.padding(top = 2.dp),450            )451        }452        SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) }453    }454455    SubLabel("PAIRED DEVICES", fg)456    if (pairedDevices.isEmpty()) {457        Text(458            "No paired devices yet.",459            color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp,460            modifier = Modifier.padding(top = 2.dp),461        )462    } else {463        pairedDevices.forEach { d ->464            RowLine(fg) {465                Name(d.deviceName ?: d.deviceId, fg)466                ValueText(if (d.lastSyncedAt != null) "synced" else "never", fg)467            }468        }469        RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { syncVm.syncNow() } }470    }471472    Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)473}474475/* ──────────────────────────────  Shortcuts  ──────────────────────────────── */476477@Composable478private fun ShortcutsBody(fg: Color) {479    val bindings = listOf(480        "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵",481        "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/",482    )483    SubLabel("KEYBINDINGS", fg, top = 0.dp)484    Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg)485    Spacer(Modifier.height(8.dp))486    bindings.forEach { (label, key) ->487        RowLine(fg) {488            Name(label, fg)489            Box(490                Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f))491                    .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp))492                    .padding(horizontal = 7.dp, vertical = 2.dp),493            ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) }494        }495    }496}497498/* ────────────────────────────────  Pro  ─────────────────────────────────── */499500@Composable501private fun ProBody(fg: Color, vm: SettingsViewModel) {502    val ctx = LocalContext.current503    val isPro by vm.isPro.collectAsState()504    if (isPro) {505        ProActiveBody(fg, vm)506        return507    }508    val features = listOf(509        "Peach Sync" to "Encrypted · peer-to-peer",510        "Peach Codex" to "Restore from paper",511        "Passkeys" to "Zero-knowledge WebAuthn",512        "Advanced Vault Health" to "Reuse · age · exposure",513        "Canary entries" to "Detects vault compromise",514        "Duress vault" to "Decoy under coercion",515    )516    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)517    Text(518        "Everything, everywhere.",519        modifier = Modifier.padding(top = 8.dp),520        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,521    )522    Text(523        "One key unlocks every Pro feature — for the lifetime of the app.",524        modifier = Modifier.padding(top = 6.dp),525        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,526    )527    Spacer(Modifier.height(12.dp))528    features.forEachIndexed { i, (name, desc) ->529        RowLine(fg) {530            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {531                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))532                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)533            }534            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)535        }536    }537    Spacer(Modifier.height(14.dp))538    Text(539        "$39",540        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,541    )542    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)543    Spacer(Modifier.height(10.dp))544    Box(545        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))546            .background(fg.copy(alpha = 0.16f))547            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))548            .clickable {549                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }550            }551            .padding(vertical = 12.dp),552        contentAlignment = Alignment.Center,553    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }554555    Spacer(Modifier.height(16.dp))556    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)557    var key by remember { mutableStateOf("") }558    val activating by vm.activating.collectAsState()559    val activationError by vm.activationError.collectAsState()560    Box(561        Modifier.fillMaxWidth().padding(top = 6.dp)562            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))563            .padding(horizontal = 12.dp, vertical = 10.dp),564    ) {565        BasicTextField(566            value = key,567            onValueChange = { key = it.uppercase(); vm.clearActivationError() },568            singleLine = true,569            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),570            cursorBrush = SolidColor(fg),571            modifier = Modifier.fillMaxWidth(),572            decorationBox = { inner ->573                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)574                inner()575            },576        )577    }578    if (activationError != null) {579        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))580    }581    Spacer(Modifier.height(8.dp))582    Box(583        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))584            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))585            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))586            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }587            .padding(vertical = 12.dp),588        contentAlignment = Alignment.Center,589    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }590}591592@Composable593private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {594    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")595    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {596        com.peachpasswords.ui.components.PeachMark(size = 60.dp)597    }598    Text(599        "Peach Pro",600        modifier = Modifier.fillMaxWidth(),601        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,602        textAlign = androidx.compose.ui.text.style.TextAlign.Center,603    )604    Text(605        "active · yours forever",606        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),607        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,608        textAlign = androidx.compose.ui.text.style.TextAlign.Center,609    )610    Spacer(Modifier.height(12.dp))611    features.forEachIndexed { i, name ->612        RowLine(fg) {613            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {614                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))615                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)616            }617            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)618        }619    }620    Spacer(Modifier.height(12.dp))621    Box(622        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))623            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))624            .clickable { vm.deactivate() }625            .padding(vertical = 12.dp),626        contentAlignment = Alignment.Center,627    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }628}629630/* ──────────────────────────────  Feedback  ───────────────────────────────── */631632@OptIn(ExperimentalLayoutApi::class)633@Composable634private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {635    val categories = listOf("Bug report", "Feature request", "Question", "Other")636    var category by remember { mutableStateOf(categories[0]) }637    var message by remember { mutableStateOf("") }638    val state by vm.feedbackState.collectAsState()639640    // Reset transient state when the card leaves composition so reopening starts fresh.641    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }642643    if (state is FeedbackState.Sent) {644        Box(645            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))646                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),647        ) {648            Column {649                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)650                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))651            }652        }653        return654    }655656    val sending = state is FeedbackState.Sending657658    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)659    SubLabel("TYPE", fg)660    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {661        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }662    }663    SubLabel("MESSAGE", fg)664    Box(665        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))666            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))667            .padding(10.dp),668    ) {669        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)670        BasicTextField(671            value = message, onValueChange = { message = it },672            enabled = !sending,673            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),674            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),675        )676    }677    Spacer(Modifier.height(8.dp))678679    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {680        val canSend = !sending && message.isNotBlank()681        Box(682            Modifier.clip(RoundedCornerShape(8.dp))683                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))684                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))685                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }686                .padding(horizontal = 12.dp, vertical = 6.dp),687        ) {688            Text(689                if (state is FeedbackState.Error) "Retry" else "Send",690                color = fg.copy(alpha = if (canSend) 1f else 0.4f),691                fontFamily = PeachType.Mono, fontSize = 11.sp,692            )693        }694        if (sending) {695            CircularProgressIndicator(696                color = LocalPeachAccent.current,697                strokeWidth = 2.dp,698                modifier = Modifier.size(16.dp),699            )700        }701    }702703    (state as? FeedbackState.Error)?.let { err ->704        Text(705            err.message,706            modifier = Modifier.padding(top = 8.dp),707            color = peachColors.Breach,708            fontFamily = PeachType.Mono,709            fontSize = 11.sp,710            lineHeight = 15.sp,711        )712    }713714    Hint("Sent anonymously · no account required", fg)715}716717/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */718719@Composable720internal fun DuressSection(fg: Color, vm: SettingsViewModel) {721    val configured by vm.duressConfigured.collectAsState()722    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)723}724725@Composable726private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {727    Text(728        "Duress password is active",729        color = peachColors.HealthOk,730        fontFamily = PeachType.Mono,731        fontSize = 11.sp,732        modifier = Modifier.padding(top = 6.dp),733    )734    Spacer(Modifier.height(8.dp))735    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }736    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)737}738739@Composable740private fun DuressForm(fg: Color, vm: SettingsViewModel) {741    var enabled by remember { mutableStateOf(false) }742    var password by remember { mutableStateOf("") }743    var confirm by remember { mutableStateOf("") }744    var action by remember { mutableStateOf(DuressAction.DECOY) }745    var error by remember { mutableStateOf<String?>(null) }746    val scope = rememberCoroutineScope()747748    RowLine(fg) {749        Name("Duress password", fg)750        Toggle(enabled, fg) { on ->751            enabled = on752            if (!on) { password = ""; confirm = ""; error = null }753        }754    }755    Text(756        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",757        color = fg.copy(alpha = 0.7f),758        fontFamily = PeachType.Mono,759        fontSize = 10.sp,760        modifier = Modifier.padding(top = 6.dp),761    )762763    AnimatedVisibility(enabled) {764        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {765            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }766            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }767768            Text(769                "When your duress password is entered at the lock screen, Peach will:",770                color = fg.copy(alpha = 0.7f),771                fontFamily = PeachType.Mono,772                fontSize = 10.sp,773                modifier = Modifier.padding(top = 4.dp),774            )775776            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }777            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }778779            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }780781            val canSave = password.isNotBlank() && confirm.isNotBlank()782            DuressSaveButton("Save duress password", fg, canSave) {783                error = when {784                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"785                    password != confirm -> "Passwords do not match"786                    else -> null787                }788                if (error == null) scope.launch {789                    if (vm.saveDuress(password, action)) {790                        password = ""; confirm = ""; enabled = false791                    } else {792                        error = "Couldn't save — the duress passphrase must differ from your real password"793                    }794                }795            }796        }797    }798}799800@Composable801private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {802    val accent = LocalPeachAccent.current803    Row(804        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),805        verticalAlignment = Alignment.CenterVertically,806        horizontalArrangement = Arrangement.spacedBy(10.dp),807    ) {808        Box(809            Modifier.size(16.dp).clip(CircleShape)810                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),811            contentAlignment = Alignment.Center,812        ) {813            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))814        }815        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)816    }817}818819@Composable820private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {821    val accent = LocalPeachAccent.current822    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)823    Box(824        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))825            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))826            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))827            .let { if (enabled) it.clickable(onClick = onClick) else it }828            .padding(vertical = 11.dp),829        contentAlignment = Alignment.Center,830    ) {831        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)832    }833}834835@Composable836private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {837    Box(838        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))839            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))840            .clickable(onClick = onClick)841            .padding(vertical = 11.dp),842        contentAlignment = Alignment.Center,843    ) {844        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)845    }846}