SettingsCardBodies.kt

66.2 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.heightIn21import androidx.compose.foundation.layout.padding22import androidx.compose.foundation.layout.size23import androidx.compose.foundation.layout.width24import androidx.compose.foundation.shape.CircleShape25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.runtime.Composable28import androidx.compose.material3.CircularProgressIndicator29import androidx.compose.runtime.DisposableEffect30import androidx.compose.runtime.collectAsState31import androidx.compose.runtime.getValue32import com.peachpasswords.feedback.FeedbackState33import com.peachpasswords.shared.PeachClipboard34import com.peachpasswords.sync.SyncMode35import com.peachpasswords.sync.canShowBackgroundSyncNotification36import com.peachpasswords.sync.describeSyncOutcome37import com.peachpasswords.sync.privateRelayDraftOf38import com.peachpasswords.sync.syncIndicator39import com.peachpasswords.sync.syncIndicatorLabel40import com.peachpasswords.ui.components.SyncIndicatorDot41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.vault.DuressAction43import com.peachpasswords.ui.theme.peachColors44import androidx.compose.runtime.mutableStateOf45import androidx.compose.runtime.remember46import androidx.compose.runtime.rememberCoroutineScope47import androidx.compose.runtime.setValue48import androidx.compose.ui.Alignment49import androidx.compose.ui.Modifier50import androidx.compose.ui.draw.clip51import androidx.compose.ui.graphics.Color52import androidx.compose.ui.graphics.SolidColor53import androidx.compose.ui.platform.LocalContext54import androidx.compose.ui.text.TextStyle55import androidx.compose.ui.text.font.FontStyle56import androidx.compose.ui.text.input.PasswordVisualTransformation57import androidx.compose.ui.unit.Dp58import androidx.compose.ui.unit.dp59import androidx.compose.ui.unit.em60import androidx.compose.ui.unit.sp61import androidx.compose.material3.Text62import com.peachpasswords.BuildConfig63import com.peachpasswords.autofill.AutofillEnablement64import com.peachpasswords.ui.PeachNav65import com.peachpasswords.ui.components.PeachEmptyOrEndState66import com.peachpasswords.ui.components.PeachField67import com.peachpasswords.ui.pairing.BackgroundSyncDialog68import com.peachpasswords.ui.sync.SyncViewModel69import com.peachpasswords.ui.theme.ACCENT_COLORS70import com.peachpasswords.ui.theme.PeachSpacing71import com.peachpasswords.ui.theme.PeachType72import com.peachpasswords.ui.updates.ChangelogPhase73import com.peachpasswords.ui.updates.UpdatePhase74import com.peachpasswords.ui.updates.UpdateViewModel75import com.peachpasswords.updates.compareVersions76import androidx.hilt.navigation.compose.hiltViewModel77import com.peachpasswords.vault.VaultSettings78import com.peachpasswords.vault.VaultSnapshot79import kotlinx.coroutines.launch80import kotlinx.coroutines.delay81import java.text.SimpleDateFormat82import java.util.Date83import java.util.Locale8485private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8687/**88 * Body content for the active stack card. Everything is tinted with the card's89 * own foreground [fg] (the browser's `currentColor` convention), so each card's90 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,91 * canary, license activation) render their browser visual but defer the action92 * to a later phase — noted inline where that's the case.93 */94@Composable95fun CardBody(96    id: CardId,97    fg: Color,98    vm: SettingsViewModel,99    settings: VaultSettings,100    entriesCount: Int,101    onNavigate: (PeachNav) -> Unit,102) {103    when (id) {104        CardId.Appearance -> AppearanceBody(fg, vm, settings)105        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)106        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)107        CardId.Sync -> SyncBody(fg, onNavigate)108        CardId.Pro -> ProBody(fg, vm)109        CardId.Feedback -> FeedbackBody(fg, vm)110    }111}112113/* ─────────────────────────  shared row vocabulary  ───────────────────────── */114115@Composable116private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {117    Text(118        text,119        modifier = Modifier.padding(top = top, bottom = 4.dp),120        color = fg.copy(alpha = 0.58f),121        fontFamily = PeachType.Mono,122        fontSize = 8.sp,123        letterSpacing = 0.22.em,124    )125}126127/**128 * Collapsible group inside a card body: the uppercase heading row toggles its129 * rows, so busy cards can fold rarely-used sections away. Groups holding130 * dangerous or rarely-used actions start collapsed.131 */132@Composable133private fun SettingsGroup(134    label: String,135    fg: Color,136    initiallyOpen: Boolean = true,137    top: Dp = 14.dp,138    content: @Composable () -> Unit,139) {140    var open by remember { mutableStateOf(initiallyOpen) }141    Row(142        Modifier.fillMaxWidth()143            .clip(RoundedCornerShape(6.dp))144            .clickable { open = !open }145            .padding(top = top, bottom = 4.dp),146        verticalAlignment = Alignment.CenterVertically,147    ) {148        Text(149            label,150            modifier = Modifier.weight(1f),151            color = fg.copy(alpha = 0.58f),152            fontFamily = PeachType.Mono,153            fontSize = 8.sp,154            letterSpacing = 0.22.em,155        )156        Text(157            if (open) "–" else "+",158            color = fg.copy(alpha = 0.58f),159            fontFamily = PeachType.Mono,160            fontSize = 10.sp,161        )162    }163    AnimatedVisibility(visible = open) { content() }164}165166@Composable167private fun RowLine(168    fg: Color,169    modifier: Modifier = Modifier,170    verticalPadding: Dp = 9.dp,171    content: @Composable RowScope.() -> Unit,172) {173    Row(174        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),175        verticalAlignment = Alignment.CenterVertically,176        horizontalArrangement = Arrangement.SpaceBetween,177        content = content,178    )179    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))180}181182@Composable183private fun RowScope.Name(text: String, fg: Color) {184    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))185}186187@Composable188private fun ValueText(text: String, fg: Color) {189    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)190}191192@Composable193private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {194    Box(195        Modifier.clip(RoundedCornerShape(8.dp))196            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))197            .background(fg.copy(alpha = 0.08f))198            .clickable(onClick = onClick)199            .padding(horizontal = 12.dp, vertical = 6.dp),200    ) {201        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)202    }203}204205@Composable206private fun SyncDisclosure(207    title: String,208    summary: String,209    expanded: Boolean,210    fg: Color,211    onToggle: () -> Unit,212) {213    Row(214        modifier = Modifier215            .fillMaxWidth()216            .heightIn(min = 48.dp)217            .clip(RoundedCornerShape(8.dp))218            .clickable(onClick = onToggle)219            .padding(vertical = 8.dp),220        verticalAlignment = Alignment.CenterVertically,221    ) {222        Column(Modifier.weight(1f)) {223            Text(224                title.uppercase(),225                color = fg.copy(alpha = 0.58f),226                fontFamily = PeachType.Mono,227                fontSize = 8.sp,228                letterSpacing = 0.22.em,229            )230            Text(231                summary,232                color = fg.copy(alpha = 0.7f),233                fontFamily = PeachType.Body,234                fontSize = 11.sp,235                modifier = Modifier.padding(top = 3.dp),236            )237        }238        Text(239            if (expanded) "hide" else "show",240            color = fg,241            fontFamily = PeachType.Mono,242            fontSize = 10.sp,243        )244    }245}246247@Composable248private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {249    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")250    Box(251        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))252            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))253            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))254            .clickable { onToggle(!on) },255    ) {256        Box(257            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)258                .padding(start = knob - 2.dp)259                .size(14.dp).clip(CircleShape)260                .background(if (on) fg else fg.copy(alpha = 0.58f)),261        )262    }263}264265@Composable266private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {267    Box(268        Modifier.clip(RoundedCornerShape(999.dp))269            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))270            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)271            .clickable(onClick = onClick)272            .padding(horizontal = 12.dp, vertical = 5.dp),273    ) {274        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)275    }276}277278@Composable279private fun ProBadge(fg: Color) {280    Box(281        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))282            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),283    ) {284        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)285    }286}287288@Composable289private fun Hint(text: String, fg: Color) {290    Text(291        text,292        modifier = Modifier.padding(top = 6.dp),293        color = fg.copy(alpha = 0.6f),294        fontFamily = PeachType.Display,295        fontStyle = FontStyle.Italic,296        fontSize = 11.5.sp,297        lineHeight = 16.sp,298    )299}300301/* ──────────────────────────────  Appearance  ─────────────────────────────── */302303@OptIn(ExperimentalLayoutApi::class)304@Composable305private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {306    SubLabel("ACCENT", fg, top = 0.dp)307    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {308        ACCENT_COLORS.forEach { (name, color) ->309            Box(310                Modifier.size(22.dp).clip(CircleShape).background(color)311                    .border(312                        if (s.accentColor == name) 2.dp else 1.dp,313                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),314                        CircleShape,315                    )316                    .clickable { vm.setAccent(name) },317            )318        }319    }320    SubLabel("TEXT SIZE", fg)321    RowLine(fg) {322        Name("Text size", fg)323        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {324            Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }325            Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }326            Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }327        }328    }329    SubLabel("ICONS", fg)330    RowLine(fg) {331        Name("Site icons", fg)332        SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) { vm.setFaviconsEnabled(!s.faviconsEnabled) }333    }334    SubLabel("MOTION", fg)335    RowLine(fg) {336        Name("Reduce motion", fg)337        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }338    }339}340341/* ──────────────────────────────  Protection  ─────────────────────────────── */342343@Composable344private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {345    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")346    val current = s.autoLockMinutes ?: 0347348    SubLabel("LOCKING", fg, top = 0.dp)349    RowLine(fg) {350        Name("Allow screenshots", fg)351        Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }352    }353    RowLine(fg) {354        Name("Auto-lock after", fg)355        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)356        SubtleButton(lockOptions[idx].second, fg) {357            val next = lockOptions[(idx + 1) % lockOptions.size].first358            vm.setAutoLockMinutes(if (next == 0) 0 else next)359        }360    }361    SubLabel("BIOMETRICS", fg)362    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity363    RowLine(fg) {364        Name("Unlock with biometrics", fg)365        Toggle(s.biometricsEnabled, fg) { on ->366            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()367        }368    }369    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)370    if (bioError != null) {371        Text(372            bioError,373            color = com.peachpasswords.ui.theme.peachColors.Breach,374            fontFamily = PeachType.Mono, fontSize = 10.sp,375            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),376        )377    }378    SubLabel("AUTOFILL", fg)379    run {380        // Read live rather than cached: the user leaves for system settings and381        // comes back, so a remembered value would show the old answer.382        val ctx = LocalContext.current383        val enabled = AutofillEnablement.isEnabled(ctx)384        RowLine(fg) {385            Column(Modifier.weight(1f)) {386                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)387                Text(388                    if (enabled) {389                        "Peach is your autofill service."390                    } else {391                        "Android decides which app fills passwords. Turn Peach on in system settings."392                    },393                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,394                    modifier = Modifier.padding(top = 2.dp),395                )396            }397            if (!enabled) {398                SubtleButton("enable", fg) {399                    // Straight to the autofill picker with Peach preselected;400                    // some OEM builds ship no handler, so fall back rather than401                    // dead-ending on a button that does nothing.402                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }403                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }404                }405            } else {406                ValueText("on", fg)407            }408        }409    }410411    SubLabel("PASSKEYS", fg)412    RowLine(fg) {413        Column(Modifier.weight(1f)) {414            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)415            Text(416                "Peach can handle passkey sign-in and creation through Android Credential Manager.",417                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,418                modifier = Modifier.padding(top = 2.dp),419            )420        }421        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }422    }423    RowLine(fg) {424        Name("Status", fg)425        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)426    }427    SubLabel("RECOVERY", fg)428    RowLine(fg) {429        Column(Modifier.weight(1f)) {430            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)431            Text(432                "Keep the paper copy current before rotating sync identity or replacing devices.",433                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,434                modifier = Modifier.padding(top = 2.dp),435            )436        }437        SubtleButton("view", fg) {438            if (bioActivity != null) {439                com.peachpasswords.ui.lock.Biometrics.authenticate(440                    activity = bioActivity,441                    title = "View recovery words",442                    subtitle = "Confirm your identity to view your recovery words",443                    onSuccess = { onNavigate(PeachNav.RecoveryView) },444                )445            } else onNavigate(PeachNav.RecoveryView)446        }447    }448    SubLabel("BREACH PROTECTION", fg)449    val checkingBreaches by vm.checkingBreaches.collectAsState()450    val breachResult by vm.breachResult.collectAsState()451    RowLine(fg) {452        Name("Breach monitoring", fg)453        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }454    }455    RowLine(fg) {456        Name("Check all passwords", fg)457        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }458    }459    breachResult?.let { result ->460        RowLine(fg) {461            Name("Last check", fg)462            ValueText(result, fg)463        }464    }465466    SubLabel("MASTER PASSWORD", fg)467    ChangePasswordRow(fg, vm)468469    SubLabel("DURESS PASSWORD", fg)470    val duressIsPro by vm.isPro.collectAsState()471    if (!duressIsPro) {472        RowLine(fg) {473            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {474                Name("Duress password", fg); ProBadge(fg)475            }476            ValueText("off", fg)477        }478        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)479    } else {480        DuressSection(fg, vm)481    }482}483484@Composable485private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {486    var open by remember { mutableStateOf(false) }487    var current by remember { mutableStateOf("") }488    var next by remember { mutableStateOf("") }489    var confirm by remember { mutableStateOf("") }490    var error by remember { mutableStateOf<String?>(null) }491    var done by remember { mutableStateOf(false) }492    val scope = rememberCoroutineScope()493494    RowLine(fg) {495        Name("Master password", fg)496        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }497    }498    AnimatedVisibility(open) {499        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {500            PwField("current password", current, fg) { current = it }501            PwField("new password (min 12 chars)", next, fg) { next = it }502            PwField("confirm new password", confirm, fg) { confirm = it }503            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }504            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)505            SubtleButton("save new password", fg) {506                error = when {507                    current.isBlank() -> "enter your current password"508                    next.length < 12 -> "new password must be at least 12 characters"509                    next != confirm -> "new passwords do not match"510                    next == current -> "new password must differ from current"511                    else -> null512                }513                if (error == null) scope.launch {514                    if (vm.changePassword(current, next)) {515                        current = ""; next = ""; confirm = ""; open = false; done = true516                    } else error = "incorrect current password"517                }518            }519        }520    }521}522523@Composable524private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {525    PeachField(526        label = placeholder,527        value = value,528        onValueChange = onChange,529        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,530        visualTransformation = PasswordVisualTransformation(),531    )532}533534/* ────────────────────────────────  Vault  ───────────────────────────────── */535536@Composable537private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {538    val vm: UpdateViewModel = hiltViewModel()539    val available by vm.available.collectAsState()540    val state by vm.state.collectAsState()541    val changelog by vm.changelog.collectAsState()542    var changelogOpen by remember { mutableStateOf(false) }543544    RowLine(fg) {545        Name("Version", fg)546        ValueText(BuildConfig.VERSION_NAME, fg)547    }548    RowLine(fg) {549        Column(Modifier.weight(1f)) {550            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)551            Text(552                when {553                    available != null -> "v${available?.version} is available"554                    state.phase == UpdatePhase.Checking -> "Checking…"555                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"556                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"557                    else -> "Peach checks once a day and tells you when there's a new version."558                },559                color = fg.copy(alpha = 0.7f),560                fontFamily = PeachType.Body,561                fontSize = 11.sp,562                lineHeight = 16.sp,563                modifier = Modifier.padding(top = 2.dp),564            )565        }566        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {567            SubtleButton("changelog", fg) {568                changelogOpen = !changelogOpen569                if (changelogOpen) vm.loadChangelog()570            }571            if (available != null) {572                SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }573            } else {574                SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {575                    vm.check()576                }577            }578        }579    }580    if (changelogOpen) {581        when (changelog.phase) {582            ChangelogPhase.Idle -> {}583            ChangelogPhase.Loading -> Text(584                "Loading changelog…",585                color = fg.copy(alpha = 0.68f),586                fontFamily = PeachType.Body,587                fontSize = 11.sp,588                modifier = Modifier.padding(vertical = 8.dp),589            )590            ChangelogPhase.Failed -> RowLine(fg) {591                Name("Couldn't reach the update server", fg)592                SubtleButton("retry", fg) { vm.loadChangelog() }593            }594            ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {595                changelog.entries.forEachIndexed { index, entry ->596                    Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {597                        Row(verticalAlignment = Alignment.CenterVertically) {598                            Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)599                            if (compareVersions(entry.version, vm.currentVersion) == 0) {600                                Text("  current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)601                            }602                        }603                        Text(604                            entry.notes ?: "No notes for this release.",605                            color = fg.copy(alpha = 0.72f),606                            fontFamily = PeachType.Body,607                            fontSize = 11.sp,608                            lineHeight = 16.sp,609                            modifier = Modifier.padding(top = 3.dp),610                        )611                    }612                    if (index < changelog.entries.lastIndex) {613                        Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))614                    }615                }616            }617        }618    }619}620621@Composable622private fun VaultBody(623    fg: Color,624    vm: SettingsViewModel,625    s: VaultSettings,626    entriesCount: Int,627    onNavigate: (PeachNav) -> Unit,628) {629    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")630    val current = s.trashRetentionDays ?: 0631    val isPro by vm.isPro.collectAsState()632    val dedupCount by vm.dedupCount.collectAsState()633    val dedupRunning by vm.dedupRunning.collectAsState()634    val dedupDone by vm.dedupDone.collectAsState()635    val history by vm.vaultHistory.collectAsState()636    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()637638    SettingsGroup("ENTRIES", fg, top = 0.dp) {639    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }640    RowLine(fg) {641        Name("Duplicates", fg)642        val done = dedupDone643        if (done != null) {644            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)645        } else {646            val count = dedupCount647            val label = when {648                dedupRunning -> "scanning..."649                count == null -> "scan"650                count == 0 -> "none found"651                else -> "$count found"652            }653            SubtleButton(label, fg) {654                if (count == null || count == 0) vm.scanDuplicates()655            }656        }657    }658    if ((dedupCount ?: 0) > 0 && dedupDone == null) {659        Column(660            Modifier.fillMaxWidth()661                .padding(top = 2.dp, bottom = 8.dp)662                .clip(RoundedCornerShape(8.dp))663                .background(fg.copy(alpha = 0.08f))664                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))665                .padding(10.dp),666            verticalArrangement = Arrangement.spacedBy(8.dp),667        ) {668            val count = dedupCount ?: 0669            Text(670                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",671                color = fg.copy(alpha = 0.82f),672                fontFamily = PeachType.Body,673                fontSize = 11.sp,674                lineHeight = 16.sp,675            )676            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {677                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }678                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }679            }680        }681    }682    }683684    SettingsGroup("MAINTENANCE", fg) {685    RowLine(fg) {686        Name("Trash auto-delete", fg)687        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)688        SubtleButton(trashOptions[idx].second, fg) {689            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)690        }691    }692    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }693    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }694    }695696    SettingsGroup("DATA", fg) {697    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }698    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }699    RowLine(fg) {700        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }701        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }702    }703    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }704    }705706    SettingsGroup("UPDATES", fg) {707    UpdatesRows(fg, onNavigate)708    }709710    SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {711    var historyShowAll by remember { mutableStateOf(false) }712    if (history.isEmpty()) {713        Text(714            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",715            color = fg.copy(alpha = 0.68f),716            fontFamily = PeachType.Body,717            fontSize = 11.sp,718            lineHeight = 16.sp,719            modifier = Modifier.padding(bottom = 6.dp),720        )721    } else {722        Text(723            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",724            color = fg.copy(alpha = 0.68f),725            fontFamily = PeachType.Body,726            fontSize = 11.sp,727            lineHeight = 16.sp,728            modifier = Modifier.padding(bottom = 4.dp),729        )730        history.take(if (historyShowAll) history.size else 3).forEach { snap ->731            RowLine(fg) {732                Column(Modifier.weight(1f)) {733                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)734                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)735                }736                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {737                    vm.restoreVaultSnapshot(snap.id)738                }739            }740        }741        if (history.size > 3) {742            RowLine(fg) {743                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)744                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }745            }746        }747        Text(748            "${history.size} of 5 snapshots",749            color = fg.copy(alpha = 0.58f),750            fontFamily = PeachType.Mono,751            fontSize = 10.sp,752            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),753        )754    }755    }756757    SettingsGroup("ADVANCED", fg, initiallyOpen = false) {758        RowLine(fg) {759            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)760            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }761        }762        RowLine(fg) {763            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)764            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }765        }766    }767}768769private fun snapshotSubLabel(snapshot: VaultSnapshot): String {770    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }771    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")772}773774private fun snapshotReasonLabel(reason: String): String = when (reason) {775    "local_save" -> "saved"776    "sync_received" -> "synced"777    "import" -> "imported"778    "restore" -> "restored"779    else -> reason780}781782private fun snapshotTimeLabel(savedAt: Long): String {783    val diff = System.currentTimeMillis() - savedAt784    if (diff < 60_000) return "Just now"785    if (diff < 3_600_000) return "${diff / 60_000}m ago"786    val date = Date(savedAt)787    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())788    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)789    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)790    if (today == thenDay) return "Today $time"791    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))792    if (yesterday == thenDay) return "Yesterday $time"793    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)794}795796/* ────────────────────────────────  Sync  ────────────────────────────────── */797798@Composable799private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {800    val context = LocalContext.current801    val syncVm: SyncViewModel = hiltViewModel()802    val pairedDevices by syncVm.pairedDevices.collectAsState()803    val pendingVouched by syncVm.pendingVouched.collectAsState()804    val syncStatus by syncVm.status.collectAsState()805    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()806    val syncMode by syncVm.syncMode.collectAsState()807    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()808    var notificationsAvailable by remember {809        mutableStateOf(canShowBackgroundSyncNotification(context))810    }811    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }812    val scope = rememberCoroutineScope()813    var confirmUnpair by remember { mutableStateOf<String?>(null) }814    var rotateConfirmOpen by remember { mutableStateOf(false) }815    var rotating by remember { mutableStateOf(false) }816    var rotateError by remember { mutableStateOf<String?>(null) }817    var relayUrlDraft by remember(configuredRelayUrl) {818        mutableStateOf(privateRelayDraftOf(configuredRelayUrl))819    }820    var relayUrlStatus by remember { mutableStateOf<String?>(null) }821    var showConnection by remember { mutableStateOf(false) }822    var showRecovery by remember { mutableStateOf(false) }823824    SubLabel("ADD A DEVICE", fg, top = 0.dp)825    RowLine(fg) {826        Column(Modifier.weight(1f)) {827            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)828            Text(829                "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",830                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,831                modifier = Modifier.padding(top = 2.dp),832            )833        }834        // "Scan" opens the camera-driven AdoptVault flow: the phone is the835        // receiver. "Show a code" opens CodePair: the phone is the source.836        // Both are valid pairing entry points; without scan, a phone that wants837        // to join an existing browser-only vault has no path.838        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }839        Spacer(Modifier.width(PeachSpacing.xs))840        SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }841    }842843    SubLabel("SYNC STATUS", fg)844    val indicator = syncIndicator(syncStatus, pairedDevices.size)845    RowLine(fg) {846        SyncIndicatorDot(847            indicator = indicator,848            label = syncIndicatorLabel(indicator, syncStatus),849            modifier = Modifier.padding(end = PeachSpacing.sm),850            size = 8.dp,851        )852        Text(853            describeSyncOutcome(syncStatus),854            color = fg.copy(alpha = 0.7f),855            fontFamily = PeachType.Body,856            fontSize = 11.sp,857            lineHeight = 16.sp,858            modifier = Modifier.weight(1f),859        )860        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {861            if (!syncStatus.syncing) syncVm.syncNow()862        }863    }864865    SubLabel("PAIRED DEVICES", fg)866    if (pairedDevices.isEmpty()) {867        PeachEmptyOrEndState(868            message = "No paired devices yet.",869            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",870            modifier = Modifier.padding(top = PeachSpacing.xs),871        )872    } else {873        pairedDevices.forEach { d ->874            RowLine(fg) {875                Column(Modifier.weight(1f)) {876                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)877                    Text(878                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",879                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,880                        modifier = Modifier.padding(top = 2.dp),881                    )882                }883                if (confirmUnpair != d.deviceId) {884                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }885                }886            }887            // HOLE 4: revocation stops this device receiving future changes. It888            // does NOT secure the vault against it — that device may already889            // hold the recovery words, which are master-password-equivalent and890            // restore the whole vault anywhere, forever, with no sync involved.891            // Say so here rather than leaving the user falsely reassured.892            if (confirmUnpair == d.deviceId) {893                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {894                    Text(895                        "Stops this device receiving future changes. It doesn't lock it " +896                            "out of what it already has - if it's lost or stolen, also " +897                            "change your master password and generate new recovery words.",898                        color = fg.copy(alpha = 0.7f),899                        fontFamily = PeachType.Body,900                        fontSize = 11.sp,901                        lineHeight = 16.sp,902                    )903                    Row(904                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),905                        horizontalArrangement = Arrangement.End,906                    ) {907                        SubtleButton("cancel", fg) { confirmUnpair = null }908                        Spacer(Modifier.width(PeachSpacing.sm))909                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }910                    }911                }912            }913        }914    }915916    SyncDisclosure(917        title = "Connection",918        summary = "Background sync, relay, and privacy choices",919        expanded = showConnection,920        fg = fg,921        onToggle = { showConnection = !showConnection },922    )923    if (showConnection) {924        RowLine(fg) {925            Column(Modifier.weight(1f)) {926                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)927                Text(928                    when {929                        backgroundSyncEnabled && !notificationsAvailable ->930                            "Action needed — Android notifications are off, so background sync is not visible."931                        backgroundSyncEnabled ->932                            "On — keeps this phone reachable for paired devices."933                        else ->934                            "Off — Peach syncs only while you are using the app."935                    },936                    color = fg.copy(alpha = 0.7f),937                    fontFamily = PeachType.Body,938                    fontSize = 11.sp,939                    lineHeight = 16.sp,940                    modifier = Modifier.padding(top = 2.dp),941                )942            }943            SubtleButton(944                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",945                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,946            ) { showBackgroundSyncPrompt = true }947        }948        SyncModeRow(949            title = "Public relay",950            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",951            selected = syncMode == SyncMode.PUBLIC,952            fg = fg,953        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }954        SyncModeRow(955            title = "Private relay",956            detail = "Use your own peach-relay on your own domain.",957            selected = syncMode == SyncMode.PRIVATE,958            fg = fg,959        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }960961        if (syncMode == SyncMode.PRIVATE) {962            Text(963                "RELAY URL",964                color = fg.copy(alpha = 0.6f),965                fontFamily = PeachType.Mono,966                fontSize = 8.sp,967                letterSpacing = 0.22.em,968                modifier = Modifier.padding(top = 8.dp),969            )970            Box(971                Modifier.fillMaxWidth().padding(top = 6.dp)972                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))973                    .padding(horizontal = 12.dp, vertical = 10.dp),974            ) {975                BasicTextField(976                    value = relayUrlDraft,977                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },978                    singleLine = true,979                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),980                    cursorBrush = SolidColor(fg),981                    modifier = Modifier.fillMaxWidth(),982                )983            }984            val needsRelayUrl = configuredRelayUrl.isEmpty()985            RowLine(fg) {986                val message = relayUrlStatus987                    ?: if (needsRelayUrl) {988                        "Sync is paused until you enter your relay URL."989                    } else {990                        "Only secure wss:// relays are accepted."991                    }992                val warn = relayUrlStatus?.startsWith("Use") == true ||993                    (relayUrlStatus == null && needsRelayUrl)994                ValueText(message, if (warn) peachColors.Breach else fg)995                SubtleButton("save", fg) {996                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {997                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)998                        "Saved"999                    } else {1000                        "Use a valid wss:// relay URL"1001                    }1002                }1003            }1004        }10051006        SyncModeRow(1007            title = "No sync",1008            detail = "Keep this device separate and move backups by hand.",1009            selected = syncMode == SyncMode.OFF,1010            fg = fg,1011        ) { syncVm.setSyncMode(SyncMode.OFF) }1012        Hint("Pairing authorizes a device, not a cloud account.", fg)1013    }10141015    if (pendingVouched.isNotEmpty()) {1016        SubLabel("PENDING DEVICES", fg)1017        pendingVouched.forEach { dev ->1018            RowLine(fg) {1019                Column(Modifier.weight(1f)) {1020                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1021                    Text(1022                        "Was added by another device. Allow it to sync?",1023                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1024                        modifier = Modifier.padding(top = 2.dp),1025                    )1026                }1027                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1028                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1029                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1030                }1031            }1032        }1033    }10341035    SyncDisclosure(1036        title = "Recovery & security",1037        summary = "For a lost or stolen device",1038        expanded = showRecovery,1039        fg = fg,1040        onToggle = { showRecovery = !showRecovery },1041    )1042    if (showRecovery) {1043        RowLine(fg) {1044            Column(Modifier.weight(1f)) {1045                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1046                Text(1047                    "Scan only when this populated vault previously trusted the source device.",1048                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1049                    modifier = Modifier.padding(top = 2.dp),1050                )1051            }1052            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1053        }1054        if (rotateConfirmOpen) {1055            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1056            rotateError?.let {1057                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1058            }1059            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1060                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1061                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1062                    if (rotating) return@SubtleButton1063                    rotating = true1064                    rotateError = null1065                    scope.launch {1066                        val ok = syncVm.rotateSyncIdentity()1067                        rotating = false1068                        if (ok) {1069                            rotateConfirmOpen = false1070                            onNavigate(PeachNav.RecoverySetup)1071                        } else {1072                            rotateError = "Rotation failed. Unlock your real vault and try again."1073                        }1074                    }1075                }1076            }1077        } else {1078            RowLine(fg) {1079                Column(Modifier.weight(1f)) {1080                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1081                    Text(1082                        "Disconnects every device and replaces the keys used for sync.",1083                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1084                        modifier = Modifier.padding(top = 2.dp),1085                    )1086                }1087                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1088            }1089        }1090    }10911092    if (showBackgroundSyncPrompt) {1093        BackgroundSyncDialog { enabled ->1094            showBackgroundSyncPrompt = false1095            notificationsAvailable = canShowBackgroundSyncNotification(context)1096            syncVm.setBackgroundSyncEnabled(enabled)1097        }1098    }1099}11001101private fun deviceTypeLabel(type: String?): String = when (type) {1102    "android" -> "Android"1103    "phone" -> "Phone"1104    "tablet" -> "Tablet"1105    "browser" -> "Browser"1106    else -> "Device"1107}11081109private fun syncTimeLabel(lastSyncedAt: Long?): String {1110    if (lastSyncedAt == null) return "never synced"1111    val diff = System.currentTimeMillis() - lastSyncedAt1112    if (diff < 60_000) return "just now"1113    if (diff < 3_600_000) return "${diff / 60_000}m ago"1114    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1115    return "${diff / 86_400_000}d ago"1116}11171118private fun isSyncStale(lastSyncedAt: Long?): Boolean =1119    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L11201121/* ────────────────────────────────  Pro  ─────────────────────────────────── */11221123@Composable1124private fun ProBody(fg: Color, vm: SettingsViewModel) {1125    val ctx = LocalContext.current1126    val isPro by vm.isPro.collectAsState()1127    if (isPro) {1128        ProActiveBody(fg, vm)1129        return1130    }1131    val features = listOf(1132        "Peach Sync" to "End-to-end encrypted",1133        "Peach Codex" to "Restore from paper",1134        "Passkeys" to "Zero-knowledge WebAuthn",1135        "Advanced Vault Health" to "Reuse · age · exposure",1136        "Canary entries" to "Detects vault compromise",1137        "Duress vault" to "Decoy under coercion",1138    )1139    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1140    Text(1141        "Everything, everywhere.",1142        modifier = Modifier.padding(top = 8.dp),1143        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1144    )1145    Text(1146        "One key unlocks every Pro feature — for the lifetime of the app.",1147        modifier = Modifier.padding(top = 6.dp),1148        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1149    )1150    Spacer(Modifier.height(10.dp))1151    features.forEachIndexed { i, (name, desc) ->1152        // Compact rows (6dp vs. the shared 9dp default) — this card is the1153        // tallest in the deck (6 features + price + CTA) and used to leave1154        // its button with no breathing room before the fanned tab stacked1155        // right beneath it in preview mode.1156        RowLine(fg, verticalPadding = 6.dp) {1157            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1158                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1159                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1160            }1161            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1162        }1163    }1164    Spacer(Modifier.height(12.dp))1165    Text(1166        "$39",1167        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1168    )1169    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1170    Spacer(Modifier.height(10.dp))1171    Box(1172        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1173            .background(fg.copy(alpha = 0.16f))1174            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1175            .clickable {1176                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1177            }1178            .padding(vertical = 12.dp),1179        contentAlignment = Alignment.Center,1180    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }11811182    // Trailing breathing room so the button clears the fanned tab stacked1183    // beneath this card in preview mode, where the scrollable viewport clips1184    // right around here (the license-key section below is already out of1185    // view at that height).1186    Spacer(Modifier.height(44.dp))11871188    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1189    var key by remember { mutableStateOf("") }1190    val activating by vm.activating.collectAsState()1191    val activationError by vm.activationError.collectAsState()1192    Box(1193        Modifier.fillMaxWidth().padding(top = 6.dp)1194            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1195            .padding(horizontal = 12.dp, vertical = 10.dp),1196    ) {1197        BasicTextField(1198            value = key,1199            // NOT .uppercase() — base58 (the license payload encoding) is1200            // case-sensitive; forcing the whole string to uppercase silently1201            // corrupted every real key before it ever reached signature1202            // verification, so activation always failed with a real key.1203            onValueChange = { key = it; vm.clearActivationError() },1204            singleLine = true,1205            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1206            cursorBrush = SolidColor(fg),1207            modifier = Modifier.fillMaxWidth(),1208            decorationBox = { inner ->1209                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1210                inner()1211            },1212        )1213    }1214    if (activationError != null) {1215        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1216    }1217    Spacer(Modifier.height(8.dp))1218    Box(1219        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1220            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1221            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1222            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1223            .padding(vertical = 12.dp),1224        contentAlignment = Alignment.Center,1225    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1226}12271228@Composable1229private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1230    val ctx = LocalContext.current1231    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1232    val licenseKey by vm.licenseKey.collectAsState()1233    val issuedAt by vm.licenseIssuedAt.collectAsState()1234    var showKey by remember { mutableStateOf(false) }1235    var copied by remember { mutableStateOf(false) }1236    val scope = rememberCoroutineScope()1237    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1238        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1239    }1240    Text(1241        "Peach Pro",1242        modifier = Modifier.fillMaxWidth(),1243        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1244        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1245    )1246    Text(1247        formatLicenseActiveSince(issuedAt),1248        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1249        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1250        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1251    )1252    licenseKey?.let { key ->1253        Spacer(Modifier.height(12.dp))1254        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1255        RowLine(fg) {1256            Text(1257                if (showKey) key else "************************",1258                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1259                fontFamily = PeachType.Mono,1260                fontSize = 10.sp,1261                modifier = Modifier.weight(1f),1262            )1263            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1264                SubtleButton(if (copied) "copied" else "copy", fg) {1265                    PeachClipboard.copy(ctx, "peach license", key)1266                    copied = true1267                    scope.launch {1268                        delay(1_500)1269                        copied = false1270                    }1271                }1272                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1273            }1274        }1275    }1276    Spacer(Modifier.height(12.dp))1277    features.forEachIndexed { i, name ->1278        RowLine(fg) {1279            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1280                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1281                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1282            }1283            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1284        }1285    }1286    Spacer(Modifier.height(12.dp))1287    Box(1288        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1289            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1290            .clickable { vm.deactivate() }1291            .padding(vertical = 12.dp),1292        contentAlignment = Alignment.Center,1293    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1294}12951296private fun formatLicenseActiveSince(issuedAt: Long?): String {1297    if (issuedAt == null) return "Active"1298    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1299    return "Active since $monthYear"1300}13011302/* ──────────────────────────────  Feedback  ───────────────────────────────── */13031304@OptIn(ExperimentalLayoutApi::class)1305@Composable1306private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1307    val categories = listOf("Bug report", "Feature request", "Question", "Other")1308    var category by remember { mutableStateOf(categories[0]) }1309    var message by remember { mutableStateOf("") }1310    val state by vm.feedbackState.collectAsState()13111312    // Reset transient state when the card leaves composition so reopening starts fresh.1313    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }13141315    if (state is FeedbackState.Sent) {1316        Box(1317            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1318                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1319        ) {1320            Column {1321                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1322                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))1323            }1324        }1325        return1326    }13271328    val sending = state is FeedbackState.Sending13291330    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)1331    SubLabel("TYPE", fg)1332    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1333        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1334    }1335    SubLabel("MESSAGE", fg)1336    Box(1337        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1338            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1339            .padding(10.dp),1340    ) {1341        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1342        BasicTextField(1343            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1344            enabled = !sending,1345            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1346            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1347        )1348    }1349    Text(1350        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1351        modifier = Modifier.padding(top = 6.dp),1352        color = fg.copy(alpha = 0.52f),1353        fontFamily = PeachType.Mono,1354        fontSize = 10.sp,1355        lineHeight = 14.sp,1356    )1357    Spacer(Modifier.height(8.dp))13581359    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1360        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1361        Box(1362            Modifier.clip(RoundedCornerShape(8.dp))1363                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1364                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1365                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1366                .padding(horizontal = 12.dp, vertical = 6.dp),1367        ) {1368            Text(1369                feedbackSubmitLabel(state),1370                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1371                fontFamily = PeachType.Mono, fontSize = 11.sp,1372            )1373        }1374        if (sending) {1375            CircularProgressIndicator(1376                color = LocalPeachAccent.current,1377                strokeWidth = 2.dp,1378                modifier = Modifier.size(16.dp),1379            )1380        }1381    }13821383    (state as? FeedbackState.Error)?.let { err ->1384        Text(1385            err.message,1386            modifier = Modifier.padding(top = 8.dp),1387            color = peachColors.Breach,1388            fontFamily = PeachType.Mono,1389            fontSize = 11.sp,1390            lineHeight = 15.sp,1391        )1392    }13931394    Hint("Sent anonymously · no account required", fg)1395}13961397/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */13981399@Composable1400internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1401    val configured by vm.duressConfigured.collectAsState()1402    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1403}14041405@Composable1406private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1407    Text(1408        "Duress password is active",1409        color = peachColors.HealthOk,1410        fontFamily = PeachType.Mono,1411        fontSize = 11.sp,1412        modifier = Modifier.padding(top = 6.dp),1413    )1414    Spacer(Modifier.height(8.dp))1415    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1416    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1417}14181419@Composable1420private fun DuressForm(fg: Color, vm: SettingsViewModel) {1421    var enabled by remember { mutableStateOf(false) }1422    var password by remember { mutableStateOf("") }1423    var confirm by remember { mutableStateOf("") }1424    var action by remember { mutableStateOf(DuressAction.DECOY) }1425    var error by remember { mutableStateOf<String?>(null) }1426    val scope = rememberCoroutineScope()14271428    RowLine(fg) {1429        Name("Duress password", fg)1430        Toggle(enabled, fg) { on ->1431            enabled = on1432            if (!on) { password = ""; confirm = ""; error = null }1433        }1434    }1435    Text(1436        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1437        color = fg.copy(alpha = 0.7f),1438        fontFamily = PeachType.Mono,1439        fontSize = 10.sp,1440        modifier = Modifier.padding(top = 6.dp),1441    )14421443    AnimatedVisibility(enabled) {1444        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1445            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1446            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }14471448            Text(1449                "When your duress password is entered at the lock screen, Peach will:",1450                color = fg.copy(alpha = 0.7f),1451                fontFamily = PeachType.Mono,1452                fontSize = 10.sp,1453                modifier = Modifier.padding(top = 4.dp),1454            )14551456            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1457            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }14581459            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }14601461            val canSave = password.isNotBlank() && confirm.isNotBlank()1462            DuressSaveButton("Save duress password", fg, canSave) {1463                error = when {1464                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1465                    password != confirm -> "Passwords do not match"1466                    else -> null1467                }1468                if (error == null) scope.launch {1469                    if (vm.saveDuress(password, action)) {1470                        password = ""; confirm = ""; enabled = false1471                    } else {1472                        error = "Couldn't save — the duress passphrase must differ from your real password"1473                    }1474                }1475            }1476        }1477    }1478}14791480@Composable1481private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1482    val accent = LocalPeachAccent.current1483    Row(1484        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1485        verticalAlignment = Alignment.CenterVertically,1486        horizontalArrangement = Arrangement.spacedBy(10.dp),1487    ) {1488        Box(1489            Modifier.size(16.dp).clip(CircleShape)1490                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1491            contentAlignment = Alignment.Center,1492        ) {1493            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1494        }1495        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1496    }1497}14981499@Composable1500private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1501    val accent = LocalPeachAccent.current1502    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1503    Box(1504        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1505            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1506            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1507            .let { if (enabled) it.clickable(onClick = onClick) else it }1508            .padding(vertical = 11.dp),1509        contentAlignment = Alignment.Center,1510    ) {1511        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1512    }1513}15141515@Composable1516private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1517    Box(1518        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1519            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1520            .clickable(onClick = onClick)1521            .padding(vertical = 11.dp),1522        contentAlignment = Alignment.Center,1523    ) {1524        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1525    }1526}15271528/** One choice in the three-way sync selector. */1529@Composable1530private fun SyncModeRow(1531    title: String,1532    detail: String,1533    selected: Boolean,1534    fg: Color,1535    onSelect: () -> Unit,1536) {1537    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1538        Column(Modifier.weight(1f)) {1539            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1540            Text(1541                detail,1542                color = fg.copy(alpha = 0.7f),1543                fontFamily = PeachType.Body,1544                fontSize = 11.sp,1545                lineHeight = 16.sp,1546                modifier = Modifier.padding(top = 2.dp),1547            )1548        }1549        Text(1550            if (selected) "●" else "○",1551            color = if (selected) fg else fg.copy(alpha = 0.45f),1552            fontFamily = PeachType.Mono,1553            fontSize = 14.sp,1554            modifier = Modifier.padding(start = 10.dp),1555        )1556    }1557}