SettingsCardBodies.kt

58.3 KB · raw

1package com.peachpasswords.ui.settings23import android.content.Intent4import android.net.Uri5import androidx.compose.animation.AnimatedVisibility6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.FlowRow14import androidx.compose.foundation.layout.ExperimentalLayoutApi15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.RowScope17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.shape.CircleShape24import androidx.compose.foundation.shape.RoundedCornerShape25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.runtime.Composable27import androidx.compose.material3.CircularProgressIndicator28import androidx.compose.runtime.DisposableEffect29import androidx.compose.runtime.collectAsState30import androidx.compose.runtime.getValue31import com.peachpasswords.feedback.FeedbackState32import com.peachpasswords.shared.PeachClipboard33import com.peachpasswords.sync.SyncMode34import com.peachpasswords.sync.SyncStatus35import com.peachpasswords.ui.theme.LocalPeachAccent36import com.peachpasswords.vault.DuressAction37import com.peachpasswords.ui.theme.peachColors38import androidx.compose.runtime.mutableStateOf39import androidx.compose.runtime.remember40import androidx.compose.runtime.rememberCoroutineScope41import androidx.compose.runtime.setValue42import androidx.compose.ui.Alignment43import androidx.compose.ui.Modifier44import androidx.compose.ui.draw.clip45import androidx.compose.ui.graphics.Color46import androidx.compose.ui.graphics.SolidColor47import androidx.compose.ui.platform.LocalContext48import androidx.compose.ui.text.TextStyle49import androidx.compose.ui.text.font.FontStyle50import androidx.compose.ui.text.input.PasswordVisualTransformation51import androidx.compose.ui.unit.Dp52import androidx.compose.ui.unit.dp53import androidx.compose.ui.unit.em54import androidx.compose.ui.unit.sp55import androidx.compose.material3.Text56import com.peachpasswords.BuildConfig57import com.peachpasswords.autofill.AutofillEnablement58import com.peachpasswords.ui.PeachNav59import com.peachpasswords.ui.components.PeachEmptyOrEndState60import com.peachpasswords.ui.components.PeachField61import com.peachpasswords.ui.sync.SyncViewModel62import com.peachpasswords.ui.theme.ACCENT_COLORS63import com.peachpasswords.ui.theme.PeachSpacing64import com.peachpasswords.ui.theme.PeachType65import com.peachpasswords.ui.updates.UpdatePhase66import com.peachpasswords.ui.updates.UpdateViewModel67import androidx.hilt.navigation.compose.hiltViewModel68import com.peachpasswords.vault.VaultSettings69import com.peachpasswords.vault.VaultSnapshot70import kotlinx.coroutines.launch71import kotlinx.coroutines.delay72import java.text.SimpleDateFormat73import java.util.Date74import java.util.Locale7576private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"7778/**79 * Body content for the active stack card. Everything is tinted with the card's80 * own foreground [fg] (the browser's `currentColor` convention), so each card's81 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,82 * canary, license activation) render their browser visual but defer the action83 * to a later phase — noted inline where that's the case.84 */85@Composable86fun CardBody(87    id: CardId,88    fg: Color,89    vm: SettingsViewModel,90    settings: VaultSettings,91    entriesCount: Int,92    onNavigate: (PeachNav) -> Unit,93) {94    when (id) {95        CardId.Appearance -> AppearanceBody(fg, vm, settings)96        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)97        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)98        CardId.Sync -> SyncBody(fg, onNavigate)99        CardId.Pro -> ProBody(fg, vm)100        CardId.Feedback -> {101            // The closest thing Peach has to an About surface, and where the brief102            // put the update entry point: version first, then the feedback form.103            AboutUpdatesBody(fg, onNavigate)104            FeedbackBody(fg, vm)105        }106    }107}108109/* ────────────────────────────  about / updates  ─────────────────────────── */110111@Composable112private fun AboutUpdatesBody(fg: Color, onNavigate: (PeachNav) -> Unit) {113    val vm: UpdateViewModel = hiltViewModel()114    val available by vm.available.collectAsState()115    val state by vm.state.collectAsState()116117    SubLabel("THIS BUILD", fg, top = 0.dp)118    RowLine(fg) {119        Name("Version", fg)120        ValueText(BuildConfig.VERSION_NAME, fg)121    }122    RowLine(fg) {123        Column(Modifier.weight(1f)) {124            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)125            Text(126                when {127                    available != null -> "v${available?.version} is available"128                    state.phase == UpdatePhase.Checking -> "Checking…"129                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"130                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"131                    else -> "Peach checks once a day and tells you when there's a new version."132                },133                color = fg.copy(alpha = 0.7f),134                fontFamily = PeachType.Body,135                fontSize = 11.sp,136                lineHeight = 16.sp,137                modifier = Modifier.padding(top = 2.dp),138            )139        }140        if (available != null) {141            SubtleButton("update", fg) { onNavigate(PeachNav.Update) }142        } else {143            SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {144                vm.check()145            }146        }147    }148}149150/* ─────────────────────────  shared row vocabulary  ───────────────────────── */151152@Composable153private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {154    Text(155        text,156        modifier = Modifier.padding(top = top, bottom = 4.dp),157        color = fg.copy(alpha = 0.58f),158        fontFamily = PeachType.Mono,159        fontSize = 8.sp,160        letterSpacing = 0.22.em,161    )162}163164@Composable165private fun RowLine(166    fg: Color,167    modifier: Modifier = Modifier,168    verticalPadding: Dp = 9.dp,169    content: @Composable RowScope.() -> Unit,170) {171    Row(172        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),173        verticalAlignment = Alignment.CenterVertically,174        horizontalArrangement = Arrangement.SpaceBetween,175        content = content,176    )177    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))178}179180@Composable181private fun RowScope.Name(text: String, fg: Color) {182    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))183}184185@Composable186private fun ValueText(text: String, fg: Color) {187    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)188}189190@Composable191private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {192    Box(193        Modifier.clip(RoundedCornerShape(8.dp))194            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))195            .background(fg.copy(alpha = 0.08f))196            .clickable(onClick = onClick)197            .padding(horizontal = 12.dp, vertical = 6.dp),198    ) {199        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)200    }201}202203@Composable204private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {205    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")206    Box(207        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))208            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))209            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))210            .clickable { onToggle(!on) },211    ) {212        Box(213            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)214                .padding(start = knob - 2.dp)215                .size(14.dp).clip(CircleShape)216                .background(if (on) fg else fg.copy(alpha = 0.58f)),217        )218    }219}220221@Composable222private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {223    Box(224        Modifier.clip(RoundedCornerShape(999.dp))225            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))226            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)227            .clickable(onClick = onClick)228            .padding(horizontal = 12.dp, vertical = 5.dp),229    ) {230        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)231    }232}233234@Composable235private fun ProBadge(fg: Color) {236    Box(237        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))238            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),239    ) {240        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)241    }242}243244@Composable245private fun Hint(text: String, fg: Color) {246    Text(247        text,248        modifier = Modifier.padding(top = 6.dp),249        color = fg.copy(alpha = 0.6f),250        fontFamily = PeachType.Display,251        fontStyle = FontStyle.Italic,252        fontSize = 11.5.sp,253        lineHeight = 16.sp,254    )255}256257/* ──────────────────────────────  Appearance  ─────────────────────────────── */258259@OptIn(ExperimentalLayoutApi::class)260@Composable261private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {262    SubLabel("ACCENT", fg, top = 0.dp)263    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {264        ACCENT_COLORS.forEach { (name, color) ->265            Box(266                Modifier.size(22.dp).clip(CircleShape).background(color)267                    .border(268                        if (s.accentColor == name) 2.dp else 1.dp,269                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),270                        CircleShape,271                    )272                    .clickable { vm.setAccent(name) },273            )274        }275    }276    SubLabel("MOTION", fg)277    RowLine(fg) {278        Name("Reduce motion", fg)279        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }280    }281}282283/* ──────────────────────────────  Protection  ─────────────────────────────── */284285@Composable286private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {287    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")288    val current = s.autoLockMinutes ?: 0289290    SubLabel("LOCKING", fg, top = 0.dp)291    RowLine(fg) {292        Name("Auto-lock after", fg)293        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)294        SubtleButton(lockOptions[idx].second, fg) {295            val next = lockOptions[(idx + 1) % lockOptions.size].first296            vm.setAutoLockMinutes(if (next == 0) 0 else next)297        }298    }299    SubLabel("BIOMETRICS", fg)300    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity301    RowLine(fg) {302        Name("Unlock with biometrics", fg)303        Toggle(s.biometricsEnabled, fg) { on ->304            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()305        }306    }307    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)308    if (bioError != null) {309        Text(310            bioError,311            color = com.peachpasswords.ui.theme.peachColors.Breach,312            fontFamily = PeachType.Mono, fontSize = 10.sp,313            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),314        )315    }316    SubLabel("AUTOFILL", fg)317    run {318        // Read live rather than cached: the user leaves for system settings and319        // comes back, so a remembered value would show the old answer.320        val ctx = LocalContext.current321        val enabled = AutofillEnablement.isEnabled(ctx)322        RowLine(fg) {323            Column(Modifier.weight(1f)) {324                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)325                Text(326                    if (enabled) {327                        "Peach is your autofill service."328                    } else {329                        "Android decides which app fills passwords. Turn Peach on in system settings."330                    },331                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,332                    modifier = Modifier.padding(top = 2.dp),333                )334            }335            if (!enabled) {336                SubtleButton("enable", fg) {337                    // Straight to the autofill picker with Peach preselected;338                    // some OEM builds ship no handler, so fall back rather than339                    // dead-ending on a button that does nothing.340                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }341                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }342                }343            } else {344                ValueText("on", fg)345            }346        }347    }348349    SubLabel("PASSKEYS", fg)350    RowLine(fg) {351        Column(Modifier.weight(1f)) {352            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)353            Text(354                "Peach can handle passkey sign-in and creation through Android Credential Manager.",355                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,356                modifier = Modifier.padding(top = 2.dp),357            )358        }359        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }360    }361    RowLine(fg) {362        Name("Status", fg)363        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)364    }365    SubLabel("RECOVERY", fg)366    RowLine(fg) {367        Column(Modifier.weight(1f)) {368            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)369            Text(370                "Keep the paper copy current before rotating sync identity or replacing devices.",371                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,372                modifier = Modifier.padding(top = 2.dp),373            )374        }375        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }376    }377    SubLabel("BREACH PROTECTION", fg)378    val checkingBreaches by vm.checkingBreaches.collectAsState()379    val breachResult by vm.breachResult.collectAsState()380    RowLine(fg) {381        Name("Breach monitoring", fg)382        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }383    }384    RowLine(fg) {385        Name("Check all passwords", fg)386        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }387    }388    breachResult?.let { result ->389        RowLine(fg) {390            Name("Last check", fg)391            ValueText(result, fg)392        }393    }394395    SubLabel("MASTER PASSWORD", fg)396    ChangePasswordRow(fg, vm)397398    SubLabel("DURESS PASSWORD", fg)399    val duressIsPro by vm.isPro.collectAsState()400    if (!duressIsPro) {401        RowLine(fg) {402            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {403                Name("Duress password", fg); ProBadge(fg)404            }405            ValueText("off", fg)406        }407        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)408    } else {409        DuressSection(fg, vm)410    }411}412413@Composable414private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {415    var open by remember { mutableStateOf(false) }416    var current by remember { mutableStateOf("") }417    var next by remember { mutableStateOf("") }418    var confirm by remember { mutableStateOf("") }419    var error by remember { mutableStateOf<String?>(null) }420    var done by remember { mutableStateOf(false) }421    val scope = rememberCoroutineScope()422423    RowLine(fg) {424        Name("Master password", fg)425        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }426    }427    AnimatedVisibility(open) {428        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {429            PwField("current password", current, fg) { current = it }430            PwField("new password (min 12 chars)", next, fg) { next = it }431            PwField("confirm new password", confirm, fg) { confirm = it }432            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }433            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)434            SubtleButton("save new password", fg) {435                error = when {436                    current.isBlank() -> "enter your current password"437                    next.length < 12 -> "new password must be at least 12 characters"438                    next != confirm -> "new passwords do not match"439                    next == current -> "new password must differ from current"440                    else -> null441                }442                if (error == null) scope.launch {443                    if (vm.changePassword(current, next)) {444                        current = ""; next = ""; confirm = ""; open = false; done = true445                    } else error = "incorrect current password"446                }447            }448        }449    }450}451452@Composable453private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {454    PeachField(455        label = placeholder,456        value = value,457        onValueChange = onChange,458        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,459        visualTransformation = PasswordVisualTransformation(),460    )461}462463/* ────────────────────────────────  Vault  ───────────────────────────────── */464465@Composable466private fun VaultBody(467    fg: Color,468    vm: SettingsViewModel,469    s: VaultSettings,470    entriesCount: Int,471    onNavigate: (PeachNav) -> Unit,472) {473    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")474    val current = s.trashRetentionDays ?: 0475    val isPro by vm.isPro.collectAsState()476    val dedupCount by vm.dedupCount.collectAsState()477    val dedupRunning by vm.dedupRunning.collectAsState()478    val dedupDone by vm.dedupDone.collectAsState()479    val history by vm.vaultHistory.collectAsState()480    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()481482    SubLabel("ENTRIES", fg, top = 0.dp)483    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }484    RowLine(fg) {485        Name("Duplicates", fg)486        val done = dedupDone487        if (done != null) {488            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)489        } else {490            val count = dedupCount491            val label = when {492                dedupRunning -> "scanning..."493                count == null -> "scan"494                count == 0 -> "none found"495                else -> "$count found"496            }497            SubtleButton(label, fg) {498                if (count == null || count == 0) vm.scanDuplicates()499            }500        }501    }502    if ((dedupCount ?: 0) > 0 && dedupDone == null) {503        Column(504            Modifier.fillMaxWidth()505                .padding(top = 2.dp, bottom = 8.dp)506                .clip(RoundedCornerShape(8.dp))507                .background(fg.copy(alpha = 0.08f))508                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))509                .padding(10.dp),510            verticalArrangement = Arrangement.spacedBy(8.dp),511        ) {512            val count = dedupCount ?: 0513            Text(514                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",515                color = fg.copy(alpha = 0.82f),516                fontFamily = PeachType.Body,517                fontSize = 11.sp,518                lineHeight = 16.sp,519            )520            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {521                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }522                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }523            }524        }525    }526527    SubLabel("MAINTENANCE", fg)528    RowLine(fg) {529        Name("Trash auto-delete", fg)530        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)531        SubtleButton(trashOptions[idx].second, fg) {532            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)533        }534    }535    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }536    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }537538    SubLabel("DATA", fg)539    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }540    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }541    RowLine(fg) {542        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }543        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }544    }545    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }546547    var historyShowAll by remember { mutableStateOf(false) }548    SubLabel("VAULT HISTORY", fg)549    if (history.isEmpty()) {550        Text(551            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",552            color = fg.copy(alpha = 0.68f),553            fontFamily = PeachType.Body,554            fontSize = 11.sp,555            lineHeight = 16.sp,556            modifier = Modifier.padding(bottom = 6.dp),557        )558    } else {559        Text(560            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",561            color = fg.copy(alpha = 0.68f),562            fontFamily = PeachType.Body,563            fontSize = 11.sp,564            lineHeight = 16.sp,565            modifier = Modifier.padding(bottom = 4.dp),566        )567        history.take(if (historyShowAll) history.size else 3).forEach { snap ->568            RowLine(fg) {569                Column(Modifier.weight(1f)) {570                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)571                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)572                }573                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {574                    vm.restoreVaultSnapshot(snap.id)575                }576            }577        }578        if (history.size > 3) {579            RowLine(fg) {580                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)581                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }582            }583        }584        Text(585            "${history.size} of 5 snapshots",586            color = fg.copy(alpha = 0.58f),587            fontFamily = PeachType.Mono,588            fontSize = 10.sp,589            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),590        )591    }592593    var advancedOpen by remember { mutableStateOf(false) }594    SubLabel("ADVANCED", fg)595    RowLine(fg) {596        Name("Advanced", fg)597        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }598    }599    if (advancedOpen) {600        RowLine(fg) {601            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)602            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }603        }604        RowLine(fg) {605            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)606            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }607        }608    }609}610611private fun snapshotSubLabel(snapshot: VaultSnapshot): String {612    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }613    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")614}615616private fun snapshotReasonLabel(reason: String): String = when (reason) {617    "local_save" -> "saved"618    "sync_received" -> "synced"619    "import" -> "imported"620    "restore" -> "restored"621    else -> reason622}623624private fun snapshotTimeLabel(savedAt: Long): String {625    val diff = System.currentTimeMillis() - savedAt626    if (diff < 60_000) return "Just now"627    if (diff < 3_600_000) return "${diff / 60_000}m ago"628    val date = Date(savedAt)629    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())630    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)631    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)632    if (today == thenDay) return "Today $time"633    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))634    if (yesterday == thenDay) return "Yesterday $time"635    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)636}637638/* ────────────────────────────────  Sync  ────────────────────────────────── */639640@Composable641private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {642    val syncVm: SyncViewModel = hiltViewModel()643    val pairedDevices by syncVm.pairedDevices.collectAsState()644    val pendingVouched by syncVm.pendingVouched.collectAsState()645    val syncStatus by syncVm.status.collectAsState()646    val configuredRelayUrl by syncVm.relayUrl.collectAsState()647    val syncMode by syncVm.syncMode.collectAsState()648    val scope = rememberCoroutineScope()649    var confirmUnpair by remember { mutableStateOf<String?>(null) }650    var rotateConfirmOpen by remember { mutableStateOf(false) }651    var rotating by remember { mutableStateOf(false) }652    var rotateError by remember { mutableStateOf<String?>(null) }653    var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(configuredRelayUrl) }654    var relayUrlStatus by remember { mutableStateOf<String?>(null) }655656    // One pairing block, two buttons. The old "Sync hub" row went to the same657    // place as "show", and the description described a LAN-code-only design658    // that no longer exists.659    SubLabel("PAIR A DEVICE", fg, top = 0.dp)660    RowLine(fg) {661        Column(Modifier.weight(1f)) {662            Row(verticalAlignment = Alignment.CenterVertically) {663                Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)664            }665            Text(666                "End-to-end encrypted, through a relay that can read none of it.",667                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,668                modifier = Modifier.padding(top = 2.dp),669            )670        }671    }672    RowLine(fg) {673        Column(Modifier.weight(1f)) {674            Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)675            Text(676                "For the other device to scan. Always use this one to pair a browser.",677                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,678                modifier = Modifier.padding(top = 2.dp),679            )680        }681        SubtleButton("show", fg) { onNavigate(PeachNav.CodePair) }682    }683    RowLine(fg) {684        Column(Modifier.weight(1f)) {685            Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)686            Text(687                "Joins that vault. Your entries are kept and the two merge; this phone's " +688                    "recovery words are replaced, and shown to you first.",689                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,690                modifier = Modifier.padding(top = 2.dp),691            )692        }693        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }694    }695696    SubLabel("PAIRED DEVICES", fg)697    if (pairedDevices.isEmpty()) {698        PeachEmptyOrEndState(699            message = "No paired devices yet.",700            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",701            modifier = Modifier.padding(top = PeachSpacing.xs),702        )703    } else {704        pairedDevices.forEach { d ->705            RowLine(fg) {706                Column(Modifier.weight(1f)) {707                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)708                    Text(709                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",710                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,711                        modifier = Modifier.padding(top = 2.dp),712                    )713                }714                if (confirmUnpair != d.deviceId) {715                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }716                }717            }718            // HOLE 4: revocation stops this device receiving future changes. It719            // does NOT secure the vault against it — that device may already720            // hold the recovery words, which are master-password-equivalent and721            // restore the whole vault anywhere, forever, with no sync involved.722            // Say so here rather than leaving the user falsely reassured.723            if (confirmUnpair == d.deviceId) {724                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {725                    Text(726                        "Stops this device receiving future changes. It doesn't lock it " +727                            "out of what it already has - if it's lost or stolen, also " +728                            "change your master password and generate new recovery words.",729                        color = fg.copy(alpha = 0.7f),730                        fontFamily = PeachType.Body,731                        fontSize = 11.sp,732                        lineHeight = 16.sp,733                    )734                    Row(735                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),736                        horizontalArrangement = Arrangement.End,737                    ) {738                        SubtleButton("cancel", fg) { confirmUnpair = null }739                        Spacer(Modifier.width(PeachSpacing.sm))740                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }741                    }742                }743            }744        }745        RowLine(fg) {746            ValueText(syncFlashLabel(syncStatus), fg)747            SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {748                if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()749            }750        }751    }752753    // Three named choices, not a toggle with an implied default. LAN sync is754    // retired (docs/sync-lan-retirement.md), so "no relay" now means no sync755    // rather than a peer-to-peer mode that never reliably worked. The756    // phone-as-relay-for-two-browsers mechanism went with it: browsers dial the757    // real relay directly now, so this handset has no forwarding role.758    SubLabel("HOW THIS VAULT SYNCS", fg)759    SyncModeRow(760        title = "Public relay",761        detail = "Peach's own relay. Works anywhere, nothing to set up. It never sees " +762            "your vault — everything is encrypted before it leaves this device.",763        selected = syncMode == SyncMode.PUBLIC,764        fg = fg,765    ) { syncVm.setSyncMode(SyncMode.PUBLIC) }766    SyncModeRow(767        title = "Private relay",768        detail = "Your own peach-relay, on your own domain. Same open-source code.",769        selected = syncMode == SyncMode.PRIVATE,770        fg = fg,771    ) { syncVm.setSyncMode(SyncMode.PRIVATE) }772773    if (syncMode == SyncMode.PRIVATE) {774        Text(775            "RELAY URL",776            color = fg.copy(alpha = 0.6f),777            fontFamily = PeachType.Mono,778            fontSize = 8.sp,779            letterSpacing = 0.22.em,780            modifier = Modifier.padding(top = 8.dp),781        )782        Box(783            Modifier.fillMaxWidth().padding(top = 6.dp)784                .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))785                .padding(horizontal = 12.dp, vertical = 10.dp),786        ) {787            BasicTextField(788                value = relayUrlDraft,789                onValueChange = { relayUrlDraft = it; relayUrlStatus = null },790                singleLine = true,791                textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),792                cursorBrush = SolidColor(fg),793                modifier = Modifier.fillMaxWidth(),794            )795        }796        RowLine(fg) {797            ValueText(relayUrlStatus ?: "Only secure wss:// relays are accepted.", fg)798            SubtleButton("save", fg) {799                relayUrlStatus = if (syncVm.setPrivateRelayUrl(relayUrlDraft)) {800                    relayUrlDraft = syncVm.relayUrl.value801                    "Saved"802                } else {803                    "Use a valid wss:// relay URL"804                }805            }806        }807    }808809    SyncModeRow(810        title = "No sync",811        detail = "This device keeps to itself. Move your vault by hand with Codex or " +812            "CSV export and import.",813        selected = syncMode == SyncMode.OFF,814        fg = fg,815    ) { syncVm.setSyncMode(SyncMode.OFF) }816817    if (pendingVouched.isNotEmpty()) {818        SubLabel("PENDING DEVICES", fg)819        pendingVouched.forEach { dev ->820            RowLine(fg) {821                Column(Modifier.weight(1f)) {822                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)823                    Text(824                        "Was added by another of your devices. Allow it to sync with this vault?",825                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,826                        modifier = Modifier.padding(top = 2.dp),827                    )828                }829                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {830                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }831                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }832                }833            }834        }835    }836837    SubLabel("RECOVERY", fg)838    if (rotateConfirmOpen) {839        Hint("This will disconnect all paired devices and your 12 recovery words will change. You'll be shown the new words next.", peachColors.Breach)840        rotateError?.let {841            Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))842        }843        Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {844            SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }845            SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {846                if (rotating) return@SubtleButton847                rotating = true848                rotateError = null849                scope.launch {850                    val ok = syncVm.rotateSyncIdentity()851                    rotating = false852                    if (ok) {853                        rotateConfirmOpen = false854                        onNavigate(PeachNav.RecoverySetup)855                    } else {856                        rotateError = "Rotation failed. Unlock your real vault and try again."857                    }858                }859            }860        }861    } else {862        RowLine(fg) {863            Column(Modifier.weight(1f)) {864                Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)865                Text(866                    "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.",867                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,868                    modifier = Modifier.padding(top = 2.dp),869                )870            }871            SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }872        }873    }874875    Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)876}877878private fun deviceTypeLabel(type: String?): String = when (type) {879    "android" -> "Android"880    "phone" -> "Phone"881    "tablet" -> "Tablet"882    "browser" -> "Browser"883    else -> "Device"884}885886private fun syncTimeLabel(lastSyncedAt: Long?): String {887    if (lastSyncedAt == null) return "never synced"888    val diff = System.currentTimeMillis() - lastSyncedAt889    if (diff < 60_000) return "just now"890    if (diff < 3_600_000) return "${diff / 60_000}m ago"891    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"892    return "${diff / 86_400_000}d ago"893}894895private fun isSyncStale(lastSyncedAt: Long?): Boolean =896    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L897898private fun syncFlashLabel(status: SyncStatus): String = when (status) {899    SyncStatus.Idle -> ""900    SyncStatus.Syncing -> "syncing..."901    SyncStatus.Offline -> "offline"902    is SyncStatus.Error -> status.message903    is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}"904}905906/* ────────────────────────────────  Pro  ─────────────────────────────────── */907908@Composable909private fun ProBody(fg: Color, vm: SettingsViewModel) {910    val ctx = LocalContext.current911    val isPro by vm.isPro.collectAsState()912    if (isPro) {913        ProActiveBody(fg, vm)914        return915    }916    val features = listOf(917        "Peach Sync" to "Encrypted · peer-to-peer",918        "Peach Codex" to "Restore from paper",919        "Passkeys" to "Zero-knowledge WebAuthn",920        "Advanced Vault Health" to "Reuse · age · exposure",921        "Canary entries" to "Detects vault compromise",922        "Duress vault" to "Decoy under coercion",923    )924    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)925    Text(926        "Everything, everywhere.",927        modifier = Modifier.padding(top = 8.dp),928        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,929    )930    Text(931        "One key unlocks every Pro feature — for the lifetime of the app.",932        modifier = Modifier.padding(top = 6.dp),933        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,934    )935    Spacer(Modifier.height(10.dp))936    features.forEachIndexed { i, (name, desc) ->937        // Compact rows (6dp vs. the shared 9dp default) — this card is the938        // tallest in the deck (6 features + price + CTA) and used to leave939        // its button with no breathing room before the fanned tab stacked940        // right beneath it in preview mode.941        RowLine(fg, verticalPadding = 6.dp) {942            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {943                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))944                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)945            }946            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)947        }948    }949    Spacer(Modifier.height(12.dp))950    Text(951        "$39",952        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,953    )954    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)955    Spacer(Modifier.height(10.dp))956    Box(957        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))958            .background(fg.copy(alpha = 0.16f))959            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))960            .clickable {961                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }962            }963            .padding(vertical = 12.dp),964        contentAlignment = Alignment.Center,965    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }966967    // Trailing breathing room so the button clears the fanned tab stacked968    // beneath this card in preview mode, where the scrollable viewport clips969    // right around here (the license-key section below is already out of970    // view at that height).971    Spacer(Modifier.height(44.dp))972973    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)974    var key by remember { mutableStateOf("") }975    val activating by vm.activating.collectAsState()976    val activationError by vm.activationError.collectAsState()977    Box(978        Modifier.fillMaxWidth().padding(top = 6.dp)979            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))980            .padding(horizontal = 12.dp, vertical = 10.dp),981    ) {982        BasicTextField(983            value = key,984            // NOT .uppercase() — base58 (the license payload encoding) is985            // case-sensitive; forcing the whole string to uppercase silently986            // corrupted every real key before it ever reached signature987            // verification, so activation always failed with a real key.988            onValueChange = { key = it; vm.clearActivationError() },989            singleLine = true,990            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),991            cursorBrush = SolidColor(fg),992            modifier = Modifier.fillMaxWidth(),993            decorationBox = { inner ->994                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)995                inner()996            },997        )998    }999    if (activationError != null) {1000        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1001    }1002    Spacer(Modifier.height(8.dp))1003    Box(1004        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1005            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1006            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1007            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1008            .padding(vertical = 12.dp),1009        contentAlignment = Alignment.Center,1010    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1011}10121013@Composable1014private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1015    val ctx = LocalContext.current1016    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1017    val licenseKey by vm.licenseKey.collectAsState()1018    val issuedAt by vm.licenseIssuedAt.collectAsState()1019    var showKey by remember { mutableStateOf(false) }1020    var copied by remember { mutableStateOf(false) }1021    val scope = rememberCoroutineScope()1022    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1023        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1024    }1025    Text(1026        "Peach Pro",1027        modifier = Modifier.fillMaxWidth(),1028        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1029        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1030    )1031    Text(1032        formatLicenseActiveSince(issuedAt),1033        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1034        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1035        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1036    )1037    licenseKey?.let { key ->1038        Spacer(Modifier.height(12.dp))1039        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1040        RowLine(fg) {1041            Text(1042                if (showKey) key else "************************",1043                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1044                fontFamily = PeachType.Mono,1045                fontSize = 10.sp,1046                modifier = Modifier.weight(1f),1047            )1048            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1049                SubtleButton(if (copied) "copied" else "copy", fg) {1050                    PeachClipboard.copy(ctx, "peach license", key)1051                    copied = true1052                    scope.launch {1053                        delay(1_500)1054                        copied = false1055                    }1056                }1057                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1058            }1059        }1060    }1061    Spacer(Modifier.height(12.dp))1062    features.forEachIndexed { i, name ->1063        RowLine(fg) {1064            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1065                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1066                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1067            }1068            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1069        }1070    }1071    Spacer(Modifier.height(12.dp))1072    Box(1073        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1074            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1075            .clickable { vm.deactivate() }1076            .padding(vertical = 12.dp),1077        contentAlignment = Alignment.Center,1078    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1079}10801081private fun formatLicenseActiveSince(issuedAt: Long?): String {1082    if (issuedAt == null) return "Active"1083    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1084    return "Active since $monthYear"1085}10861087/* ──────────────────────────────  Feedback  ───────────────────────────────── */10881089@OptIn(ExperimentalLayoutApi::class)1090@Composable1091private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1092    val categories = listOf("Bug report", "Feature request", "Question", "Other")1093    var category by remember { mutableStateOf(categories[0]) }1094    var message by remember { mutableStateOf("") }1095    val state by vm.feedbackState.collectAsState()10961097    // Reset transient state when the card leaves composition so reopening starts fresh.1098    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }10991100    if (state is FeedbackState.Sent) {1101        Box(1102            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1103                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1104        ) {1105            Column {1106                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1107                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))1108            }1109        }1110        return1111    }11121113    val sending = state is FeedbackState.Sending11141115    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)1116    SubLabel("TYPE", fg)1117    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1118        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1119    }1120    SubLabel("MESSAGE", fg)1121    Box(1122        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1123            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1124            .padding(10.dp),1125    ) {1126        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1127        BasicTextField(1128            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1129            enabled = !sending,1130            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1131            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1132        )1133    }1134    Text(1135        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1136        modifier = Modifier.padding(top = 6.dp),1137        color = fg.copy(alpha = 0.52f),1138        fontFamily = PeachType.Mono,1139        fontSize = 10.sp,1140        lineHeight = 14.sp,1141    )1142    Spacer(Modifier.height(8.dp))11431144    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1145        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1146        Box(1147            Modifier.clip(RoundedCornerShape(8.dp))1148                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1149                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1150                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1151                .padding(horizontal = 12.dp, vertical = 6.dp),1152        ) {1153            Text(1154                feedbackSubmitLabel(state),1155                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1156                fontFamily = PeachType.Mono, fontSize = 11.sp,1157            )1158        }1159        if (sending) {1160            CircularProgressIndicator(1161                color = LocalPeachAccent.current,1162                strokeWidth = 2.dp,1163                modifier = Modifier.size(16.dp),1164            )1165        }1166    }11671168    (state as? FeedbackState.Error)?.let { err ->1169        Text(1170            err.message,1171            modifier = Modifier.padding(top = 8.dp),1172            color = peachColors.Breach,1173            fontFamily = PeachType.Mono,1174            fontSize = 11.sp,1175            lineHeight = 15.sp,1176        )1177    }11781179    Hint("Sent anonymously · no account required", fg)1180}11811182/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */11831184@Composable1185internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1186    val configured by vm.duressConfigured.collectAsState()1187    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1188}11891190@Composable1191private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1192    Text(1193        "Duress password is active",1194        color = peachColors.HealthOk,1195        fontFamily = PeachType.Mono,1196        fontSize = 11.sp,1197        modifier = Modifier.padding(top = 6.dp),1198    )1199    Spacer(Modifier.height(8.dp))1200    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1201    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1202}12031204@Composable1205private fun DuressForm(fg: Color, vm: SettingsViewModel) {1206    var enabled by remember { mutableStateOf(false) }1207    var password by remember { mutableStateOf("") }1208    var confirm by remember { mutableStateOf("") }1209    var action by remember { mutableStateOf(DuressAction.DECOY) }1210    var error by remember { mutableStateOf<String?>(null) }1211    val scope = rememberCoroutineScope()12121213    RowLine(fg) {1214        Name("Duress password", fg)1215        Toggle(enabled, fg) { on ->1216            enabled = on1217            if (!on) { password = ""; confirm = ""; error = null }1218        }1219    }1220    Text(1221        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1222        color = fg.copy(alpha = 0.7f),1223        fontFamily = PeachType.Mono,1224        fontSize = 10.sp,1225        modifier = Modifier.padding(top = 6.dp),1226    )12271228    AnimatedVisibility(enabled) {1229        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1230            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1231            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }12321233            Text(1234                "When your duress password is entered at the lock screen, Peach will:",1235                color = fg.copy(alpha = 0.7f),1236                fontFamily = PeachType.Mono,1237                fontSize = 10.sp,1238                modifier = Modifier.padding(top = 4.dp),1239            )12401241            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1242            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }12431244            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }12451246            val canSave = password.isNotBlank() && confirm.isNotBlank()1247            DuressSaveButton("Save duress password", fg, canSave) {1248                error = when {1249                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1250                    password != confirm -> "Passwords do not match"1251                    else -> null1252                }1253                if (error == null) scope.launch {1254                    if (vm.saveDuress(password, action)) {1255                        password = ""; confirm = ""; enabled = false1256                    } else {1257                        error = "Couldn't save — the duress passphrase must differ from your real password"1258                    }1259                }1260            }1261        }1262    }1263}12641265@Composable1266private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1267    val accent = LocalPeachAccent.current1268    Row(1269        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1270        verticalAlignment = Alignment.CenterVertically,1271        horizontalArrangement = Arrangement.spacedBy(10.dp),1272    ) {1273        Box(1274            Modifier.size(16.dp).clip(CircleShape)1275                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1276            contentAlignment = Alignment.Center,1277        ) {1278            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1279        }1280        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1281    }1282}12831284@Composable1285private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1286    val accent = LocalPeachAccent.current1287    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1288    Box(1289        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1290            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1291            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1292            .let { if (enabled) it.clickable(onClick = onClick) else it }1293            .padding(vertical = 11.dp),1294        contentAlignment = Alignment.Center,1295    ) {1296        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1297    }1298}12991300@Composable1301private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1302    Box(1303        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1304            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1305            .clickable(onClick = onClick)1306            .padding(vertical = 11.dp),1307        contentAlignment = Alignment.Center,1308    ) {1309        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1310    }1311}13121313/** One choice in the three-way sync selector. */1314@Composable1315private fun SyncModeRow(1316    title: String,1317    detail: String,1318    selected: Boolean,1319    fg: Color,1320    onSelect: () -> Unit,1321) {1322    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1323        Column(Modifier.weight(1f)) {1324            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1325            Text(1326                detail,1327                color = fg.copy(alpha = 0.7f),1328                fontFamily = PeachType.Body,1329                fontSize = 11.sp,1330                lineHeight = 16.sp,1331                modifier = Modifier.padding(top = 2.dp),1332            )1333        }1334        Text(1335            if (selected) "●" else "○",1336            color = if (selected) fg else fg.copy(alpha = 0.45f),1337            fontFamily = PeachType.Mono,1338            fontSize = 14.sp,1339            modifier = Modifier.padding(start = 10.dp),1340        )1341    }1342}