SettingsCardBodies.kt

61.0 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.privateRelayDraftOf37import com.peachpasswords.sync.syncIndicator38import com.peachpasswords.sync.syncIndicatorLabel39import com.peachpasswords.ui.components.SyncIndicatorDot40import com.peachpasswords.ui.theme.LocalPeachAccent41import com.peachpasswords.vault.DuressAction42import com.peachpasswords.ui.theme.peachColors43import androidx.compose.runtime.mutableStateOf44import androidx.compose.runtime.remember45import androidx.compose.runtime.rememberCoroutineScope46import androidx.compose.runtime.setValue47import androidx.compose.ui.Alignment48import androidx.compose.ui.Modifier49import androidx.compose.ui.draw.clip50import androidx.compose.ui.graphics.Color51import androidx.compose.ui.graphics.SolidColor52import androidx.compose.ui.platform.LocalContext53import androidx.compose.ui.text.TextStyle54import androidx.compose.ui.text.font.FontStyle55import androidx.compose.ui.text.input.PasswordVisualTransformation56import androidx.compose.ui.unit.Dp57import androidx.compose.ui.unit.dp58import androidx.compose.ui.unit.em59import androidx.compose.ui.unit.sp60import androidx.compose.material3.Text61import com.peachpasswords.BuildConfig62import com.peachpasswords.autofill.AutofillEnablement63import com.peachpasswords.ui.PeachNav64import com.peachpasswords.ui.components.PeachEmptyOrEndState65import com.peachpasswords.ui.components.PeachField66import com.peachpasswords.ui.pairing.BackgroundSyncDialog67import com.peachpasswords.ui.sync.SyncViewModel68import com.peachpasswords.ui.theme.ACCENT_COLORS69import com.peachpasswords.ui.theme.PeachSpacing70import com.peachpasswords.ui.theme.PeachType71import com.peachpasswords.ui.updates.UpdatePhase72import com.peachpasswords.ui.updates.UpdateViewModel73import androidx.hilt.navigation.compose.hiltViewModel74import com.peachpasswords.vault.VaultSettings75import com.peachpasswords.vault.VaultSnapshot76import kotlinx.coroutines.launch77import kotlinx.coroutines.delay78import java.text.SimpleDateFormat79import java.util.Date80import java.util.Locale8182private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8384/**85 * Body content for the active stack card. Everything is tinted with the card's86 * own foreground [fg] (the browser's `currentColor` convention), so each card's87 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,88 * canary, license activation) render their browser visual but defer the action89 * to a later phase — noted inline where that's the case.90 */91@Composable92fun CardBody(93    id: CardId,94    fg: Color,95    vm: SettingsViewModel,96    settings: VaultSettings,97    entriesCount: Int,98    onNavigate: (PeachNav) -> Unit,99) {100    when (id) {101        CardId.Appearance -> AppearanceBody(fg, vm, settings)102        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)103        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)104        CardId.Sync -> SyncBody(fg, onNavigate)105        CardId.Pro -> ProBody(fg, vm)106        CardId.Feedback -> {107            // The closest thing Peach has to an About surface, and where the brief108            // put the update entry point: version first, then the feedback form.109            AboutUpdatesBody(fg, onNavigate)110            FeedbackBody(fg, vm)111        }112    }113}114115/* ────────────────────────────  about / updates  ─────────────────────────── */116117@Composable118private fun AboutUpdatesBody(fg: Color, onNavigate: (PeachNav) -> Unit) {119    val vm: UpdateViewModel = hiltViewModel()120    val available by vm.available.collectAsState()121    val state by vm.state.collectAsState()122123    SubLabel("THIS BUILD", fg, top = 0.dp)124    RowLine(fg) {125        Name("Version", fg)126        ValueText(BuildConfig.VERSION_NAME, fg)127    }128    RowLine(fg) {129        Column(Modifier.weight(1f)) {130            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)131            Text(132                when {133                    available != null -> "v${available?.version} is available"134                    state.phase == UpdatePhase.Checking -> "Checking…"135                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"136                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"137                    else -> "Peach checks once a day and tells you when there's a new version."138                },139                color = fg.copy(alpha = 0.7f),140                fontFamily = PeachType.Body,141                fontSize = 11.sp,142                lineHeight = 16.sp,143                modifier = Modifier.padding(top = 2.dp),144            )145        }146        if (available != null) {147            SubtleButton("update", fg) { onNavigate(PeachNav.Update) }148        } else {149            SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {150                vm.check()151            }152        }153    }154}155156/* ─────────────────────────  shared row vocabulary  ───────────────────────── */157158@Composable159private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {160    Text(161        text,162        modifier = Modifier.padding(top = top, bottom = 4.dp),163        color = fg.copy(alpha = 0.58f),164        fontFamily = PeachType.Mono,165        fontSize = 8.sp,166        letterSpacing = 0.22.em,167    )168}169170@Composable171private fun RowLine(172    fg: Color,173    modifier: Modifier = Modifier,174    verticalPadding: Dp = 9.dp,175    content: @Composable RowScope.() -> Unit,176) {177    Row(178        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),179        verticalAlignment = Alignment.CenterVertically,180        horizontalArrangement = Arrangement.SpaceBetween,181        content = content,182    )183    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))184}185186@Composable187private fun RowScope.Name(text: String, fg: Color) {188    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))189}190191@Composable192private fun ValueText(text: String, fg: Color) {193    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)194}195196@Composable197private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {198    Box(199        Modifier.clip(RoundedCornerShape(8.dp))200            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))201            .background(fg.copy(alpha = 0.08f))202            .clickable(onClick = onClick)203            .padding(horizontal = 12.dp, vertical = 6.dp),204    ) {205        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)206    }207}208209@Composable210private fun SyncDisclosure(211    title: String,212    summary: String,213    expanded: Boolean,214    fg: Color,215    onToggle: () -> Unit,216) {217    Row(218        modifier = Modifier219            .fillMaxWidth()220            .heightIn(min = 48.dp)221            .clip(RoundedCornerShape(8.dp))222            .clickable(onClick = onToggle)223            .padding(vertical = 8.dp),224        verticalAlignment = Alignment.CenterVertically,225    ) {226        Column(Modifier.weight(1f)) {227            Text(228                title.uppercase(),229                color = fg.copy(alpha = 0.58f),230                fontFamily = PeachType.Mono,231                fontSize = 8.sp,232                letterSpacing = 0.22.em,233            )234            Text(235                summary,236                color = fg.copy(alpha = 0.7f),237                fontFamily = PeachType.Body,238                fontSize = 11.sp,239                modifier = Modifier.padding(top = 3.dp),240            )241        }242        Text(243            if (expanded) "hide" else "show",244            color = fg,245            fontFamily = PeachType.Mono,246            fontSize = 10.sp,247        )248    }249}250251@Composable252private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {253    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")254    Box(255        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))256            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))257            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))258            .clickable { onToggle(!on) },259    ) {260        Box(261            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)262                .padding(start = knob - 2.dp)263                .size(14.dp).clip(CircleShape)264                .background(if (on) fg else fg.copy(alpha = 0.58f)),265        )266    }267}268269@Composable270private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {271    Box(272        Modifier.clip(RoundedCornerShape(999.dp))273            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))274            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)275            .clickable(onClick = onClick)276            .padding(horizontal = 12.dp, vertical = 5.dp),277    ) {278        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)279    }280}281282@Composable283private fun ProBadge(fg: Color) {284    Box(285        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))286            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),287    ) {288        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)289    }290}291292@Composable293private fun Hint(text: String, fg: Color) {294    Text(295        text,296        modifier = Modifier.padding(top = 6.dp),297        color = fg.copy(alpha = 0.6f),298        fontFamily = PeachType.Display,299        fontStyle = FontStyle.Italic,300        fontSize = 11.5.sp,301        lineHeight = 16.sp,302    )303}304305/* ──────────────────────────────  Appearance  ─────────────────────────────── */306307@OptIn(ExperimentalLayoutApi::class)308@Composable309private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {310    SubLabel("ACCENT", fg, top = 0.dp)311    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {312        ACCENT_COLORS.forEach { (name, color) ->313            Box(314                Modifier.size(22.dp).clip(CircleShape).background(color)315                    .border(316                        if (s.accentColor == name) 2.dp else 1.dp,317                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),318                        CircleShape,319                    )320                    .clickable { vm.setAccent(name) },321            )322        }323    }324    SubLabel("MOTION", fg)325    RowLine(fg) {326        Name("Reduce motion", fg)327        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }328    }329}330331/* ──────────────────────────────  Protection  ─────────────────────────────── */332333@Composable334private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {335    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")336    val current = s.autoLockMinutes ?: 0337338    SubLabel("LOCKING", fg, top = 0.dp)339    RowLine(fg) {340        Name("Auto-lock after", fg)341        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)342        SubtleButton(lockOptions[idx].second, fg) {343            val next = lockOptions[(idx + 1) % lockOptions.size].first344            vm.setAutoLockMinutes(if (next == 0) 0 else next)345        }346    }347    SubLabel("BIOMETRICS", fg)348    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity349    RowLine(fg) {350        Name("Unlock with biometrics", fg)351        Toggle(s.biometricsEnabled, fg) { on ->352            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()353        }354    }355    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)356    if (bioError != null) {357        Text(358            bioError,359            color = com.peachpasswords.ui.theme.peachColors.Breach,360            fontFamily = PeachType.Mono, fontSize = 10.sp,361            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),362        )363    }364    SubLabel("AUTOFILL", fg)365    run {366        // Read live rather than cached: the user leaves for system settings and367        // comes back, so a remembered value would show the old answer.368        val ctx = LocalContext.current369        val enabled = AutofillEnablement.isEnabled(ctx)370        RowLine(fg) {371            Column(Modifier.weight(1f)) {372                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)373                Text(374                    if (enabled) {375                        "Peach is your autofill service."376                    } else {377                        "Android decides which app fills passwords. Turn Peach on in system settings."378                    },379                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,380                    modifier = Modifier.padding(top = 2.dp),381                )382            }383            if (!enabled) {384                SubtleButton("enable", fg) {385                    // Straight to the autofill picker with Peach preselected;386                    // some OEM builds ship no handler, so fall back rather than387                    // dead-ending on a button that does nothing.388                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }389                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }390                }391            } else {392                ValueText("on", fg)393            }394        }395    }396397    SubLabel("PASSKEYS", fg)398    RowLine(fg) {399        Column(Modifier.weight(1f)) {400            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)401            Text(402                "Peach can handle passkey sign-in and creation through Android Credential Manager.",403                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,404                modifier = Modifier.padding(top = 2.dp),405            )406        }407        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }408    }409    RowLine(fg) {410        Name("Status", fg)411        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)412    }413    SubLabel("RECOVERY", fg)414    RowLine(fg) {415        Column(Modifier.weight(1f)) {416            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)417            Text(418                "Keep the paper copy current before rotating sync identity or replacing devices.",419                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,420                modifier = Modifier.padding(top = 2.dp),421            )422        }423        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }424    }425    SubLabel("BREACH PROTECTION", fg)426    val checkingBreaches by vm.checkingBreaches.collectAsState()427    val breachResult by vm.breachResult.collectAsState()428    RowLine(fg) {429        Name("Breach monitoring", fg)430        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }431    }432    RowLine(fg) {433        Name("Check all passwords", fg)434        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }435    }436    breachResult?.let { result ->437        RowLine(fg) {438            Name("Last check", fg)439            ValueText(result, fg)440        }441    }442443    SubLabel("MASTER PASSWORD", fg)444    ChangePasswordRow(fg, vm)445446    SubLabel("DURESS PASSWORD", fg)447    val duressIsPro by vm.isPro.collectAsState()448    if (!duressIsPro) {449        RowLine(fg) {450            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {451                Name("Duress password", fg); ProBadge(fg)452            }453            ValueText("off", fg)454        }455        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)456    } else {457        DuressSection(fg, vm)458    }459}460461@Composable462private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {463    var open by remember { mutableStateOf(false) }464    var current by remember { mutableStateOf("") }465    var next by remember { mutableStateOf("") }466    var confirm by remember { mutableStateOf("") }467    var error by remember { mutableStateOf<String?>(null) }468    var done by remember { mutableStateOf(false) }469    val scope = rememberCoroutineScope()470471    RowLine(fg) {472        Name("Master password", fg)473        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }474    }475    AnimatedVisibility(open) {476        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {477            PwField("current password", current, fg) { current = it }478            PwField("new password (min 12 chars)", next, fg) { next = it }479            PwField("confirm new password", confirm, fg) { confirm = it }480            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }481            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)482            SubtleButton("save new password", fg) {483                error = when {484                    current.isBlank() -> "enter your current password"485                    next.length < 12 -> "new password must be at least 12 characters"486                    next != confirm -> "new passwords do not match"487                    next == current -> "new password must differ from current"488                    else -> null489                }490                if (error == null) scope.launch {491                    if (vm.changePassword(current, next)) {492                        current = ""; next = ""; confirm = ""; open = false; done = true493                    } else error = "incorrect current password"494                }495            }496        }497    }498}499500@Composable501private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {502    PeachField(503        label = placeholder,504        value = value,505        onValueChange = onChange,506        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,507        visualTransformation = PasswordVisualTransformation(),508    )509}510511/* ────────────────────────────────  Vault  ───────────────────────────────── */512513@Composable514private fun VaultBody(515    fg: Color,516    vm: SettingsViewModel,517    s: VaultSettings,518    entriesCount: Int,519    onNavigate: (PeachNav) -> Unit,520) {521    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")522    val current = s.trashRetentionDays ?: 0523    val isPro by vm.isPro.collectAsState()524    val dedupCount by vm.dedupCount.collectAsState()525    val dedupRunning by vm.dedupRunning.collectAsState()526    val dedupDone by vm.dedupDone.collectAsState()527    val history by vm.vaultHistory.collectAsState()528    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()529530    SubLabel("ENTRIES", fg, top = 0.dp)531    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }532    RowLine(fg) {533        Name("Duplicates", fg)534        val done = dedupDone535        if (done != null) {536            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)537        } else {538            val count = dedupCount539            val label = when {540                dedupRunning -> "scanning..."541                count == null -> "scan"542                count == 0 -> "none found"543                else -> "$count found"544            }545            SubtleButton(label, fg) {546                if (count == null || count == 0) vm.scanDuplicates()547            }548        }549    }550    if ((dedupCount ?: 0) > 0 && dedupDone == null) {551        Column(552            Modifier.fillMaxWidth()553                .padding(top = 2.dp, bottom = 8.dp)554                .clip(RoundedCornerShape(8.dp))555                .background(fg.copy(alpha = 0.08f))556                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))557                .padding(10.dp),558            verticalArrangement = Arrangement.spacedBy(8.dp),559        ) {560            val count = dedupCount ?: 0561            Text(562                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",563                color = fg.copy(alpha = 0.82f),564                fontFamily = PeachType.Body,565                fontSize = 11.sp,566                lineHeight = 16.sp,567            )568            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {569                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }570                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }571            }572        }573    }574575    SubLabel("MAINTENANCE", fg)576    RowLine(fg) {577        Name("Trash auto-delete", fg)578        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)579        SubtleButton(trashOptions[idx].second, fg) {580            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)581        }582    }583    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }584    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }585586    SubLabel("DATA", fg)587    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }588    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }589    RowLine(fg) {590        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }591        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }592    }593    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }594595    var historyShowAll by remember { mutableStateOf(false) }596    SubLabel("VAULT HISTORY", fg)597    if (history.isEmpty()) {598        Text(599            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",600            color = fg.copy(alpha = 0.68f),601            fontFamily = PeachType.Body,602            fontSize = 11.sp,603            lineHeight = 16.sp,604            modifier = Modifier.padding(bottom = 6.dp),605        )606    } else {607        Text(608            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",609            color = fg.copy(alpha = 0.68f),610            fontFamily = PeachType.Body,611            fontSize = 11.sp,612            lineHeight = 16.sp,613            modifier = Modifier.padding(bottom = 4.dp),614        )615        history.take(if (historyShowAll) history.size else 3).forEach { snap ->616            RowLine(fg) {617                Column(Modifier.weight(1f)) {618                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)619                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)620                }621                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {622                    vm.restoreVaultSnapshot(snap.id)623                }624            }625        }626        if (history.size > 3) {627            RowLine(fg) {628                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)629                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }630            }631        }632        Text(633            "${history.size} of 5 snapshots",634            color = fg.copy(alpha = 0.58f),635            fontFamily = PeachType.Mono,636            fontSize = 10.sp,637            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),638        )639    }640641    var advancedOpen by remember { mutableStateOf(false) }642    SubLabel("ADVANCED", fg)643    RowLine(fg) {644        Name("Advanced", fg)645        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }646    }647    if (advancedOpen) {648        RowLine(fg) {649            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)650            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }651        }652        RowLine(fg) {653            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)654            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }655        }656    }657}658659private fun snapshotSubLabel(snapshot: VaultSnapshot): String {660    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }661    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")662}663664private fun snapshotReasonLabel(reason: String): String = when (reason) {665    "local_save" -> "saved"666    "sync_received" -> "synced"667    "import" -> "imported"668    "restore" -> "restored"669    else -> reason670}671672private fun snapshotTimeLabel(savedAt: Long): String {673    val diff = System.currentTimeMillis() - savedAt674    if (diff < 60_000) return "Just now"675    if (diff < 3_600_000) return "${diff / 60_000}m ago"676    val date = Date(savedAt)677    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())678    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)679    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)680    if (today == thenDay) return "Today $time"681    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))682    if (yesterday == thenDay) return "Yesterday $time"683    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)684}685686/* ────────────────────────────────  Sync  ────────────────────────────────── */687688@Composable689private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {690    val syncVm: SyncViewModel = hiltViewModel()691    val pairedDevices by syncVm.pairedDevices.collectAsState()692    val pendingVouched by syncVm.pendingVouched.collectAsState()693    val syncStatus by syncVm.status.collectAsState()694    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()695    val syncMode by syncVm.syncMode.collectAsState()696    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()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                "This vault shows a QR. An empty device scans it and receives the vault after the codes match.",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        SubtleButton("pair", fg) { onNavigate(PeachNav.CodePair) }721    }722723    SubLabel("SYNC STATUS", fg)724    val indicator = syncIndicator(syncStatus, pairedDevices.size)725    RowLine(fg) {726        SyncIndicatorDot(727            indicator = indicator,728            label = syncIndicatorLabel(indicator, syncStatus),729            modifier = Modifier.padding(end = PeachSpacing.sm),730            size = 8.dp,731        )732        Text(733            describeSyncOutcome(syncStatus),734            color = fg.copy(alpha = 0.7f),735            fontFamily = PeachType.Body,736            fontSize = 11.sp,737            lineHeight = 16.sp,738            modifier = Modifier.weight(1f),739        )740        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {741            if (!syncStatus.syncing) syncVm.syncNow()742        }743    }744745    SubLabel("PAIRED DEVICES", fg)746    if (pairedDevices.isEmpty()) {747        PeachEmptyOrEndState(748            message = "No paired devices yet.",749            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",750            modifier = Modifier.padding(top = PeachSpacing.xs),751        )752    } else {753        pairedDevices.forEach { d ->754            RowLine(fg) {755                Column(Modifier.weight(1f)) {756                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)757                    Text(758                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",759                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,760                        modifier = Modifier.padding(top = 2.dp),761                    )762                }763                if (confirmUnpair != d.deviceId) {764                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }765                }766            }767            // HOLE 4: revocation stops this device receiving future changes. It768            // does NOT secure the vault against it — that device may already769            // hold the recovery words, which are master-password-equivalent and770            // restore the whole vault anywhere, forever, with no sync involved.771            // Say so here rather than leaving the user falsely reassured.772            if (confirmUnpair == d.deviceId) {773                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {774                    Text(775                        "Stops this device receiving future changes. It doesn't lock it " +776                            "out of what it already has - if it's lost or stolen, also " +777                            "change your master password and generate new recovery words.",778                        color = fg.copy(alpha = 0.7f),779                        fontFamily = PeachType.Body,780                        fontSize = 11.sp,781                        lineHeight = 16.sp,782                    )783                    Row(784                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),785                        horizontalArrangement = Arrangement.End,786                    ) {787                        SubtleButton("cancel", fg) { confirmUnpair = null }788                        Spacer(Modifier.width(PeachSpacing.sm))789                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }790                    }791                }792            }793        }794    }795796    SyncDisclosure(797        title = "Connection",798        summary = "Background sync, relay, and privacy choices",799        expanded = showConnection,800        fg = fg,801        onToggle = { showConnection = !showConnection },802    )803    if (showConnection) {804        RowLine(fg) {805            Column(Modifier.weight(1f)) {806                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)807                Text(808                    if (backgroundSyncEnabled) {809                        "On — keeps this phone reachable for paired devices."810                    } else {811                        "Off — Peach syncs only while you are using the app."812                    },813                    color = fg.copy(alpha = 0.7f),814                    fontFamily = PeachType.Body,815                    fontSize = 11.sp,816                    lineHeight = 16.sp,817                    modifier = Modifier.padding(top = 2.dp),818                )819            }820            SubtleButton("change", fg) { showBackgroundSyncPrompt = true }821        }822        SyncModeRow(823            title = "Public relay",824            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",825            selected = syncMode == SyncMode.PUBLIC,826            fg = fg,827        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }828        SyncModeRow(829            title = "Private relay",830            detail = "Use your own peach-relay on your own domain.",831            selected = syncMode == SyncMode.PRIVATE,832            fg = fg,833        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }834835        if (syncMode == SyncMode.PRIVATE) {836            Text(837                "RELAY URL",838                color = fg.copy(alpha = 0.6f),839                fontFamily = PeachType.Mono,840                fontSize = 8.sp,841                letterSpacing = 0.22.em,842                modifier = Modifier.padding(top = 8.dp),843            )844            Box(845                Modifier.fillMaxWidth().padding(top = 6.dp)846                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))847                    .padding(horizontal = 12.dp, vertical = 10.dp),848            ) {849                BasicTextField(850                    value = relayUrlDraft,851                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },852                    singleLine = true,853                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),854                    cursorBrush = SolidColor(fg),855                    modifier = Modifier.fillMaxWidth(),856                )857            }858            val needsRelayUrl = configuredRelayUrl.isEmpty()859            RowLine(fg) {860                val message = relayUrlStatus861                    ?: if (needsRelayUrl) {862                        "Sync is paused until you enter your relay URL."863                    } else {864                        "Only secure wss:// relays are accepted."865                    }866                val warn = relayUrlStatus?.startsWith("Use") == true ||867                    (relayUrlStatus == null && needsRelayUrl)868                ValueText(message, if (warn) peachColors.Breach else fg)869                SubtleButton("save", fg) {870                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {871                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)872                        "Saved"873                    } else {874                        "Use a valid wss:// relay URL"875                    }876                }877            }878        }879880        SyncModeRow(881            title = "No sync",882            detail = "Keep this device separate and move backups by hand.",883            selected = syncMode == SyncMode.OFF,884            fg = fg,885        ) { syncVm.setSyncMode(SyncMode.OFF) }886        Hint("Pairing authorizes a device, not a cloud account.", fg)887    }888889    if (pendingVouched.isNotEmpty()) {890        SubLabel("PENDING DEVICES", fg)891        pendingVouched.forEach { dev ->892            RowLine(fg) {893                Column(Modifier.weight(1f)) {894                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)895                    Text(896                        "Was added by another device. Allow it to sync?",897                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,898                        modifier = Modifier.padding(top = 2.dp),899                    )900                }901                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {902                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }903                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }904                }905            }906        }907    }908909    SyncDisclosure(910        title = "Recovery & security",911        summary = "For a lost or stolen device",912        expanded = showRecovery,913        fg = fg,914        onToggle = { showRecovery = !showRecovery },915    )916    if (showRecovery) {917        RowLine(fg) {918            Column(Modifier.weight(1f)) {919                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)920                Text(921                    "Scan only when this populated vault previously trusted the source device.",922                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,923                    modifier = Modifier.padding(top = 2.dp),924                )925            }926            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }927        }928        if (rotateConfirmOpen) {929            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)930            rotateError?.let {931                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))932            }933            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {934                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }935                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {936                    if (rotating) return@SubtleButton937                    rotating = true938                    rotateError = null939                    scope.launch {940                        val ok = syncVm.rotateSyncIdentity()941                        rotating = false942                        if (ok) {943                            rotateConfirmOpen = false944                            onNavigate(PeachNav.RecoverySetup)945                        } else {946                            rotateError = "Rotation failed. Unlock your real vault and try again."947                        }948                    }949                }950            }951        } else {952            RowLine(fg) {953                Column(Modifier.weight(1f)) {954                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)955                    Text(956                        "Disconnects every device and replaces the keys used for sync.",957                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,958                        modifier = Modifier.padding(top = 2.dp),959                    )960                }961                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }962            }963        }964    }965966    if (showBackgroundSyncPrompt) {967        BackgroundSyncDialog { enabled ->968            showBackgroundSyncPrompt = false969            syncVm.setBackgroundSyncEnabled(enabled)970        }971    }972}973974private fun deviceTypeLabel(type: String?): String = when (type) {975    "android" -> "Android"976    "phone" -> "Phone"977    "tablet" -> "Tablet"978    "browser" -> "Browser"979    else -> "Device"980}981982private fun syncTimeLabel(lastSyncedAt: Long?): String {983    if (lastSyncedAt == null) return "never synced"984    val diff = System.currentTimeMillis() - lastSyncedAt985    if (diff < 60_000) return "just now"986    if (diff < 3_600_000) return "${diff / 60_000}m ago"987    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"988    return "${diff / 86_400_000}d ago"989}990991private fun isSyncStale(lastSyncedAt: Long?): Boolean =992    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L993994/* ────────────────────────────────  Pro  ─────────────────────────────────── */995996@Composable997private fun ProBody(fg: Color, vm: SettingsViewModel) {998    val ctx = LocalContext.current999    val isPro by vm.isPro.collectAsState()1000    if (isPro) {1001        ProActiveBody(fg, vm)1002        return1003    }1004    val features = listOf(1005        "Peach Sync" to "End-to-end encrypted",1006        "Peach Codex" to "Restore from paper",1007        "Passkeys" to "Zero-knowledge WebAuthn",1008        "Advanced Vault Health" to "Reuse · age · exposure",1009        "Canary entries" to "Detects vault compromise",1010        "Duress vault" to "Decoy under coercion",1011    )1012    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1013    Text(1014        "Everything, everywhere.",1015        modifier = Modifier.padding(top = 8.dp),1016        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1017    )1018    Text(1019        "One key unlocks every Pro feature — for the lifetime of the app.",1020        modifier = Modifier.padding(top = 6.dp),1021        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1022    )1023    Spacer(Modifier.height(10.dp))1024    features.forEachIndexed { i, (name, desc) ->1025        // Compact rows (6dp vs. the shared 9dp default) — this card is the1026        // tallest in the deck (6 features + price + CTA) and used to leave1027        // its button with no breathing room before the fanned tab stacked1028        // right beneath it in preview mode.1029        RowLine(fg, verticalPadding = 6.dp) {1030            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1031                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1032                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1033            }1034            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1035        }1036    }1037    Spacer(Modifier.height(12.dp))1038    Text(1039        "$39",1040        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1041    )1042    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1043    Spacer(Modifier.height(10.dp))1044    Box(1045        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1046            .background(fg.copy(alpha = 0.16f))1047            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1048            .clickable {1049                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1050            }1051            .padding(vertical = 12.dp),1052        contentAlignment = Alignment.Center,1053    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10541055    // Trailing breathing room so the button clears the fanned tab stacked1056    // beneath this card in preview mode, where the scrollable viewport clips1057    // right around here (the license-key section below is already out of1058    // view at that height).1059    Spacer(Modifier.height(44.dp))10601061    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1062    var key by remember { mutableStateOf("") }1063    val activating by vm.activating.collectAsState()1064    val activationError by vm.activationError.collectAsState()1065    Box(1066        Modifier.fillMaxWidth().padding(top = 6.dp)1067            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1068            .padding(horizontal = 12.dp, vertical = 10.dp),1069    ) {1070        BasicTextField(1071            value = key,1072            // NOT .uppercase() — base58 (the license payload encoding) is1073            // case-sensitive; forcing the whole string to uppercase silently1074            // corrupted every real key before it ever reached signature1075            // verification, so activation always failed with a real key.1076            onValueChange = { key = it; vm.clearActivationError() },1077            singleLine = true,1078            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1079            cursorBrush = SolidColor(fg),1080            modifier = Modifier.fillMaxWidth(),1081            decorationBox = { inner ->1082                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1083                inner()1084            },1085        )1086    }1087    if (activationError != null) {1088        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1089    }1090    Spacer(Modifier.height(8.dp))1091    Box(1092        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1093            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1094            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1095            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1096            .padding(vertical = 12.dp),1097        contentAlignment = Alignment.Center,1098    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1099}11001101@Composable1102private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1103    val ctx = LocalContext.current1104    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1105    val licenseKey by vm.licenseKey.collectAsState()1106    val issuedAt by vm.licenseIssuedAt.collectAsState()1107    var showKey by remember { mutableStateOf(false) }1108    var copied by remember { mutableStateOf(false) }1109    val scope = rememberCoroutineScope()1110    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1111        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1112    }1113    Text(1114        "Peach Pro",1115        modifier = Modifier.fillMaxWidth(),1116        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1117        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1118    )1119    Text(1120        formatLicenseActiveSince(issuedAt),1121        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1122        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1123        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1124    )1125    licenseKey?.let { key ->1126        Spacer(Modifier.height(12.dp))1127        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1128        RowLine(fg) {1129            Text(1130                if (showKey) key else "************************",1131                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1132                fontFamily = PeachType.Mono,1133                fontSize = 10.sp,1134                modifier = Modifier.weight(1f),1135            )1136            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1137                SubtleButton(if (copied) "copied" else "copy", fg) {1138                    PeachClipboard.copy(ctx, "peach license", key)1139                    copied = true1140                    scope.launch {1141                        delay(1_500)1142                        copied = false1143                    }1144                }1145                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1146            }1147        }1148    }1149    Spacer(Modifier.height(12.dp))1150    features.forEachIndexed { i, name ->1151        RowLine(fg) {1152            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1153                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1154                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1155            }1156            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1157        }1158    }1159    Spacer(Modifier.height(12.dp))1160    Box(1161        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1162            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1163            .clickable { vm.deactivate() }1164            .padding(vertical = 12.dp),1165        contentAlignment = Alignment.Center,1166    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1167}11681169private fun formatLicenseActiveSince(issuedAt: Long?): String {1170    if (issuedAt == null) return "Active"1171    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1172    return "Active since $monthYear"1173}11741175/* ──────────────────────────────  Feedback  ───────────────────────────────── */11761177@OptIn(ExperimentalLayoutApi::class)1178@Composable1179private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1180    val categories = listOf("Bug report", "Feature request", "Question", "Other")1181    var category by remember { mutableStateOf(categories[0]) }1182    var message by remember { mutableStateOf("") }1183    val state by vm.feedbackState.collectAsState()11841185    // Reset transient state when the card leaves composition so reopening starts fresh.1186    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }11871188    if (state is FeedbackState.Sent) {1189        Box(1190            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1191                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1192        ) {1193            Column {1194                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1195                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))1196            }1197        }1198        return1199    }12001201    val sending = state is FeedbackState.Sending12021203    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)1204    SubLabel("TYPE", fg)1205    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1206        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1207    }1208    SubLabel("MESSAGE", fg)1209    Box(1210        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1211            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1212            .padding(10.dp),1213    ) {1214        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1215        BasicTextField(1216            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1217            enabled = !sending,1218            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1219            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1220        )1221    }1222    Text(1223        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1224        modifier = Modifier.padding(top = 6.dp),1225        color = fg.copy(alpha = 0.52f),1226        fontFamily = PeachType.Mono,1227        fontSize = 10.sp,1228        lineHeight = 14.sp,1229    )1230    Spacer(Modifier.height(8.dp))12311232    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1233        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1234        Box(1235            Modifier.clip(RoundedCornerShape(8.dp))1236                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1237                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1238                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1239                .padding(horizontal = 12.dp, vertical = 6.dp),1240        ) {1241            Text(1242                feedbackSubmitLabel(state),1243                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1244                fontFamily = PeachType.Mono, fontSize = 11.sp,1245            )1246        }1247        if (sending) {1248            CircularProgressIndicator(1249                color = LocalPeachAccent.current,1250                strokeWidth = 2.dp,1251                modifier = Modifier.size(16.dp),1252            )1253        }1254    }12551256    (state as? FeedbackState.Error)?.let { err ->1257        Text(1258            err.message,1259            modifier = Modifier.padding(top = 8.dp),1260            color = peachColors.Breach,1261            fontFamily = PeachType.Mono,1262            fontSize = 11.sp,1263            lineHeight = 15.sp,1264        )1265    }12661267    Hint("Sent anonymously · no account required", fg)1268}12691270/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */12711272@Composable1273internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1274    val configured by vm.duressConfigured.collectAsState()1275    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1276}12771278@Composable1279private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1280    Text(1281        "Duress password is active",1282        color = peachColors.HealthOk,1283        fontFamily = PeachType.Mono,1284        fontSize = 11.sp,1285        modifier = Modifier.padding(top = 6.dp),1286    )1287    Spacer(Modifier.height(8.dp))1288    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1289    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1290}12911292@Composable1293private fun DuressForm(fg: Color, vm: SettingsViewModel) {1294    var enabled by remember { mutableStateOf(false) }1295    var password by remember { mutableStateOf("") }1296    var confirm by remember { mutableStateOf("") }1297    var action by remember { mutableStateOf(DuressAction.DECOY) }1298    var error by remember { mutableStateOf<String?>(null) }1299    val scope = rememberCoroutineScope()13001301    RowLine(fg) {1302        Name("Duress password", fg)1303        Toggle(enabled, fg) { on ->1304            enabled = on1305            if (!on) { password = ""; confirm = ""; error = null }1306        }1307    }1308    Text(1309        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1310        color = fg.copy(alpha = 0.7f),1311        fontFamily = PeachType.Mono,1312        fontSize = 10.sp,1313        modifier = Modifier.padding(top = 6.dp),1314    )13151316    AnimatedVisibility(enabled) {1317        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1318            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1319            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13201321            Text(1322                "When your duress password is entered at the lock screen, Peach will:",1323                color = fg.copy(alpha = 0.7f),1324                fontFamily = PeachType.Mono,1325                fontSize = 10.sp,1326                modifier = Modifier.padding(top = 4.dp),1327            )13281329            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1330            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13311332            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13331334            val canSave = password.isNotBlank() && confirm.isNotBlank()1335            DuressSaveButton("Save duress password", fg, canSave) {1336                error = when {1337                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1338                    password != confirm -> "Passwords do not match"1339                    else -> null1340                }1341                if (error == null) scope.launch {1342                    if (vm.saveDuress(password, action)) {1343                        password = ""; confirm = ""; enabled = false1344                    } else {1345                        error = "Couldn't save — the duress passphrase must differ from your real password"1346                    }1347                }1348            }1349        }1350    }1351}13521353@Composable1354private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1355    val accent = LocalPeachAccent.current1356    Row(1357        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1358        verticalAlignment = Alignment.CenterVertically,1359        horizontalArrangement = Arrangement.spacedBy(10.dp),1360    ) {1361        Box(1362            Modifier.size(16.dp).clip(CircleShape)1363                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1364            contentAlignment = Alignment.Center,1365        ) {1366            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1367        }1368        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1369    }1370}13711372@Composable1373private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1374    val accent = LocalPeachAccent.current1375    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1376    Box(1377        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1378            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1379            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1380            .let { if (enabled) it.clickable(onClick = onClick) else it }1381            .padding(vertical = 11.dp),1382        contentAlignment = Alignment.Center,1383    ) {1384        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1385    }1386}13871388@Composable1389private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1390    Box(1391        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1392            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1393            .clickable(onClick = onClick)1394            .padding(vertical = 11.dp),1395        contentAlignment = Alignment.Center,1396    ) {1397        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1398    }1399}14001401/** One choice in the three-way sync selector. */1402@Composable1403private fun SyncModeRow(1404    title: String,1405    detail: String,1406    selected: Boolean,1407    fg: Color,1408    onSelect: () -> Unit,1409) {1410    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1411        Column(Modifier.weight(1f)) {1412            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1413            Text(1414                detail,1415                color = fg.copy(alpha = 0.7f),1416                fontFamily = PeachType.Body,1417                fontSize = 11.sp,1418                lineHeight = 16.sp,1419                modifier = Modifier.padding(top = 2.dp),1420            )1421        }1422        Text(1423            if (selected) "●" else "○",1424            color = if (selected) fg else fg.copy(alpha = 0.45f),1425            fontFamily = PeachType.Mono,1426            fontSize = 14.sp,1427            modifier = Modifier.padding(start = 10.dp),1428        )1429    }1430}