SettingsCardBodies.kt

61.7 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.UpdatePhase73import com.peachpasswords.ui.updates.UpdateViewModel74import androidx.hilt.navigation.compose.hiltViewModel75import com.peachpasswords.vault.VaultSettings76import com.peachpasswords.vault.VaultSnapshot77import kotlinx.coroutines.launch78import kotlinx.coroutines.delay79import java.text.SimpleDateFormat80import java.util.Date81import java.util.Locale8283private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8485/**86 * Body content for the active stack card. Everything is tinted with the card's87 * own foreground [fg] (the browser's `currentColor` convention), so each card's88 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,89 * canary, license activation) render their browser visual but defer the action90 * to a later phase — noted inline where that's the case.91 */92@Composable93fun CardBody(94    id: CardId,95    fg: Color,96    vm: SettingsViewModel,97    settings: VaultSettings,98    entriesCount: Int,99    onNavigate: (PeachNav) -> Unit,100) {101    when (id) {102        CardId.Appearance -> AppearanceBody(fg, vm, settings)103        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)104        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)105        CardId.Sync -> SyncBody(fg, onNavigate)106        CardId.Pro -> ProBody(fg, vm)107        CardId.Feedback -> FeedbackBody(fg, vm)108    }109}110111/* ─────────────────────────  shared row vocabulary  ───────────────────────── */112113@Composable114private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {115    Text(116        text,117        modifier = Modifier.padding(top = top, bottom = 4.dp),118        color = fg.copy(alpha = 0.58f),119        fontFamily = PeachType.Mono,120        fontSize = 8.sp,121        letterSpacing = 0.22.em,122    )123}124125@Composable126private fun RowLine(127    fg: Color,128    modifier: Modifier = Modifier,129    verticalPadding: Dp = 9.dp,130    content: @Composable RowScope.() -> Unit,131) {132    Row(133        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),134        verticalAlignment = Alignment.CenterVertically,135        horizontalArrangement = Arrangement.SpaceBetween,136        content = content,137    )138    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))139}140141@Composable142private fun RowScope.Name(text: String, fg: Color) {143    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))144}145146@Composable147private fun ValueText(text: String, fg: Color) {148    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)149}150151@Composable152private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {153    Box(154        Modifier.clip(RoundedCornerShape(8.dp))155            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))156            .background(fg.copy(alpha = 0.08f))157            .clickable(onClick = onClick)158            .padding(horizontal = 12.dp, vertical = 6.dp),159    ) {160        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)161    }162}163164@Composable165private fun SyncDisclosure(166    title: String,167    summary: String,168    expanded: Boolean,169    fg: Color,170    onToggle: () -> Unit,171) {172    Row(173        modifier = Modifier174            .fillMaxWidth()175            .heightIn(min = 48.dp)176            .clip(RoundedCornerShape(8.dp))177            .clickable(onClick = onToggle)178            .padding(vertical = 8.dp),179        verticalAlignment = Alignment.CenterVertically,180    ) {181        Column(Modifier.weight(1f)) {182            Text(183                title.uppercase(),184                color = fg.copy(alpha = 0.58f),185                fontFamily = PeachType.Mono,186                fontSize = 8.sp,187                letterSpacing = 0.22.em,188            )189            Text(190                summary,191                color = fg.copy(alpha = 0.7f),192                fontFamily = PeachType.Body,193                fontSize = 11.sp,194                modifier = Modifier.padding(top = 3.dp),195            )196        }197        Text(198            if (expanded) "hide" else "show",199            color = fg,200            fontFamily = PeachType.Mono,201            fontSize = 10.sp,202        )203    }204}205206@Composable207private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {208    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")209    Box(210        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))211            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))212            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))213            .clickable { onToggle(!on) },214    ) {215        Box(216            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)217                .padding(start = knob - 2.dp)218                .size(14.dp).clip(CircleShape)219                .background(if (on) fg else fg.copy(alpha = 0.58f)),220        )221    }222}223224@Composable225private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {226    Box(227        Modifier.clip(RoundedCornerShape(999.dp))228            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))229            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)230            .clickable(onClick = onClick)231            .padding(horizontal = 12.dp, vertical = 5.dp),232    ) {233        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)234    }235}236237@Composable238private fun ProBadge(fg: Color) {239    Box(240        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))241            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),242    ) {243        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)244    }245}246247@Composable248private fun Hint(text: String, fg: Color) {249    Text(250        text,251        modifier = Modifier.padding(top = 6.dp),252        color = fg.copy(alpha = 0.6f),253        fontFamily = PeachType.Display,254        fontStyle = FontStyle.Italic,255        fontSize = 11.5.sp,256        lineHeight = 16.sp,257    )258}259260/* ──────────────────────────────  Appearance  ─────────────────────────────── */261262@OptIn(ExperimentalLayoutApi::class)263@Composable264private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {265    SubLabel("ACCENT", fg, top = 0.dp)266    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {267        ACCENT_COLORS.forEach { (name, color) ->268            Box(269                Modifier.size(22.dp).clip(CircleShape).background(color)270                    .border(271                        if (s.accentColor == name) 2.dp else 1.dp,272                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),273                        CircleShape,274                    )275                    .clickable { vm.setAccent(name) },276            )277        }278    }279    SubLabel("MOTION", fg)280    RowLine(fg) {281        Name("Reduce motion", fg)282        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }283    }284}285286/* ──────────────────────────────  Protection  ─────────────────────────────── */287288@Composable289private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {290    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")291    val current = s.autoLockMinutes ?: 0292293    SubLabel("LOCKING", fg, top = 0.dp)294    RowLine(fg) {295        Name("Auto-lock after", fg)296        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)297        SubtleButton(lockOptions[idx].second, fg) {298            val next = lockOptions[(idx + 1) % lockOptions.size].first299            vm.setAutoLockMinutes(if (next == 0) 0 else next)300        }301    }302    SubLabel("BIOMETRICS", fg)303    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity304    RowLine(fg) {305        Name("Unlock with biometrics", fg)306        Toggle(s.biometricsEnabled, fg) { on ->307            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()308        }309    }310    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)311    if (bioError != null) {312        Text(313            bioError,314            color = com.peachpasswords.ui.theme.peachColors.Breach,315            fontFamily = PeachType.Mono, fontSize = 10.sp,316            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),317        )318    }319    SubLabel("AUTOFILL", fg)320    run {321        // Read live rather than cached: the user leaves for system settings and322        // comes back, so a remembered value would show the old answer.323        val ctx = LocalContext.current324        val enabled = AutofillEnablement.isEnabled(ctx)325        RowLine(fg) {326            Column(Modifier.weight(1f)) {327                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)328                Text(329                    if (enabled) {330                        "Peach is your autofill service."331                    } else {332                        "Android decides which app fills passwords. Turn Peach on in system settings."333                    },334                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,335                    modifier = Modifier.padding(top = 2.dp),336                )337            }338            if (!enabled) {339                SubtleButton("enable", fg) {340                    // Straight to the autofill picker with Peach preselected;341                    // some OEM builds ship no handler, so fall back rather than342                    // dead-ending on a button that does nothing.343                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }344                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }345                }346            } else {347                ValueText("on", fg)348            }349        }350    }351352    SubLabel("PASSKEYS", fg)353    RowLine(fg) {354        Column(Modifier.weight(1f)) {355            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)356            Text(357                "Peach can handle passkey sign-in and creation through Android Credential Manager.",358                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,359                modifier = Modifier.padding(top = 2.dp),360            )361        }362        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }363    }364    RowLine(fg) {365        Name("Status", fg)366        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)367    }368    SubLabel("RECOVERY", fg)369    RowLine(fg) {370        Column(Modifier.weight(1f)) {371            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)372            Text(373                "Keep the paper copy current before rotating sync identity or replacing devices.",374                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,375                modifier = Modifier.padding(top = 2.dp),376            )377        }378        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }379    }380    SubLabel("BREACH PROTECTION", fg)381    val checkingBreaches by vm.checkingBreaches.collectAsState()382    val breachResult by vm.breachResult.collectAsState()383    RowLine(fg) {384        Name("Breach monitoring", fg)385        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }386    }387    RowLine(fg) {388        Name("Check all passwords", fg)389        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }390    }391    breachResult?.let { result ->392        RowLine(fg) {393            Name("Last check", fg)394            ValueText(result, fg)395        }396    }397398    SubLabel("MASTER PASSWORD", fg)399    ChangePasswordRow(fg, vm)400401    SubLabel("DURESS PASSWORD", fg)402    val duressIsPro by vm.isPro.collectAsState()403    if (!duressIsPro) {404        RowLine(fg) {405            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {406                Name("Duress password", fg); ProBadge(fg)407            }408            ValueText("off", fg)409        }410        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)411    } else {412        DuressSection(fg, vm)413    }414}415416@Composable417private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {418    var open by remember { mutableStateOf(false) }419    var current by remember { mutableStateOf("") }420    var next by remember { mutableStateOf("") }421    var confirm by remember { mutableStateOf("") }422    var error by remember { mutableStateOf<String?>(null) }423    var done by remember { mutableStateOf(false) }424    val scope = rememberCoroutineScope()425426    RowLine(fg) {427        Name("Master password", fg)428        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }429    }430    AnimatedVisibility(open) {431        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {432            PwField("current password", current, fg) { current = it }433            PwField("new password (min 12 chars)", next, fg) { next = it }434            PwField("confirm new password", confirm, fg) { confirm = it }435            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }436            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)437            SubtleButton("save new password", fg) {438                error = when {439                    current.isBlank() -> "enter your current password"440                    next.length < 12 -> "new password must be at least 12 characters"441                    next != confirm -> "new passwords do not match"442                    next == current -> "new password must differ from current"443                    else -> null444                }445                if (error == null) scope.launch {446                    if (vm.changePassword(current, next)) {447                        current = ""; next = ""; confirm = ""; open = false; done = true448                    } else error = "incorrect current password"449                }450            }451        }452    }453}454455@Composable456private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {457    PeachField(458        label = placeholder,459        value = value,460        onValueChange = onChange,461        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,462        visualTransformation = PasswordVisualTransformation(),463    )464}465466/* ────────────────────────────────  Vault  ───────────────────────────────── */467468@Composable469private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {470    val vm: UpdateViewModel = hiltViewModel()471    val available by vm.available.collectAsState()472    val state by vm.state.collectAsState()473474    RowLine(fg) {475        Name("Version", fg)476        ValueText(BuildConfig.VERSION_NAME, fg)477    }478    RowLine(fg) {479        Column(Modifier.weight(1f)) {480            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)481            Text(482                when {483                    available != null -> "v${'$'}{available?.version} is available"484                    state.phase == UpdatePhase.Checking -> "Checking…"485                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"486                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"487                    else -> "Peach checks once a day and tells you when there's a new version."488                },489                color = fg.copy(alpha = 0.7f),490                fontFamily = PeachType.Body,491                fontSize = 11.sp,492                lineHeight = 16.sp,493                modifier = Modifier.padding(top = 2.dp),494            )495        }496        if (available != null) {497            SubtleButton("update", fg) { onNavigate(PeachNav.Update) }498        } else {499            SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {500                vm.check()501            }502        }503    }504}505506@Composable507private fun VaultBody(508    fg: Color,509    vm: SettingsViewModel,510    s: VaultSettings,511    entriesCount: Int,512    onNavigate: (PeachNav) -> Unit,513) {514    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")515    val current = s.trashRetentionDays ?: 0516    val isPro by vm.isPro.collectAsState()517    val dedupCount by vm.dedupCount.collectAsState()518    val dedupRunning by vm.dedupRunning.collectAsState()519    val dedupDone by vm.dedupDone.collectAsState()520    val history by vm.vaultHistory.collectAsState()521    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()522523    SubLabel("ENTRIES", fg, top = 0.dp)524    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }525    RowLine(fg) {526        Name("Duplicates", fg)527        val done = dedupDone528        if (done != null) {529            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)530        } else {531            val count = dedupCount532            val label = when {533                dedupRunning -> "scanning..."534                count == null -> "scan"535                count == 0 -> "none found"536                else -> "$count found"537            }538            SubtleButton(label, fg) {539                if (count == null || count == 0) vm.scanDuplicates()540            }541        }542    }543    if ((dedupCount ?: 0) > 0 && dedupDone == null) {544        Column(545            Modifier.fillMaxWidth()546                .padding(top = 2.dp, bottom = 8.dp)547                .clip(RoundedCornerShape(8.dp))548                .background(fg.copy(alpha = 0.08f))549                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))550                .padding(10.dp),551            verticalArrangement = Arrangement.spacedBy(8.dp),552        ) {553            val count = dedupCount ?: 0554            Text(555                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",556                color = fg.copy(alpha = 0.82f),557                fontFamily = PeachType.Body,558                fontSize = 11.sp,559                lineHeight = 16.sp,560            )561            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {562                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }563                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }564            }565        }566    }567568    SubLabel("MAINTENANCE", fg)569    RowLine(fg) {570        Name("Trash auto-delete", fg)571        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)572        SubtleButton(trashOptions[idx].second, fg) {573            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)574        }575    }576    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }577    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }578579    SubLabel("DATA", fg)580    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }581    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }582    RowLine(fg) {583        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }584        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }585    }586    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }587588    var historyShowAll by remember { mutableStateOf(false) }589    SubLabel("UPDATES", fg)590    UpdatesRows(fg, onNavigate)591592    SubLabel("VAULT HISTORY", fg)593    if (history.isEmpty()) {594        Text(595            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",596            color = fg.copy(alpha = 0.68f),597            fontFamily = PeachType.Body,598            fontSize = 11.sp,599            lineHeight = 16.sp,600            modifier = Modifier.padding(bottom = 6.dp),601        )602    } else {603        Text(604            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",605            color = fg.copy(alpha = 0.68f),606            fontFamily = PeachType.Body,607            fontSize = 11.sp,608            lineHeight = 16.sp,609            modifier = Modifier.padding(bottom = 4.dp),610        )611        history.take(if (historyShowAll) history.size else 3).forEach { snap ->612            RowLine(fg) {613                Column(Modifier.weight(1f)) {614                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)615                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)616                }617                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {618                    vm.restoreVaultSnapshot(snap.id)619                }620            }621        }622        if (history.size > 3) {623            RowLine(fg) {624                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)625                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }626            }627        }628        Text(629            "${history.size} of 5 snapshots",630            color = fg.copy(alpha = 0.58f),631            fontFamily = PeachType.Mono,632            fontSize = 10.sp,633            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),634        )635    }636637    var advancedOpen by remember { mutableStateOf(false) }638    SubLabel("ADVANCED", fg)639    RowLine(fg) {640        Name("Advanced", fg)641        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }642    }643    if (advancedOpen) {644        RowLine(fg) {645            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)646            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }647        }648        RowLine(fg) {649            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)650            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }651        }652    }653}654655private fun snapshotSubLabel(snapshot: VaultSnapshot): String {656    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }657    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")658}659660private fun snapshotReasonLabel(reason: String): String = when (reason) {661    "local_save" -> "saved"662    "sync_received" -> "synced"663    "import" -> "imported"664    "restore" -> "restored"665    else -> reason666}667668private fun snapshotTimeLabel(savedAt: Long): String {669    val diff = System.currentTimeMillis() - savedAt670    if (diff < 60_000) return "Just now"671    if (diff < 3_600_000) return "${diff / 60_000}m ago"672    val date = Date(savedAt)673    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())674    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)675    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)676    if (today == thenDay) return "Today $time"677    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))678    if (yesterday == thenDay) return "Yesterday $time"679    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)680}681682/* ────────────────────────────────  Sync  ────────────────────────────────── */683684@Composable685private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {686    val context = LocalContext.current687    val syncVm: SyncViewModel = hiltViewModel()688    val pairedDevices by syncVm.pairedDevices.collectAsState()689    val pendingVouched by syncVm.pendingVouched.collectAsState()690    val syncStatus by syncVm.status.collectAsState()691    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()692    val syncMode by syncVm.syncMode.collectAsState()693    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()694    var notificationsAvailable by remember {695        mutableStateOf(canShowBackgroundSyncNotification(context))696    }697    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }698    val scope = rememberCoroutineScope()699    var confirmUnpair by remember { mutableStateOf<String?>(null) }700    var rotateConfirmOpen by remember { mutableStateOf(false) }701    var rotating by remember { mutableStateOf(false) }702    var rotateError by remember { mutableStateOf<String?>(null) }703    var relayUrlDraft by remember(configuredRelayUrl) {704        mutableStateOf(privateRelayDraftOf(configuredRelayUrl))705    }706    var relayUrlStatus by remember { mutableStateOf<String?>(null) }707    var showConnection by remember { mutableStateOf(false) }708    var showRecovery by remember { mutableStateOf(false) }709710    SubLabel("ADD A DEVICE", fg, top = 0.dp)711    RowLine(fg) {712        Column(Modifier.weight(1f)) {713            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)714            Text(715                "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",716                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,717                modifier = Modifier.padding(top = 2.dp),718            )719        }720        // "Scan" opens the camera-driven AdoptVault flow: the phone is the721        // receiver. "Show a code" opens CodePair: the phone is the source.722        // Both are valid pairing entry points; without scan, a phone that wants723        // to join an existing browser-only vault has no path.724        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }725        Spacer(Modifier.width(PeachSpacing.xs))726        SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }727    }728729    SubLabel("SYNC STATUS", fg)730    val indicator = syncIndicator(syncStatus, pairedDevices.size)731    RowLine(fg) {732        SyncIndicatorDot(733            indicator = indicator,734            label = syncIndicatorLabel(indicator, syncStatus),735            modifier = Modifier.padding(end = PeachSpacing.sm),736            size = 8.dp,737        )738        Text(739            describeSyncOutcome(syncStatus),740            color = fg.copy(alpha = 0.7f),741            fontFamily = PeachType.Body,742            fontSize = 11.sp,743            lineHeight = 16.sp,744            modifier = Modifier.weight(1f),745        )746        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {747            if (!syncStatus.syncing) syncVm.syncNow()748        }749    }750751    SubLabel("PAIRED DEVICES", fg)752    if (pairedDevices.isEmpty()) {753        PeachEmptyOrEndState(754            message = "No paired devices yet.",755            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",756            modifier = Modifier.padding(top = PeachSpacing.xs),757        )758    } else {759        pairedDevices.forEach { d ->760            RowLine(fg) {761                Column(Modifier.weight(1f)) {762                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)763                    Text(764                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",765                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,766                        modifier = Modifier.padding(top = 2.dp),767                    )768                }769                if (confirmUnpair != d.deviceId) {770                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }771                }772            }773            // HOLE 4: revocation stops this device receiving future changes. It774            // does NOT secure the vault against it — that device may already775            // hold the recovery words, which are master-password-equivalent and776            // restore the whole vault anywhere, forever, with no sync involved.777            // Say so here rather than leaving the user falsely reassured.778            if (confirmUnpair == d.deviceId) {779                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {780                    Text(781                        "Stops this device receiving future changes. It doesn't lock it " +782                            "out of what it already has - if it's lost or stolen, also " +783                            "change your master password and generate new recovery words.",784                        color = fg.copy(alpha = 0.7f),785                        fontFamily = PeachType.Body,786                        fontSize = 11.sp,787                        lineHeight = 16.sp,788                    )789                    Row(790                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),791                        horizontalArrangement = Arrangement.End,792                    ) {793                        SubtleButton("cancel", fg) { confirmUnpair = null }794                        Spacer(Modifier.width(PeachSpacing.sm))795                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }796                    }797                }798            }799        }800    }801802    SyncDisclosure(803        title = "Connection",804        summary = "Background sync, relay, and privacy choices",805        expanded = showConnection,806        fg = fg,807        onToggle = { showConnection = !showConnection },808    )809    if (showConnection) {810        RowLine(fg) {811            Column(Modifier.weight(1f)) {812                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)813                Text(814                    when {815                        backgroundSyncEnabled && !notificationsAvailable ->816                            "Action needed — Android notifications are off, so background sync is not visible."817                        backgroundSyncEnabled ->818                            "On — keeps this phone reachable for paired devices."819                        else ->820                            "Off — Peach syncs only while you are using the app."821                    },822                    color = fg.copy(alpha = 0.7f),823                    fontFamily = PeachType.Body,824                    fontSize = 11.sp,825                    lineHeight = 16.sp,826                    modifier = Modifier.padding(top = 2.dp),827                )828            }829            SubtleButton(830                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",831                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,832            ) { showBackgroundSyncPrompt = true }833        }834        SyncModeRow(835            title = "Public relay",836            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",837            selected = syncMode == SyncMode.PUBLIC,838            fg = fg,839        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }840        SyncModeRow(841            title = "Private relay",842            detail = "Use your own peach-relay on your own domain.",843            selected = syncMode == SyncMode.PRIVATE,844            fg = fg,845        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }846847        if (syncMode == SyncMode.PRIVATE) {848            Text(849                "RELAY URL",850                color = fg.copy(alpha = 0.6f),851                fontFamily = PeachType.Mono,852                fontSize = 8.sp,853                letterSpacing = 0.22.em,854                modifier = Modifier.padding(top = 8.dp),855            )856            Box(857                Modifier.fillMaxWidth().padding(top = 6.dp)858                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))859                    .padding(horizontal = 12.dp, vertical = 10.dp),860            ) {861                BasicTextField(862                    value = relayUrlDraft,863                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },864                    singleLine = true,865                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),866                    cursorBrush = SolidColor(fg),867                    modifier = Modifier.fillMaxWidth(),868                )869            }870            val needsRelayUrl = configuredRelayUrl.isEmpty()871            RowLine(fg) {872                val message = relayUrlStatus873                    ?: if (needsRelayUrl) {874                        "Sync is paused until you enter your relay URL."875                    } else {876                        "Only secure wss:// relays are accepted."877                    }878                val warn = relayUrlStatus?.startsWith("Use") == true ||879                    (relayUrlStatus == null && needsRelayUrl)880                ValueText(message, if (warn) peachColors.Breach else fg)881                SubtleButton("save", fg) {882                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {883                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)884                        "Saved"885                    } else {886                        "Use a valid wss:// relay URL"887                    }888                }889            }890        }891892        SyncModeRow(893            title = "No sync",894            detail = "Keep this device separate and move backups by hand.",895            selected = syncMode == SyncMode.OFF,896            fg = fg,897        ) { syncVm.setSyncMode(SyncMode.OFF) }898        Hint("Pairing authorizes a device, not a cloud account.", fg)899    }900901    if (pendingVouched.isNotEmpty()) {902        SubLabel("PENDING DEVICES", fg)903        pendingVouched.forEach { dev ->904            RowLine(fg) {905                Column(Modifier.weight(1f)) {906                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)907                    Text(908                        "Was added by another device. Allow it to sync?",909                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,910                        modifier = Modifier.padding(top = 2.dp),911                    )912                }913                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {914                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }915                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }916                }917            }918        }919    }920921    SyncDisclosure(922        title = "Recovery & security",923        summary = "For a lost or stolen device",924        expanded = showRecovery,925        fg = fg,926        onToggle = { showRecovery = !showRecovery },927    )928    if (showRecovery) {929        RowLine(fg) {930            Column(Modifier.weight(1f)) {931                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)932                Text(933                    "Scan only when this populated vault previously trusted the source device.",934                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,935                    modifier = Modifier.padding(top = 2.dp),936                )937            }938            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }939        }940        if (rotateConfirmOpen) {941            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)942            rotateError?.let {943                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))944            }945            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {946                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }947                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {948                    if (rotating) return@SubtleButton949                    rotating = true950                    rotateError = null951                    scope.launch {952                        val ok = syncVm.rotateSyncIdentity()953                        rotating = false954                        if (ok) {955                            rotateConfirmOpen = false956                            onNavigate(PeachNav.RecoverySetup)957                        } else {958                            rotateError = "Rotation failed. Unlock your real vault and try again."959                        }960                    }961                }962            }963        } else {964            RowLine(fg) {965                Column(Modifier.weight(1f)) {966                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)967                    Text(968                        "Disconnects every device and replaces the keys used for sync.",969                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,970                        modifier = Modifier.padding(top = 2.dp),971                    )972                }973                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }974            }975        }976    }977978    if (showBackgroundSyncPrompt) {979        BackgroundSyncDialog { enabled ->980            showBackgroundSyncPrompt = false981            notificationsAvailable = canShowBackgroundSyncNotification(context)982            syncVm.setBackgroundSyncEnabled(enabled)983        }984    }985}986987private fun deviceTypeLabel(type: String?): String = when (type) {988    "android" -> "Android"989    "phone" -> "Phone"990    "tablet" -> "Tablet"991    "browser" -> "Browser"992    else -> "Device"993}994995private fun syncTimeLabel(lastSyncedAt: Long?): String {996    if (lastSyncedAt == null) return "never synced"997    val diff = System.currentTimeMillis() - lastSyncedAt998    if (diff < 60_000) return "just now"999    if (diff < 3_600_000) return "${diff / 60_000}m ago"1000    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1001    return "${diff / 86_400_000}d ago"1002}10031004private fun isSyncStale(lastSyncedAt: Long?): Boolean =1005    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L10061007/* ────────────────────────────────  Pro  ─────────────────────────────────── */10081009@Composable1010private fun ProBody(fg: Color, vm: SettingsViewModel) {1011    val ctx = LocalContext.current1012    val isPro by vm.isPro.collectAsState()1013    if (isPro) {1014        ProActiveBody(fg, vm)1015        return1016    }1017    val features = listOf(1018        "Peach Sync" to "End-to-end encrypted",1019        "Peach Codex" to "Restore from paper",1020        "Passkeys" to "Zero-knowledge WebAuthn",1021        "Advanced Vault Health" to "Reuse · age · exposure",1022        "Canary entries" to "Detects vault compromise",1023        "Duress vault" to "Decoy under coercion",1024    )1025    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1026    Text(1027        "Everything, everywhere.",1028        modifier = Modifier.padding(top = 8.dp),1029        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1030    )1031    Text(1032        "One key unlocks every Pro feature — for the lifetime of the app.",1033        modifier = Modifier.padding(top = 6.dp),1034        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1035    )1036    Spacer(Modifier.height(10.dp))1037    features.forEachIndexed { i, (name, desc) ->1038        // Compact rows (6dp vs. the shared 9dp default) — this card is the1039        // tallest in the deck (6 features + price + CTA) and used to leave1040        // its button with no breathing room before the fanned tab stacked1041        // right beneath it in preview mode.1042        RowLine(fg, verticalPadding = 6.dp) {1043            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1044                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1045                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1046            }1047            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1048        }1049    }1050    Spacer(Modifier.height(12.dp))1051    Text(1052        "$39",1053        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1054    )1055    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1056    Spacer(Modifier.height(10.dp))1057    Box(1058        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1059            .background(fg.copy(alpha = 0.16f))1060            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1061            .clickable {1062                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1063            }1064            .padding(vertical = 12.dp),1065        contentAlignment = Alignment.Center,1066    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10671068    // Trailing breathing room so the button clears the fanned tab stacked1069    // beneath this card in preview mode, where the scrollable viewport clips1070    // right around here (the license-key section below is already out of1071    // view at that height).1072    Spacer(Modifier.height(44.dp))10731074    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1075    var key by remember { mutableStateOf("") }1076    val activating by vm.activating.collectAsState()1077    val activationError by vm.activationError.collectAsState()1078    Box(1079        Modifier.fillMaxWidth().padding(top = 6.dp)1080            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1081            .padding(horizontal = 12.dp, vertical = 10.dp),1082    ) {1083        BasicTextField(1084            value = key,1085            // NOT .uppercase() — base58 (the license payload encoding) is1086            // case-sensitive; forcing the whole string to uppercase silently1087            // corrupted every real key before it ever reached signature1088            // verification, so activation always failed with a real key.1089            onValueChange = { key = it; vm.clearActivationError() },1090            singleLine = true,1091            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1092            cursorBrush = SolidColor(fg),1093            modifier = Modifier.fillMaxWidth(),1094            decorationBox = { inner ->1095                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1096                inner()1097            },1098        )1099    }1100    if (activationError != null) {1101        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1102    }1103    Spacer(Modifier.height(8.dp))1104    Box(1105        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1106            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1107            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1108            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1109            .padding(vertical = 12.dp),1110        contentAlignment = Alignment.Center,1111    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1112}11131114@Composable1115private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1116    val ctx = LocalContext.current1117    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1118    val licenseKey by vm.licenseKey.collectAsState()1119    val issuedAt by vm.licenseIssuedAt.collectAsState()1120    var showKey by remember { mutableStateOf(false) }1121    var copied by remember { mutableStateOf(false) }1122    val scope = rememberCoroutineScope()1123    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1124        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1125    }1126    Text(1127        "Peach Pro",1128        modifier = Modifier.fillMaxWidth(),1129        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1130        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1131    )1132    Text(1133        formatLicenseActiveSince(issuedAt),1134        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1135        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1136        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1137    )1138    licenseKey?.let { key ->1139        Spacer(Modifier.height(12.dp))1140        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1141        RowLine(fg) {1142            Text(1143                if (showKey) key else "************************",1144                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1145                fontFamily = PeachType.Mono,1146                fontSize = 10.sp,1147                modifier = Modifier.weight(1f),1148            )1149            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1150                SubtleButton(if (copied) "copied" else "copy", fg) {1151                    PeachClipboard.copy(ctx, "peach license", key)1152                    copied = true1153                    scope.launch {1154                        delay(1_500)1155                        copied = false1156                    }1157                }1158                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1159            }1160        }1161    }1162    Spacer(Modifier.height(12.dp))1163    features.forEachIndexed { i, name ->1164        RowLine(fg) {1165            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1166                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1167                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1168            }1169            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1170        }1171    }1172    Spacer(Modifier.height(12.dp))1173    Box(1174        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1175            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1176            .clickable { vm.deactivate() }1177            .padding(vertical = 12.dp),1178        contentAlignment = Alignment.Center,1179    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1180}11811182private fun formatLicenseActiveSince(issuedAt: Long?): String {1183    if (issuedAt == null) return "Active"1184    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1185    return "Active since $monthYear"1186}11871188/* ──────────────────────────────  Feedback  ───────────────────────────────── */11891190@OptIn(ExperimentalLayoutApi::class)1191@Composable1192private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1193    val categories = listOf("Bug report", "Feature request", "Question", "Other")1194    var category by remember { mutableStateOf(categories[0]) }1195    var message by remember { mutableStateOf("") }1196    val state by vm.feedbackState.collectAsState()11971198    // Reset transient state when the card leaves composition so reopening starts fresh.1199    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }12001201    if (state is FeedbackState.Sent) {1202        Box(1203            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1204                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1205        ) {1206            Column {1207                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1208                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))1209            }1210        }1211        return1212    }12131214    val sending = state is FeedbackState.Sending12151216    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)1217    SubLabel("TYPE", fg)1218    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1219        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1220    }1221    SubLabel("MESSAGE", fg)1222    Box(1223        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1224            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1225            .padding(10.dp),1226    ) {1227        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1228        BasicTextField(1229            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1230            enabled = !sending,1231            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1232            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1233        )1234    }1235    Text(1236        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1237        modifier = Modifier.padding(top = 6.dp),1238        color = fg.copy(alpha = 0.52f),1239        fontFamily = PeachType.Mono,1240        fontSize = 10.sp,1241        lineHeight = 14.sp,1242    )1243    Spacer(Modifier.height(8.dp))12441245    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1246        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1247        Box(1248            Modifier.clip(RoundedCornerShape(8.dp))1249                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1250                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1251                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1252                .padding(horizontal = 12.dp, vertical = 6.dp),1253        ) {1254            Text(1255                feedbackSubmitLabel(state),1256                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1257                fontFamily = PeachType.Mono, fontSize = 11.sp,1258            )1259        }1260        if (sending) {1261            CircularProgressIndicator(1262                color = LocalPeachAccent.current,1263                strokeWidth = 2.dp,1264                modifier = Modifier.size(16.dp),1265            )1266        }1267    }12681269    (state as? FeedbackState.Error)?.let { err ->1270        Text(1271            err.message,1272            modifier = Modifier.padding(top = 8.dp),1273            color = peachColors.Breach,1274            fontFamily = PeachType.Mono,1275            fontSize = 11.sp,1276            lineHeight = 15.sp,1277        )1278    }12791280    Hint("Sent anonymously · no account required", fg)1281}12821283/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */12841285@Composable1286internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1287    val configured by vm.duressConfigured.collectAsState()1288    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1289}12901291@Composable1292private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1293    Text(1294        "Duress password is active",1295        color = peachColors.HealthOk,1296        fontFamily = PeachType.Mono,1297        fontSize = 11.sp,1298        modifier = Modifier.padding(top = 6.dp),1299    )1300    Spacer(Modifier.height(8.dp))1301    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1302    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1303}13041305@Composable1306private fun DuressForm(fg: Color, vm: SettingsViewModel) {1307    var enabled by remember { mutableStateOf(false) }1308    var password by remember { mutableStateOf("") }1309    var confirm by remember { mutableStateOf("") }1310    var action by remember { mutableStateOf(DuressAction.DECOY) }1311    var error by remember { mutableStateOf<String?>(null) }1312    val scope = rememberCoroutineScope()13131314    RowLine(fg) {1315        Name("Duress password", fg)1316        Toggle(enabled, fg) { on ->1317            enabled = on1318            if (!on) { password = ""; confirm = ""; error = null }1319        }1320    }1321    Text(1322        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1323        color = fg.copy(alpha = 0.7f),1324        fontFamily = PeachType.Mono,1325        fontSize = 10.sp,1326        modifier = Modifier.padding(top = 6.dp),1327    )13281329    AnimatedVisibility(enabled) {1330        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1331            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1332            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13331334            Text(1335                "When your duress password is entered at the lock screen, Peach will:",1336                color = fg.copy(alpha = 0.7f),1337                fontFamily = PeachType.Mono,1338                fontSize = 10.sp,1339                modifier = Modifier.padding(top = 4.dp),1340            )13411342            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1343            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13441345            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13461347            val canSave = password.isNotBlank() && confirm.isNotBlank()1348            DuressSaveButton("Save duress password", fg, canSave) {1349                error = when {1350                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1351                    password != confirm -> "Passwords do not match"1352                    else -> null1353                }1354                if (error == null) scope.launch {1355                    if (vm.saveDuress(password, action)) {1356                        password = ""; confirm = ""; enabled = false1357                    } else {1358                        error = "Couldn't save — the duress passphrase must differ from your real password"1359                    }1360                }1361            }1362        }1363    }1364}13651366@Composable1367private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1368    val accent = LocalPeachAccent.current1369    Row(1370        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1371        verticalAlignment = Alignment.CenterVertically,1372        horizontalArrangement = Arrangement.spacedBy(10.dp),1373    ) {1374        Box(1375            Modifier.size(16.dp).clip(CircleShape)1376                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1377            contentAlignment = Alignment.Center,1378        ) {1379            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1380        }1381        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1382    }1383}13841385@Composable1386private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1387    val accent = LocalPeachAccent.current1388    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1389    Box(1390        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1391            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1392            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1393            .let { if (enabled) it.clickable(onClick = onClick) else it }1394            .padding(vertical = 11.dp),1395        contentAlignment = Alignment.Center,1396    ) {1397        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1398    }1399}14001401@Composable1402private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1403    Box(1404        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1405            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1406            .clickable(onClick = onClick)1407            .padding(vertical = 11.dp),1408        contentAlignment = Alignment.Center,1409    ) {1410        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1411    }1412}14131414/** One choice in the three-way sync selector. */1415@Composable1416private fun SyncModeRow(1417    title: String,1418    detail: String,1419    selected: Boolean,1420    fg: Color,1421    onSelect: () -> Unit,1422) {1423    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1424        Column(Modifier.weight(1f)) {1425            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1426            Text(1427                detail,1428                color = fg.copy(alpha = 0.7f),1429                fontFamily = PeachType.Body,1430                fontSize = 11.sp,1431                lineHeight = 16.sp,1432                modifier = Modifier.padding(top = 2.dp),1433            )1434        }1435        Text(1436            if (selected) "●" else "○",1437            color = if (selected) fg else fg.copy(alpha = 0.45f),1438            fontFamily = PeachType.Mono,1439            fontSize = 14.sp,1440            modifier = Modifier.padding(start = 10.dp),1441        )1442    }1443}