SettingsCardBodies.kt

60.8 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.describeSyncOutcome36import com.peachpasswords.sync.syncIndicator37import com.peachpasswords.sync.syncIndicatorLabel38import com.peachpasswords.ui.components.SyncIndicatorDot39import com.peachpasswords.ui.theme.LocalPeachAccent40import com.peachpasswords.vault.DuressAction41import com.peachpasswords.ui.theme.peachColors42import androidx.compose.runtime.mutableStateOf43import androidx.compose.runtime.remember44import androidx.compose.runtime.rememberCoroutineScope45import androidx.compose.runtime.setValue46import androidx.compose.ui.Alignment47import androidx.compose.ui.Modifier48import androidx.compose.ui.draw.clip49import androidx.compose.ui.graphics.Color50import androidx.compose.ui.graphics.SolidColor51import androidx.compose.ui.platform.LocalContext52import androidx.compose.ui.text.TextStyle53import androidx.compose.ui.text.font.FontStyle54import androidx.compose.ui.text.input.PasswordVisualTransformation55import androidx.compose.ui.unit.Dp56import androidx.compose.ui.unit.dp57import androidx.compose.ui.unit.em58import androidx.compose.ui.unit.sp59import androidx.compose.material3.Text60import com.peachpasswords.BuildConfig61import com.peachpasswords.autofill.AutofillEnablement62import com.peachpasswords.ui.PeachNav63import com.peachpasswords.ui.components.PeachEmptyOrEndState64import com.peachpasswords.ui.components.PeachField65import com.peachpasswords.ui.pairing.BackgroundSyncDialog66import com.peachpasswords.ui.sync.SyncViewModel67import com.peachpasswords.ui.theme.ACCENT_COLORS68import com.peachpasswords.ui.theme.PeachSpacing69import com.peachpasswords.ui.theme.PeachType70import com.peachpasswords.ui.updates.UpdatePhase71import com.peachpasswords.ui.updates.UpdateViewModel72import androidx.hilt.navigation.compose.hiltViewModel73import com.peachpasswords.vault.VaultSettings74import com.peachpasswords.vault.VaultSnapshot75import kotlinx.coroutines.launch76import kotlinx.coroutines.delay77import java.text.SimpleDateFormat78import java.util.Date79import java.util.Locale8081private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8283/**84 * Body content for the active stack card. Everything is tinted with the card's85 * own foreground [fg] (the browser's `currentColor` convention), so each card's86 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,87 * canary, license activation) render their browser visual but defer the action88 * to a later phase — noted inline where that's the case.89 */90@Composable91fun CardBody(92    id: CardId,93    fg: Color,94    vm: SettingsViewModel,95    settings: VaultSettings,96    entriesCount: Int,97    onNavigate: (PeachNav) -> Unit,98) {99    when (id) {100        CardId.Appearance -> AppearanceBody(fg, vm, settings)101        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)102        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)103        CardId.Sync -> SyncBody(fg, onNavigate)104        CardId.Pro -> ProBody(fg, vm)105        CardId.Feedback -> {106            // The closest thing Peach has to an About surface, and where the brief107            // put the update entry point: version first, then the feedback form.108            AboutUpdatesBody(fg, onNavigate)109            FeedbackBody(fg, vm)110        }111    }112}113114/* ────────────────────────────  about / updates  ─────────────────────────── */115116@Composable117private fun AboutUpdatesBody(fg: Color, onNavigate: (PeachNav) -> Unit) {118    val vm: UpdateViewModel = hiltViewModel()119    val available by vm.available.collectAsState()120    val state by vm.state.collectAsState()121122    SubLabel("THIS BUILD", fg, top = 0.dp)123    RowLine(fg) {124        Name("Version", fg)125        ValueText(BuildConfig.VERSION_NAME, fg)126    }127    RowLine(fg) {128        Column(Modifier.weight(1f)) {129            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)130            Text(131                when {132                    available != null -> "v${available?.version} is available"133                    state.phase == UpdatePhase.Checking -> "Checking…"134                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"135                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"136                    else -> "Peach checks once a day and tells you when there's a new version."137                },138                color = fg.copy(alpha = 0.7f),139                fontFamily = PeachType.Body,140                fontSize = 11.sp,141                lineHeight = 16.sp,142                modifier = Modifier.padding(top = 2.dp),143            )144        }145        if (available != null) {146            SubtleButton("update", fg) { onNavigate(PeachNav.Update) }147        } else {148            SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {149                vm.check()150            }151        }152    }153}154155/* ─────────────────────────  shared row vocabulary  ───────────────────────── */156157@Composable158private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {159    Text(160        text,161        modifier = Modifier.padding(top = top, bottom = 4.dp),162        color = fg.copy(alpha = 0.58f),163        fontFamily = PeachType.Mono,164        fontSize = 8.sp,165        letterSpacing = 0.22.em,166    )167}168169@Composable170private fun RowLine(171    fg: Color,172    modifier: Modifier = Modifier,173    verticalPadding: Dp = 9.dp,174    content: @Composable RowScope.() -> Unit,175) {176    Row(177        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),178        verticalAlignment = Alignment.CenterVertically,179        horizontalArrangement = Arrangement.SpaceBetween,180        content = content,181    )182    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))183}184185@Composable186private fun RowScope.Name(text: String, fg: Color) {187    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))188}189190@Composable191private fun ValueText(text: String, fg: Color) {192    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)193}194195@Composable196private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {197    Box(198        Modifier.clip(RoundedCornerShape(8.dp))199            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))200            .background(fg.copy(alpha = 0.08f))201            .clickable(onClick = onClick)202            .padding(horizontal = 12.dp, vertical = 6.dp),203    ) {204        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)205    }206}207208@Composable209private fun SyncDisclosure(210    title: String,211    summary: String,212    expanded: Boolean,213    fg: Color,214    onToggle: () -> Unit,215) {216    Row(217        modifier = Modifier218            .fillMaxWidth()219            .heightIn(min = 48.dp)220            .clip(RoundedCornerShape(8.dp))221            .clickable(onClick = onToggle)222            .padding(vertical = 8.dp),223        verticalAlignment = Alignment.CenterVertically,224    ) {225        Column(Modifier.weight(1f)) {226            Text(227                title.uppercase(),228                color = fg.copy(alpha = 0.58f),229                fontFamily = PeachType.Mono,230                fontSize = 8.sp,231                letterSpacing = 0.22.em,232            )233            Text(234                summary,235                color = fg.copy(alpha = 0.7f),236                fontFamily = PeachType.Body,237                fontSize = 11.sp,238                modifier = Modifier.padding(top = 3.dp),239            )240        }241        Text(242            if (expanded) "hide" else "show",243            color = fg,244            fontFamily = PeachType.Mono,245            fontSize = 10.sp,246        )247    }248}249250@Composable251private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {252    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")253    Box(254        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))255            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))256            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))257            .clickable { onToggle(!on) },258    ) {259        Box(260            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)261                .padding(start = knob - 2.dp)262                .size(14.dp).clip(CircleShape)263                .background(if (on) fg else fg.copy(alpha = 0.58f)),264        )265    }266}267268@Composable269private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {270    Box(271        Modifier.clip(RoundedCornerShape(999.dp))272            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))273            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)274            .clickable(onClick = onClick)275            .padding(horizontal = 12.dp, vertical = 5.dp),276    ) {277        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)278    }279}280281@Composable282private fun ProBadge(fg: Color) {283    Box(284        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))285            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),286    ) {287        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)288    }289}290291@Composable292private fun Hint(text: String, fg: Color) {293    Text(294        text,295        modifier = Modifier.padding(top = 6.dp),296        color = fg.copy(alpha = 0.6f),297        fontFamily = PeachType.Display,298        fontStyle = FontStyle.Italic,299        fontSize = 11.5.sp,300        lineHeight = 16.sp,301    )302}303304/* ──────────────────────────────  Appearance  ─────────────────────────────── */305306@OptIn(ExperimentalLayoutApi::class)307@Composable308private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {309    SubLabel("ACCENT", fg, top = 0.dp)310    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {311        ACCENT_COLORS.forEach { (name, color) ->312            Box(313                Modifier.size(22.dp).clip(CircleShape).background(color)314                    .border(315                        if (s.accentColor == name) 2.dp else 1.dp,316                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),317                        CircleShape,318                    )319                    .clickable { vm.setAccent(name) },320            )321        }322    }323    SubLabel("MOTION", fg)324    RowLine(fg) {325        Name("Reduce motion", fg)326        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }327    }328}329330/* ──────────────────────────────  Protection  ─────────────────────────────── */331332@Composable333private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {334    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")335    val current = s.autoLockMinutes ?: 0336337    SubLabel("LOCKING", fg, top = 0.dp)338    RowLine(fg) {339        Name("Auto-lock after", fg)340        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)341        SubtleButton(lockOptions[idx].second, fg) {342            val next = lockOptions[(idx + 1) % lockOptions.size].first343            vm.setAutoLockMinutes(if (next == 0) 0 else next)344        }345    }346    SubLabel("BIOMETRICS", fg)347    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity348    RowLine(fg) {349        Name("Unlock with biometrics", fg)350        Toggle(s.biometricsEnabled, fg) { on ->351            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()352        }353    }354    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)355    if (bioError != null) {356        Text(357            bioError,358            color = com.peachpasswords.ui.theme.peachColors.Breach,359            fontFamily = PeachType.Mono, fontSize = 10.sp,360            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),361        )362    }363    SubLabel("AUTOFILL", fg)364    run {365        // Read live rather than cached: the user leaves for system settings and366        // comes back, so a remembered value would show the old answer.367        val ctx = LocalContext.current368        val enabled = AutofillEnablement.isEnabled(ctx)369        RowLine(fg) {370            Column(Modifier.weight(1f)) {371                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)372                Text(373                    if (enabled) {374                        "Peach is your autofill service."375                    } else {376                        "Android decides which app fills passwords. Turn Peach on in system settings."377                    },378                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,379                    modifier = Modifier.padding(top = 2.dp),380                )381            }382            if (!enabled) {383                SubtleButton("enable", fg) {384                    // Straight to the autofill picker with Peach preselected;385                    // some OEM builds ship no handler, so fall back rather than386                    // dead-ending on a button that does nothing.387                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }388                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }389                }390            } else {391                ValueText("on", fg)392            }393        }394    }395396    SubLabel("PASSKEYS", fg)397    RowLine(fg) {398        Column(Modifier.weight(1f)) {399            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)400            Text(401                "Peach can handle passkey sign-in and creation through Android Credential Manager.",402                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,403                modifier = Modifier.padding(top = 2.dp),404            )405        }406        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }407    }408    RowLine(fg) {409        Name("Status", fg)410        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)411    }412    SubLabel("RECOVERY", fg)413    RowLine(fg) {414        Column(Modifier.weight(1f)) {415            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)416            Text(417                "Keep the paper copy current before rotating sync identity or replacing devices.",418                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,419                modifier = Modifier.padding(top = 2.dp),420            )421        }422        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }423    }424    SubLabel("BREACH PROTECTION", fg)425    val checkingBreaches by vm.checkingBreaches.collectAsState()426    val breachResult by vm.breachResult.collectAsState()427    RowLine(fg) {428        Name("Breach monitoring", fg)429        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }430    }431    RowLine(fg) {432        Name("Check all passwords", fg)433        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }434    }435    breachResult?.let { result ->436        RowLine(fg) {437            Name("Last check", fg)438            ValueText(result, fg)439        }440    }441442    SubLabel("MASTER PASSWORD", fg)443    ChangePasswordRow(fg, vm)444445    SubLabel("DURESS PASSWORD", fg)446    val duressIsPro by vm.isPro.collectAsState()447    if (!duressIsPro) {448        RowLine(fg) {449            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {450                Name("Duress password", fg); ProBadge(fg)451            }452            ValueText("off", fg)453        }454        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)455    } else {456        DuressSection(fg, vm)457    }458}459460@Composable461private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {462    var open by remember { mutableStateOf(false) }463    var current by remember { mutableStateOf("") }464    var next by remember { mutableStateOf("") }465    var confirm by remember { mutableStateOf("") }466    var error by remember { mutableStateOf<String?>(null) }467    var done by remember { mutableStateOf(false) }468    val scope = rememberCoroutineScope()469470    RowLine(fg) {471        Name("Master password", fg)472        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }473    }474    AnimatedVisibility(open) {475        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {476            PwField("current password", current, fg) { current = it }477            PwField("new password (min 12 chars)", next, fg) { next = it }478            PwField("confirm new password", confirm, fg) { confirm = it }479            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }480            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)481            SubtleButton("save new password", fg) {482                error = when {483                    current.isBlank() -> "enter your current password"484                    next.length < 12 -> "new password must be at least 12 characters"485                    next != confirm -> "new passwords do not match"486                    next == current -> "new password must differ from current"487                    else -> null488                }489                if (error == null) scope.launch {490                    if (vm.changePassword(current, next)) {491                        current = ""; next = ""; confirm = ""; open = false; done = true492                    } else error = "incorrect current password"493                }494            }495        }496    }497}498499@Composable500private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {501    PeachField(502        label = placeholder,503        value = value,504        onValueChange = onChange,505        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,506        visualTransformation = PasswordVisualTransformation(),507    )508}509510/* ────────────────────────────────  Vault  ───────────────────────────────── */511512@Composable513private fun VaultBody(514    fg: Color,515    vm: SettingsViewModel,516    s: VaultSettings,517    entriesCount: Int,518    onNavigate: (PeachNav) -> Unit,519) {520    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")521    val current = s.trashRetentionDays ?: 0522    val isPro by vm.isPro.collectAsState()523    val dedupCount by vm.dedupCount.collectAsState()524    val dedupRunning by vm.dedupRunning.collectAsState()525    val dedupDone by vm.dedupDone.collectAsState()526    val history by vm.vaultHistory.collectAsState()527    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()528529    SubLabel("ENTRIES", fg, top = 0.dp)530    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }531    RowLine(fg) {532        Name("Duplicates", fg)533        val done = dedupDone534        if (done != null) {535            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)536        } else {537            val count = dedupCount538            val label = when {539                dedupRunning -> "scanning..."540                count == null -> "scan"541                count == 0 -> "none found"542                else -> "$count found"543            }544            SubtleButton(label, fg) {545                if (count == null || count == 0) vm.scanDuplicates()546            }547        }548    }549    if ((dedupCount ?: 0) > 0 && dedupDone == null) {550        Column(551            Modifier.fillMaxWidth()552                .padding(top = 2.dp, bottom = 8.dp)553                .clip(RoundedCornerShape(8.dp))554                .background(fg.copy(alpha = 0.08f))555                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))556                .padding(10.dp),557            verticalArrangement = Arrangement.spacedBy(8.dp),558        ) {559            val count = dedupCount ?: 0560            Text(561                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",562                color = fg.copy(alpha = 0.82f),563                fontFamily = PeachType.Body,564                fontSize = 11.sp,565                lineHeight = 16.sp,566            )567            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {568                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }569                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }570            }571        }572    }573574    SubLabel("MAINTENANCE", fg)575    RowLine(fg) {576        Name("Trash auto-delete", fg)577        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)578        SubtleButton(trashOptions[idx].second, fg) {579            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)580        }581    }582    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }583    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }584585    SubLabel("DATA", fg)586    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }587    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }588    RowLine(fg) {589        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }590        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }591    }592    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }593594    var historyShowAll by remember { mutableStateOf(false) }595    SubLabel("VAULT HISTORY", fg)596    if (history.isEmpty()) {597        Text(598            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",599            color = fg.copy(alpha = 0.68f),600            fontFamily = PeachType.Body,601            fontSize = 11.sp,602            lineHeight = 16.sp,603            modifier = Modifier.padding(bottom = 6.dp),604        )605    } else {606        Text(607            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",608            color = fg.copy(alpha = 0.68f),609            fontFamily = PeachType.Body,610            fontSize = 11.sp,611            lineHeight = 16.sp,612            modifier = Modifier.padding(bottom = 4.dp),613        )614        history.take(if (historyShowAll) history.size else 3).forEach { snap ->615            RowLine(fg) {616                Column(Modifier.weight(1f)) {617                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)618                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)619                }620                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {621                    vm.restoreVaultSnapshot(snap.id)622                }623            }624        }625        if (history.size > 3) {626            RowLine(fg) {627                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)628                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }629            }630        }631        Text(632            "${history.size} of 5 snapshots",633            color = fg.copy(alpha = 0.58f),634            fontFamily = PeachType.Mono,635            fontSize = 10.sp,636            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),637        )638    }639640    var advancedOpen by remember { mutableStateOf(false) }641    SubLabel("ADVANCED", fg)642    RowLine(fg) {643        Name("Advanced", fg)644        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }645    }646    if (advancedOpen) {647        RowLine(fg) {648            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)649            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }650        }651        RowLine(fg) {652            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)653            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }654        }655    }656}657658private fun snapshotSubLabel(snapshot: VaultSnapshot): String {659    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }660    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")661}662663private fun snapshotReasonLabel(reason: String): String = when (reason) {664    "local_save" -> "saved"665    "sync_received" -> "synced"666    "import" -> "imported"667    "restore" -> "restored"668    else -> reason669}670671private fun snapshotTimeLabel(savedAt: Long): String {672    val diff = System.currentTimeMillis() - savedAt673    if (diff < 60_000) return "Just now"674    if (diff < 3_600_000) return "${diff / 60_000}m ago"675    val date = Date(savedAt)676    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())677    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)678    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)679    if (today == thenDay) return "Today $time"680    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))681    if (yesterday == thenDay) return "Yesterday $time"682    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)683}684685/* ────────────────────────────────  Sync  ────────────────────────────────── */686687@Composable688private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {689    val syncVm: SyncViewModel = hiltViewModel()690    val pairedDevices by syncVm.pairedDevices.collectAsState()691    val pendingVouched by syncVm.pendingVouched.collectAsState()692    val syncStatus by syncVm.status.collectAsState()693    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()694    val syncMode by syncVm.syncMode.collectAsState()695    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()696    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }697    val scope = rememberCoroutineScope()698    var confirmUnpair by remember { mutableStateOf<String?>(null) }699    var rotateConfirmOpen by remember { mutableStateOf(false) }700    var rotating by remember { mutableStateOf(false) }701    var rotateError by remember { mutableStateOf<String?>(null) }702    var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(configuredRelayUrl) }703    var relayUrlStatus by remember { mutableStateOf<String?>(null) }704    var showConnection by remember { mutableStateOf(false) }705    var showRecovery by remember { mutableStateOf(false) }706707    SubLabel("ADD A DEVICE", fg, top = 0.dp)708    RowLine(fg) {709        Column(Modifier.weight(1f)) {710            Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)711            Text(712                "Let another Peach device scan this phone.",713                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,714                modifier = Modifier.padding(top = 2.dp),715            )716        }717        SubtleButton("show", fg) { onNavigate(PeachNav.CodePair) }718    }719    RowLine(fg) {720        Column(Modifier.weight(1f)) {721            Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)722            Text(723                "For a new empty phone, or to re-pair a device this vault already knows.",724                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,725                modifier = Modifier.padding(top = 2.dp),726            )727        }728        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }729    }730731    SubLabel("SYNC STATUS", fg)732    val indicator = syncIndicator(syncStatus, pairedDevices.size)733    RowLine(fg) {734        SyncIndicatorDot(735            indicator = indicator,736            label = syncIndicatorLabel(indicator, syncStatus),737            modifier = Modifier.padding(end = PeachSpacing.sm),738            size = 8.dp,739        )740        Text(741            describeSyncOutcome(syncStatus),742            color = fg.copy(alpha = 0.7f),743            fontFamily = PeachType.Body,744            fontSize = 11.sp,745            lineHeight = 16.sp,746            modifier = Modifier.weight(1f),747        )748        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {749            if (!syncStatus.syncing) syncVm.syncNow()750        }751    }752753    SubLabel("PAIRED DEVICES", fg)754    if (pairedDevices.isEmpty()) {755        PeachEmptyOrEndState(756            message = "No paired devices yet.",757            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",758            modifier = Modifier.padding(top = PeachSpacing.xs),759        )760    } else {761        pairedDevices.forEach { d ->762            RowLine(fg) {763                Column(Modifier.weight(1f)) {764                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)765                    Text(766                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",767                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,768                        modifier = Modifier.padding(top = 2.dp),769                    )770                }771                if (confirmUnpair != d.deviceId) {772                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }773                }774            }775            // HOLE 4: revocation stops this device receiving future changes. It776            // does NOT secure the vault against it — that device may already777            // hold the recovery words, which are master-password-equivalent and778            // restore the whole vault anywhere, forever, with no sync involved.779            // Say so here rather than leaving the user falsely reassured.780            if (confirmUnpair == d.deviceId) {781                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {782                    Text(783                        "Stops this device receiving future changes. It doesn't lock it " +784                            "out of what it already has - if it's lost or stolen, also " +785                            "change your master password and generate new recovery words.",786                        color = fg.copy(alpha = 0.7f),787                        fontFamily = PeachType.Body,788                        fontSize = 11.sp,789                        lineHeight = 16.sp,790                    )791                    Row(792                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),793                        horizontalArrangement = Arrangement.End,794                    ) {795                        SubtleButton("cancel", fg) { confirmUnpair = null }796                        Spacer(Modifier.width(PeachSpacing.sm))797                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }798                    }799                }800            }801        }802    }803804    SyncDisclosure(805        title = "Connection",806        summary = "Background sync, relay, and privacy choices",807        expanded = showConnection,808        fg = fg,809        onToggle = { showConnection = !showConnection },810    )811    if (showConnection) {812        RowLine(fg) {813            Column(Modifier.weight(1f)) {814                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)815                Text(816                    if (backgroundSyncEnabled) {817                        "On — keeps this phone reachable for paired devices."818                    } else {819                        "Off — Peach syncs only while you are using the app."820                    },821                    color = fg.copy(alpha = 0.7f),822                    fontFamily = PeachType.Body,823                    fontSize = 11.sp,824                    lineHeight = 16.sp,825                    modifier = Modifier.padding(top = 2.dp),826                )827            }828            SubtleButton("change", fg) { showBackgroundSyncPrompt = true }829        }830        SyncModeRow(831            title = "Public relay",832            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",833            selected = syncMode == SyncMode.PUBLIC,834            fg = fg,835        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }836        SyncModeRow(837            title = "Private relay",838            detail = "Use your own peach-relay on your own domain.",839            selected = syncMode == SyncMode.PRIVATE,840            fg = fg,841        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }842843        if (syncMode == SyncMode.PRIVATE) {844            Text(845                "RELAY URL",846                color = fg.copy(alpha = 0.6f),847                fontFamily = PeachType.Mono,848                fontSize = 8.sp,849                letterSpacing = 0.22.em,850                modifier = Modifier.padding(top = 8.dp),851            )852            Box(853                Modifier.fillMaxWidth().padding(top = 6.dp)854                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))855                    .padding(horizontal = 12.dp, vertical = 10.dp),856            ) {857                BasicTextField(858                    value = relayUrlDraft,859                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },860                    singleLine = true,861                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),862                    cursorBrush = SolidColor(fg),863                    modifier = Modifier.fillMaxWidth(),864                )865            }866            val needsRelayUrl = configuredRelayUrl.isEmpty()867            RowLine(fg) {868                val message = relayUrlStatus869                    ?: if (needsRelayUrl) {870                        "Sync is paused until you enter your relay URL."871                    } else {872                        "Only secure wss:// relays are accepted."873                    }874                val warn = relayUrlStatus?.startsWith("Use") == true ||875                    (relayUrlStatus == null && needsRelayUrl)876                ValueText(message, if (warn) peachColors.Breach else fg)877                SubtleButton("save", fg) {878                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {879                        relayUrlDraft = syncVm.privateRelayUrl.value880                        "Saved"881                    } else {882                        "Use a valid wss:// relay URL"883                    }884                }885            }886        }887888        SyncModeRow(889            title = "No sync",890            detail = "Keep this device separate and move backups by hand.",891            selected = syncMode == SyncMode.OFF,892            fg = fg,893        ) { syncVm.setSyncMode(SyncMode.OFF) }894        Hint("Pairing authorizes a device, not a cloud account.", fg)895    }896897    if (pendingVouched.isNotEmpty()) {898        SubLabel("PENDING DEVICES", fg)899        pendingVouched.forEach { dev ->900            RowLine(fg) {901                Column(Modifier.weight(1f)) {902                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)903                    Text(904                        "Was added by another device. Allow it to sync?",905                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,906                        modifier = Modifier.padding(top = 2.dp),907                    )908                }909                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {910                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }911                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }912                }913            }914        }915    }916917    SyncDisclosure(918        title = "Recovery & security",919        summary = "For a lost or stolen device",920        expanded = showRecovery,921        fg = fg,922        onToggle = { showRecovery = !showRecovery },923    )924    if (showRecovery) {925        if (rotateConfirmOpen) {926            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)927            rotateError?.let {928                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))929            }930            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {931                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }932                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {933                    if (rotating) return@SubtleButton934                    rotating = true935                    rotateError = null936                    scope.launch {937                        val ok = syncVm.rotateSyncIdentity()938                        rotating = false939                        if (ok) {940                            rotateConfirmOpen = false941                            onNavigate(PeachNav.RecoverySetup)942                        } else {943                            rotateError = "Rotation failed. Unlock your real vault and try again."944                        }945                    }946                }947            }948        } else {949            RowLine(fg) {950                Column(Modifier.weight(1f)) {951                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)952                    Text(953                        "Disconnects every device and replaces the keys used for sync.",954                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,955                        modifier = Modifier.padding(top = 2.dp),956                    )957                }958                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }959            }960        }961    }962963    if (showBackgroundSyncPrompt) {964        BackgroundSyncDialog { enabled ->965            showBackgroundSyncPrompt = false966            syncVm.setBackgroundSyncEnabled(enabled)967        }968    }969}970971private fun deviceTypeLabel(type: String?): String = when (type) {972    "android" -> "Android"973    "phone" -> "Phone"974    "tablet" -> "Tablet"975    "browser" -> "Browser"976    else -> "Device"977}978979private fun syncTimeLabel(lastSyncedAt: Long?): String {980    if (lastSyncedAt == null) return "never synced"981    val diff = System.currentTimeMillis() - lastSyncedAt982    if (diff < 60_000) return "just now"983    if (diff < 3_600_000) return "${diff / 60_000}m ago"984    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"985    return "${diff / 86_400_000}d ago"986}987988private fun isSyncStale(lastSyncedAt: Long?): Boolean =989    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L990991/* ────────────────────────────────  Pro  ─────────────────────────────────── */992993@Composable994private fun ProBody(fg: Color, vm: SettingsViewModel) {995    val ctx = LocalContext.current996    val isPro by vm.isPro.collectAsState()997    if (isPro) {998        ProActiveBody(fg, vm)999        return1000    }1001    val features = listOf(1002        "Peach Sync" to "End-to-end encrypted",1003        "Peach Codex" to "Restore from paper",1004        "Passkeys" to "Zero-knowledge WebAuthn",1005        "Advanced Vault Health" to "Reuse · age · exposure",1006        "Canary entries" to "Detects vault compromise",1007        "Duress vault" to "Decoy under coercion",1008    )1009    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1010    Text(1011        "Everything, everywhere.",1012        modifier = Modifier.padding(top = 8.dp),1013        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1014    )1015    Text(1016        "One key unlocks every Pro feature — for the lifetime of the app.",1017        modifier = Modifier.padding(top = 6.dp),1018        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1019    )1020    Spacer(Modifier.height(10.dp))1021    features.forEachIndexed { i, (name, desc) ->1022        // Compact rows (6dp vs. the shared 9dp default) — this card is the1023        // tallest in the deck (6 features + price + CTA) and used to leave1024        // its button with no breathing room before the fanned tab stacked1025        // right beneath it in preview mode.1026        RowLine(fg, verticalPadding = 6.dp) {1027            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1028                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1029                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1030            }1031            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1032        }1033    }1034    Spacer(Modifier.height(12.dp))1035    Text(1036        "$39",1037        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1038    )1039    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1040    Spacer(Modifier.height(10.dp))1041    Box(1042        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1043            .background(fg.copy(alpha = 0.16f))1044            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1045            .clickable {1046                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1047            }1048            .padding(vertical = 12.dp),1049        contentAlignment = Alignment.Center,1050    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10511052    // Trailing breathing room so the button clears the fanned tab stacked1053    // beneath this card in preview mode, where the scrollable viewport clips1054    // right around here (the license-key section below is already out of1055    // view at that height).1056    Spacer(Modifier.height(44.dp))10571058    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1059    var key by remember { mutableStateOf("") }1060    val activating by vm.activating.collectAsState()1061    val activationError by vm.activationError.collectAsState()1062    Box(1063        Modifier.fillMaxWidth().padding(top = 6.dp)1064            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1065            .padding(horizontal = 12.dp, vertical = 10.dp),1066    ) {1067        BasicTextField(1068            value = key,1069            // NOT .uppercase() — base58 (the license payload encoding) is1070            // case-sensitive; forcing the whole string to uppercase silently1071            // corrupted every real key before it ever reached signature1072            // verification, so activation always failed with a real key.1073            onValueChange = { key = it; vm.clearActivationError() },1074            singleLine = true,1075            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1076            cursorBrush = SolidColor(fg),1077            modifier = Modifier.fillMaxWidth(),1078            decorationBox = { inner ->1079                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1080                inner()1081            },1082        )1083    }1084    if (activationError != null) {1085        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1086    }1087    Spacer(Modifier.height(8.dp))1088    Box(1089        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1090            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1091            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1092            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1093            .padding(vertical = 12.dp),1094        contentAlignment = Alignment.Center,1095    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1096}10971098@Composable1099private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1100    val ctx = LocalContext.current1101    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1102    val licenseKey by vm.licenseKey.collectAsState()1103    val issuedAt by vm.licenseIssuedAt.collectAsState()1104    var showKey by remember { mutableStateOf(false) }1105    var copied by remember { mutableStateOf(false) }1106    val scope = rememberCoroutineScope()1107    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1108        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1109    }1110    Text(1111        "Peach Pro",1112        modifier = Modifier.fillMaxWidth(),1113        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1114        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1115    )1116    Text(1117        formatLicenseActiveSince(issuedAt),1118        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1119        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1120        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1121    )1122    licenseKey?.let { key ->1123        Spacer(Modifier.height(12.dp))1124        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1125        RowLine(fg) {1126            Text(1127                if (showKey) key else "************************",1128                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1129                fontFamily = PeachType.Mono,1130                fontSize = 10.sp,1131                modifier = Modifier.weight(1f),1132            )1133            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1134                SubtleButton(if (copied) "copied" else "copy", fg) {1135                    PeachClipboard.copy(ctx, "peach license", key)1136                    copied = true1137                    scope.launch {1138                        delay(1_500)1139                        copied = false1140                    }1141                }1142                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1143            }1144        }1145    }1146    Spacer(Modifier.height(12.dp))1147    features.forEachIndexed { i, name ->1148        RowLine(fg) {1149            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1150                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1151                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1152            }1153            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1154        }1155    }1156    Spacer(Modifier.height(12.dp))1157    Box(1158        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1159            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1160            .clickable { vm.deactivate() }1161            .padding(vertical = 12.dp),1162        contentAlignment = Alignment.Center,1163    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1164}11651166private fun formatLicenseActiveSince(issuedAt: Long?): String {1167    if (issuedAt == null) return "Active"1168    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1169    return "Active since $monthYear"1170}11711172/* ──────────────────────────────  Feedback  ───────────────────────────────── */11731174@OptIn(ExperimentalLayoutApi::class)1175@Composable1176private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1177    val categories = listOf("Bug report", "Feature request", "Question", "Other")1178    var category by remember { mutableStateOf(categories[0]) }1179    var message by remember { mutableStateOf("") }1180    val state by vm.feedbackState.collectAsState()11811182    // Reset transient state when the card leaves composition so reopening starts fresh.1183    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }11841185    if (state is FeedbackState.Sent) {1186        Box(1187            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1188                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1189        ) {1190            Column {1191                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1192                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))1193            }1194        }1195        return1196    }11971198    val sending = state is FeedbackState.Sending11991200    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)1201    SubLabel("TYPE", fg)1202    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1203        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1204    }1205    SubLabel("MESSAGE", fg)1206    Box(1207        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1208            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1209            .padding(10.dp),1210    ) {1211        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1212        BasicTextField(1213            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1214            enabled = !sending,1215            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1216            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1217        )1218    }1219    Text(1220        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1221        modifier = Modifier.padding(top = 6.dp),1222        color = fg.copy(alpha = 0.52f),1223        fontFamily = PeachType.Mono,1224        fontSize = 10.sp,1225        lineHeight = 14.sp,1226    )1227    Spacer(Modifier.height(8.dp))12281229    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1230        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1231        Box(1232            Modifier.clip(RoundedCornerShape(8.dp))1233                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1234                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1235                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1236                .padding(horizontal = 12.dp, vertical = 6.dp),1237        ) {1238            Text(1239                feedbackSubmitLabel(state),1240                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1241                fontFamily = PeachType.Mono, fontSize = 11.sp,1242            )1243        }1244        if (sending) {1245            CircularProgressIndicator(1246                color = LocalPeachAccent.current,1247                strokeWidth = 2.dp,1248                modifier = Modifier.size(16.dp),1249            )1250        }1251    }12521253    (state as? FeedbackState.Error)?.let { err ->1254        Text(1255            err.message,1256            modifier = Modifier.padding(top = 8.dp),1257            color = peachColors.Breach,1258            fontFamily = PeachType.Mono,1259            fontSize = 11.sp,1260            lineHeight = 15.sp,1261        )1262    }12631264    Hint("Sent anonymously · no account required", fg)1265}12661267/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */12681269@Composable1270internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1271    val configured by vm.duressConfigured.collectAsState()1272    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1273}12741275@Composable1276private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1277    Text(1278        "Duress password is active",1279        color = peachColors.HealthOk,1280        fontFamily = PeachType.Mono,1281        fontSize = 11.sp,1282        modifier = Modifier.padding(top = 6.dp),1283    )1284    Spacer(Modifier.height(8.dp))1285    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1286    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1287}12881289@Composable1290private fun DuressForm(fg: Color, vm: SettingsViewModel) {1291    var enabled by remember { mutableStateOf(false) }1292    var password by remember { mutableStateOf("") }1293    var confirm by remember { mutableStateOf("") }1294    var action by remember { mutableStateOf(DuressAction.DECOY) }1295    var error by remember { mutableStateOf<String?>(null) }1296    val scope = rememberCoroutineScope()12971298    RowLine(fg) {1299        Name("Duress password", fg)1300        Toggle(enabled, fg) { on ->1301            enabled = on1302            if (!on) { password = ""; confirm = ""; error = null }1303        }1304    }1305    Text(1306        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1307        color = fg.copy(alpha = 0.7f),1308        fontFamily = PeachType.Mono,1309        fontSize = 10.sp,1310        modifier = Modifier.padding(top = 6.dp),1311    )13121313    AnimatedVisibility(enabled) {1314        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1315            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1316            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13171318            Text(1319                "When your duress password is entered at the lock screen, Peach will:",1320                color = fg.copy(alpha = 0.7f),1321                fontFamily = PeachType.Mono,1322                fontSize = 10.sp,1323                modifier = Modifier.padding(top = 4.dp),1324            )13251326            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1327            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13281329            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13301331            val canSave = password.isNotBlank() && confirm.isNotBlank()1332            DuressSaveButton("Save duress password", fg, canSave) {1333                error = when {1334                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1335                    password != confirm -> "Passwords do not match"1336                    else -> null1337                }1338                if (error == null) scope.launch {1339                    if (vm.saveDuress(password, action)) {1340                        password = ""; confirm = ""; enabled = false1341                    } else {1342                        error = "Couldn't save — the duress passphrase must differ from your real password"1343                    }1344                }1345            }1346        }1347    }1348}13491350@Composable1351private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1352    val accent = LocalPeachAccent.current1353    Row(1354        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1355        verticalAlignment = Alignment.CenterVertically,1356        horizontalArrangement = Arrangement.spacedBy(10.dp),1357    ) {1358        Box(1359            Modifier.size(16.dp).clip(CircleShape)1360                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1361            contentAlignment = Alignment.Center,1362        ) {1363            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1364        }1365        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1366    }1367}13681369@Composable1370private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1371    val accent = LocalPeachAccent.current1372    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1373    Box(1374        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1375            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1376            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1377            .let { if (enabled) it.clickable(onClick = onClick) else it }1378            .padding(vertical = 11.dp),1379        contentAlignment = Alignment.Center,1380    ) {1381        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1382    }1383}13841385@Composable1386private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1387    Box(1388        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1389            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1390            .clickable(onClick = onClick)1391            .padding(vertical = 11.dp),1392        contentAlignment = Alignment.Center,1393    ) {1394        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1395    }1396}13971398/** One choice in the three-way sync selector. */1399@Composable1400private fun SyncModeRow(1401    title: String,1402    detail: String,1403    selected: Boolean,1404    fg: Color,1405    onSelect: () -> Unit,1406) {1407    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1408        Column(Modifier.weight(1f)) {1409            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1410            Text(1411                detail,1412                color = fg.copy(alpha = 0.7f),1413                fontFamily = PeachType.Body,1414                fontSize = 11.sp,1415                lineHeight = 16.sp,1416                modifier = Modifier.padding(top = 2.dp),1417            )1418        }1419        Text(1420            if (selected) "●" else "○",1421            color = if (selected) fg else fg.copy(alpha = 0.45f),1422            fontFamily = PeachType.Mono,1423            fontSize = 14.sp,1424            modifier = Modifier.padding(start = 10.dp),1425        )1426    }1427}