SettingsCardBodies.kt

62.1 KB · raw

1package com.peachpasswords.ui.settings23import android.content.Intent4import android.net.Uri5import androidx.compose.animation.AnimatedVisibility6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.FlowRow14import androidx.compose.foundation.layout.ExperimentalLayoutApi15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.RowScope17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.heightIn21import androidx.compose.foundation.layout.padding22import androidx.compose.foundation.layout.size23import androidx.compose.foundation.layout.width24import androidx.compose.foundation.shape.CircleShape25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.runtime.Composable28import androidx.compose.material3.CircularProgressIndicator29import androidx.compose.runtime.DisposableEffect30import androidx.compose.runtime.collectAsState31import androidx.compose.runtime.getValue32import com.peachpasswords.feedback.FeedbackState33import com.peachpasswords.shared.PeachClipboard34import com.peachpasswords.sync.SyncMode35import com.peachpasswords.sync.canShowBackgroundSyncNotification36import com.peachpasswords.sync.describeSyncOutcome37import com.peachpasswords.sync.privateRelayDraftOf38import com.peachpasswords.sync.syncIndicator39import com.peachpasswords.sync.syncIndicatorLabel40import com.peachpasswords.ui.components.SyncIndicatorDot41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.vault.DuressAction43import com.peachpasswords.ui.theme.peachColors44import androidx.compose.runtime.mutableStateOf45import androidx.compose.runtime.remember46import androidx.compose.runtime.rememberCoroutineScope47import androidx.compose.runtime.setValue48import androidx.compose.ui.Alignment49import androidx.compose.ui.Modifier50import androidx.compose.ui.draw.clip51import androidx.compose.ui.graphics.Color52import androidx.compose.ui.graphics.SolidColor53import androidx.compose.ui.platform.LocalContext54import androidx.compose.ui.text.TextStyle55import androidx.compose.ui.text.font.FontStyle56import androidx.compose.ui.text.input.PasswordVisualTransformation57import androidx.compose.ui.unit.Dp58import androidx.compose.ui.unit.dp59import androidx.compose.ui.unit.em60import androidx.compose.ui.unit.sp61import androidx.compose.material3.Text62import com.peachpasswords.BuildConfig63import com.peachpasswords.autofill.AutofillEnablement64import com.peachpasswords.ui.PeachNav65import com.peachpasswords.ui.components.PeachEmptyOrEndState66import com.peachpasswords.ui.components.PeachField67import com.peachpasswords.ui.pairing.BackgroundSyncDialog68import com.peachpasswords.ui.sync.SyncViewModel69import com.peachpasswords.ui.theme.ACCENT_COLORS70import com.peachpasswords.ui.theme.PeachSpacing71import com.peachpasswords.ui.theme.PeachType72import com.peachpasswords.ui.updates.UpdatePhase73import com.peachpasswords.ui.updates.UpdateViewModel74import androidx.hilt.navigation.compose.hiltViewModel75import com.peachpasswords.vault.VaultSettings76import com.peachpasswords.vault.VaultSnapshot77import kotlinx.coroutines.launch78import kotlinx.coroutines.delay79import java.text.SimpleDateFormat80import java.util.Date81import java.util.Locale8283private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8485/**86 * Body content for the active stack card. Everything is tinted with the card's87 * own foreground [fg] (the browser's `currentColor` convention), so each card's88 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,89 * canary, license activation) render their browser visual but defer the action90 * to a later phase — noted inline where that's the case.91 */92@Composable93fun CardBody(94    id: CardId,95    fg: Color,96    vm: SettingsViewModel,97    settings: VaultSettings,98    entriesCount: Int,99    onNavigate: (PeachNav) -> Unit,100) {101    when (id) {102        CardId.Appearance -> AppearanceBody(fg, vm, settings)103        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)104        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)105        CardId.Sync -> SyncBody(fg, onNavigate)106        CardId.Pro -> ProBody(fg, vm)107        CardId.Feedback -> FeedbackBody(fg, vm)108    }109}110111/* ─────────────────────────  shared row vocabulary  ───────────────────────── */112113@Composable114private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {115    Text(116        text,117        modifier = Modifier.padding(top = top, bottom = 4.dp),118        color = fg.copy(alpha = 0.58f),119        fontFamily = PeachType.Mono,120        fontSize = 8.sp,121        letterSpacing = 0.22.em,122    )123}124125@Composable126private fun RowLine(127    fg: Color,128    modifier: Modifier = Modifier,129    verticalPadding: Dp = 9.dp,130    content: @Composable RowScope.() -> Unit,131) {132    Row(133        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),134        verticalAlignment = Alignment.CenterVertically,135        horizontalArrangement = Arrangement.SpaceBetween,136        content = content,137    )138    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))139}140141@Composable142private fun RowScope.Name(text: String, fg: Color) {143    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))144}145146@Composable147private fun ValueText(text: String, fg: Color) {148    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)149}150151@Composable152private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {153    Box(154        Modifier.clip(RoundedCornerShape(8.dp))155            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))156            .background(fg.copy(alpha = 0.08f))157            .clickable(onClick = onClick)158            .padding(horizontal = 12.dp, vertical = 6.dp),159    ) {160        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)161    }162}163164@Composable165private fun SyncDisclosure(166    title: String,167    summary: String,168    expanded: Boolean,169    fg: Color,170    onToggle: () -> Unit,171) {172    Row(173        modifier = Modifier174            .fillMaxWidth()175            .heightIn(min = 48.dp)176            .clip(RoundedCornerShape(8.dp))177            .clickable(onClick = onToggle)178            .padding(vertical = 8.dp),179        verticalAlignment = Alignment.CenterVertically,180    ) {181        Column(Modifier.weight(1f)) {182            Text(183                title.uppercase(),184                color = fg.copy(alpha = 0.58f),185                fontFamily = PeachType.Mono,186                fontSize = 8.sp,187                letterSpacing = 0.22.em,188            )189            Text(190                summary,191                color = fg.copy(alpha = 0.7f),192                fontFamily = PeachType.Body,193                fontSize = 11.sp,194                modifier = Modifier.padding(top = 3.dp),195            )196        }197        Text(198            if (expanded) "hide" else "show",199            color = fg,200            fontFamily = PeachType.Mono,201            fontSize = 10.sp,202        )203    }204}205206@Composable207private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {208    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")209    Box(210        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))211            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))212            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))213            .clickable { onToggle(!on) },214    ) {215        Box(216            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)217                .padding(start = knob - 2.dp)218                .size(14.dp).clip(CircleShape)219                .background(if (on) fg else fg.copy(alpha = 0.58f)),220        )221    }222}223224@Composable225private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {226    Box(227        Modifier.clip(RoundedCornerShape(999.dp))228            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))229            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)230            .clickable(onClick = onClick)231            .padding(horizontal = 12.dp, vertical = 5.dp),232    ) {233        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)234    }235}236237@Composable238private fun ProBadge(fg: Color) {239    Box(240        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))241            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),242    ) {243        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)244    }245}246247@Composable248private fun Hint(text: String, fg: Color) {249    Text(250        text,251        modifier = Modifier.padding(top = 6.dp),252        color = fg.copy(alpha = 0.6f),253        fontFamily = PeachType.Display,254        fontStyle = FontStyle.Italic,255        fontSize = 11.5.sp,256        lineHeight = 16.sp,257    )258}259260/* ──────────────────────────────  Appearance  ─────────────────────────────── */261262@OptIn(ExperimentalLayoutApi::class)263@Composable264private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {265    SubLabel("ACCENT", fg, top = 0.dp)266    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {267        ACCENT_COLORS.forEach { (name, color) ->268            Box(269                Modifier.size(22.dp).clip(CircleShape).background(color)270                    .border(271                        if (s.accentColor == name) 2.dp else 1.dp,272                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),273                        CircleShape,274                    )275                    .clickable { vm.setAccent(name) },276            )277        }278    }279    SubLabel("MOTION", fg)280    RowLine(fg) {281        Name("Reduce motion", fg)282        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }283    }284}285286/* ──────────────────────────────  Protection  ─────────────────────────────── */287288@Composable289private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {290    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")291    val current = s.autoLockMinutes ?: 0292293    SubLabel("LOCKING", fg, top = 0.dp)294    RowLine(fg) {295        Name("Auto-lock after", fg)296        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)297        SubtleButton(lockOptions[idx].second, fg) {298            val next = lockOptions[(idx + 1) % lockOptions.size].first299            vm.setAutoLockMinutes(if (next == 0) 0 else next)300        }301    }302    SubLabel("BIOMETRICS", fg)303    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity304    RowLine(fg) {305        Name("Unlock with biometrics", fg)306        Toggle(s.biometricsEnabled, fg) { on ->307            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()308        }309    }310    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)311    if (bioError != null) {312        Text(313            bioError,314            color = com.peachpasswords.ui.theme.peachColors.Breach,315            fontFamily = PeachType.Mono, fontSize = 10.sp,316            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),317        )318    }319    SubLabel("AUTOFILL", fg)320    run {321        // Read live rather than cached: the user leaves for system settings and322        // comes back, so a remembered value would show the old answer.323        val ctx = LocalContext.current324        val enabled = AutofillEnablement.isEnabled(ctx)325        RowLine(fg) {326            Column(Modifier.weight(1f)) {327                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)328                Text(329                    if (enabled) {330                        "Peach is your autofill service."331                    } else {332                        "Android decides which app fills passwords. Turn Peach on in system settings."333                    },334                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,335                    modifier = Modifier.padding(top = 2.dp),336                )337            }338            if (!enabled) {339                SubtleButton("enable", fg) {340                    // Straight to the autofill picker with Peach preselected;341                    // some OEM builds ship no handler, so fall back rather than342                    // dead-ending on a button that does nothing.343                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }344                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }345                }346            } else {347                ValueText("on", fg)348            }349        }350    }351352    SubLabel("PASSKEYS", fg)353    RowLine(fg) {354        Column(Modifier.weight(1f)) {355            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)356            Text(357                "Peach can handle passkey sign-in and creation through Android Credential Manager.",358                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,359                modifier = Modifier.padding(top = 2.dp),360            )361        }362        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }363    }364    RowLine(fg) {365        Name("Status", fg)366        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)367    }368    SubLabel("RECOVERY", fg)369    RowLine(fg) {370        Column(Modifier.weight(1f)) {371            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)372            Text(373                "Keep the paper copy current before rotating sync identity or replacing devices.",374                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,375                modifier = Modifier.padding(top = 2.dp),376            )377        }378        SubtleButton("view", fg) {379            if (bioActivity != null) {380                com.peachpasswords.ui.lock.Biometrics.authenticate(381                    activity = bioActivity,382                    title = "View recovery words",383                    subtitle = "Confirm your identity to view your recovery words",384                    onSuccess = { onNavigate(PeachNav.RecoveryView) },385                )386            } else onNavigate(PeachNav.RecoveryView)387        }388    }389    SubLabel("BREACH PROTECTION", fg)390    val checkingBreaches by vm.checkingBreaches.collectAsState()391    val breachResult by vm.breachResult.collectAsState()392    RowLine(fg) {393        Name("Breach monitoring", fg)394        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }395    }396    RowLine(fg) {397        Name("Check all passwords", fg)398        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }399    }400    breachResult?.let { result ->401        RowLine(fg) {402            Name("Last check", fg)403            ValueText(result, fg)404        }405    }406407    SubLabel("MASTER PASSWORD", fg)408    ChangePasswordRow(fg, vm)409410    SubLabel("DURESS PASSWORD", fg)411    val duressIsPro by vm.isPro.collectAsState()412    if (!duressIsPro) {413        RowLine(fg) {414            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {415                Name("Duress password", fg); ProBadge(fg)416            }417            ValueText("off", fg)418        }419        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)420    } else {421        DuressSection(fg, vm)422    }423}424425@Composable426private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {427    var open by remember { mutableStateOf(false) }428    var current by remember { mutableStateOf("") }429    var next by remember { mutableStateOf("") }430    var confirm by remember { mutableStateOf("") }431    var error by remember { mutableStateOf<String?>(null) }432    var done by remember { mutableStateOf(false) }433    val scope = rememberCoroutineScope()434435    RowLine(fg) {436        Name("Master password", fg)437        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }438    }439    AnimatedVisibility(open) {440        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {441            PwField("current password", current, fg) { current = it }442            PwField("new password (min 12 chars)", next, fg) { next = it }443            PwField("confirm new password", confirm, fg) { confirm = it }444            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }445            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)446            SubtleButton("save new password", fg) {447                error = when {448                    current.isBlank() -> "enter your current password"449                    next.length < 12 -> "new password must be at least 12 characters"450                    next != confirm -> "new passwords do not match"451                    next == current -> "new password must differ from current"452                    else -> null453                }454                if (error == null) scope.launch {455                    if (vm.changePassword(current, next)) {456                        current = ""; next = ""; confirm = ""; open = false; done = true457                    } else error = "incorrect current password"458                }459            }460        }461    }462}463464@Composable465private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {466    PeachField(467        label = placeholder,468        value = value,469        onValueChange = onChange,470        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,471        visualTransformation = PasswordVisualTransformation(),472    )473}474475/* ────────────────────────────────  Vault  ───────────────────────────────── */476477@Composable478private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {479    val vm: UpdateViewModel = hiltViewModel()480    val available by vm.available.collectAsState()481    val state by vm.state.collectAsState()482483    RowLine(fg) {484        Name("Version", fg)485        ValueText(BuildConfig.VERSION_NAME, fg)486    }487    RowLine(fg) {488        Column(Modifier.weight(1f)) {489            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)490            Text(491                when {492                    available != null -> "v${available?.version} is available"493                    state.phase == UpdatePhase.Checking -> "Checking…"494                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"495                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"496                    else -> "Peach checks once a day and tells you when there's a new version."497                },498                color = fg.copy(alpha = 0.7f),499                fontFamily = PeachType.Body,500                fontSize = 11.sp,501                lineHeight = 16.sp,502                modifier = Modifier.padding(top = 2.dp),503            )504        }505        if (available != null) {506            SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }507        } else {508            SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {509                vm.check()510            }511        }512    }513}514515@Composable516private fun VaultBody(517    fg: Color,518    vm: SettingsViewModel,519    s: VaultSettings,520    entriesCount: Int,521    onNavigate: (PeachNav) -> Unit,522) {523    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")524    val current = s.trashRetentionDays ?: 0525    val isPro by vm.isPro.collectAsState()526    val dedupCount by vm.dedupCount.collectAsState()527    val dedupRunning by vm.dedupRunning.collectAsState()528    val dedupDone by vm.dedupDone.collectAsState()529    val history by vm.vaultHistory.collectAsState()530    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()531532    SubLabel("ENTRIES", fg, top = 0.dp)533    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }534    RowLine(fg) {535        Name("Duplicates", fg)536        val done = dedupDone537        if (done != null) {538            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)539        } else {540            val count = dedupCount541            val label = when {542                dedupRunning -> "scanning..."543                count == null -> "scan"544                count == 0 -> "none found"545                else -> "$count found"546            }547            SubtleButton(label, fg) {548                if (count == null || count == 0) vm.scanDuplicates()549            }550        }551    }552    if ((dedupCount ?: 0) > 0 && dedupDone == null) {553        Column(554            Modifier.fillMaxWidth()555                .padding(top = 2.dp, bottom = 8.dp)556                .clip(RoundedCornerShape(8.dp))557                .background(fg.copy(alpha = 0.08f))558                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))559                .padding(10.dp),560            verticalArrangement = Arrangement.spacedBy(8.dp),561        ) {562            val count = dedupCount ?: 0563            Text(564                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",565                color = fg.copy(alpha = 0.82f),566                fontFamily = PeachType.Body,567                fontSize = 11.sp,568                lineHeight = 16.sp,569            )570            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {571                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }572                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }573            }574        }575    }576577    SubLabel("MAINTENANCE", fg)578    RowLine(fg) {579        Name("Trash auto-delete", fg)580        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)581        SubtleButton(trashOptions[idx].second, fg) {582            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)583        }584    }585    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }586    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }587588    SubLabel("DATA", fg)589    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }590    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }591    RowLine(fg) {592        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }593        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }594    }595    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }596597    var historyShowAll by remember { mutableStateOf(false) }598    SubLabel("UPDATES", fg)599    UpdatesRows(fg, onNavigate)600601    SubLabel("VAULT HISTORY", fg)602    if (history.isEmpty()) {603        Text(604            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",605            color = fg.copy(alpha = 0.68f),606            fontFamily = PeachType.Body,607            fontSize = 11.sp,608            lineHeight = 16.sp,609            modifier = Modifier.padding(bottom = 6.dp),610        )611    } else {612        Text(613            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",614            color = fg.copy(alpha = 0.68f),615            fontFamily = PeachType.Body,616            fontSize = 11.sp,617            lineHeight = 16.sp,618            modifier = Modifier.padding(bottom = 4.dp),619        )620        history.take(if (historyShowAll) history.size else 3).forEach { snap ->621            RowLine(fg) {622                Column(Modifier.weight(1f)) {623                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)624                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)625                }626                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {627                    vm.restoreVaultSnapshot(snap.id)628                }629            }630        }631        if (history.size > 3) {632            RowLine(fg) {633                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)634                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }635            }636        }637        Text(638            "${history.size} of 5 snapshots",639            color = fg.copy(alpha = 0.58f),640            fontFamily = PeachType.Mono,641            fontSize = 10.sp,642            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),643        )644    }645646    var advancedOpen by remember { mutableStateOf(false) }647    SubLabel("ADVANCED", fg)648    RowLine(fg) {649        Name("Advanced", fg)650        SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }651    }652    if (advancedOpen) {653        RowLine(fg) {654            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)655            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }656        }657        RowLine(fg) {658            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)659            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }660        }661    }662}663664private fun snapshotSubLabel(snapshot: VaultSnapshot): String {665    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }666    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")667}668669private fun snapshotReasonLabel(reason: String): String = when (reason) {670    "local_save" -> "saved"671    "sync_received" -> "synced"672    "import" -> "imported"673    "restore" -> "restored"674    else -> reason675}676677private fun snapshotTimeLabel(savedAt: Long): String {678    val diff = System.currentTimeMillis() - savedAt679    if (diff < 60_000) return "Just now"680    if (diff < 3_600_000) return "${diff / 60_000}m ago"681    val date = Date(savedAt)682    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())683    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)684    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)685    if (today == thenDay) return "Today $time"686    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))687    if (yesterday == thenDay) return "Yesterday $time"688    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)689}690691/* ────────────────────────────────  Sync  ────────────────────────────────── */692693@Composable694private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {695    val context = LocalContext.current696    val syncVm: SyncViewModel = hiltViewModel()697    val pairedDevices by syncVm.pairedDevices.collectAsState()698    val pendingVouched by syncVm.pendingVouched.collectAsState()699    val syncStatus by syncVm.status.collectAsState()700    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()701    val syncMode by syncVm.syncMode.collectAsState()702    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()703    var notificationsAvailable by remember {704        mutableStateOf(canShowBackgroundSyncNotification(context))705    }706    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }707    val scope = rememberCoroutineScope()708    var confirmUnpair by remember { mutableStateOf<String?>(null) }709    var rotateConfirmOpen by remember { mutableStateOf(false) }710    var rotating by remember { mutableStateOf(false) }711    var rotateError by remember { mutableStateOf<String?>(null) }712    var relayUrlDraft by remember(configuredRelayUrl) {713        mutableStateOf(privateRelayDraftOf(configuredRelayUrl))714    }715    var relayUrlStatus by remember { mutableStateOf<String?>(null) }716    var showConnection by remember { mutableStateOf(false) }717    var showRecovery by remember { mutableStateOf(false) }718719    SubLabel("ADD A DEVICE", fg, top = 0.dp)720    RowLine(fg) {721        Column(Modifier.weight(1f)) {722            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)723            Text(724                "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",725                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,726                modifier = Modifier.padding(top = 2.dp),727            )728        }729        // "Scan" opens the camera-driven AdoptVault flow: the phone is the730        // receiver. "Show a code" opens CodePair: the phone is the source.731        // Both are valid pairing entry points; without scan, a phone that wants732        // to join an existing browser-only vault has no path.733        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }734        Spacer(Modifier.width(PeachSpacing.xs))735        SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }736    }737738    SubLabel("SYNC STATUS", fg)739    val indicator = syncIndicator(syncStatus, pairedDevices.size)740    RowLine(fg) {741        SyncIndicatorDot(742            indicator = indicator,743            label = syncIndicatorLabel(indicator, syncStatus),744            modifier = Modifier.padding(end = PeachSpacing.sm),745            size = 8.dp,746        )747        Text(748            describeSyncOutcome(syncStatus),749            color = fg.copy(alpha = 0.7f),750            fontFamily = PeachType.Body,751            fontSize = 11.sp,752            lineHeight = 16.sp,753            modifier = Modifier.weight(1f),754        )755        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {756            if (!syncStatus.syncing) syncVm.syncNow()757        }758    }759760    SubLabel("PAIRED DEVICES", fg)761    if (pairedDevices.isEmpty()) {762        PeachEmptyOrEndState(763            message = "No paired devices yet.",764            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",765            modifier = Modifier.padding(top = PeachSpacing.xs),766        )767    } else {768        pairedDevices.forEach { d ->769            RowLine(fg) {770                Column(Modifier.weight(1f)) {771                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)772                    Text(773                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",774                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,775                        modifier = Modifier.padding(top = 2.dp),776                    )777                }778                if (confirmUnpair != d.deviceId) {779                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }780                }781            }782            // HOLE 4: revocation stops this device receiving future changes. It783            // does NOT secure the vault against it — that device may already784            // hold the recovery words, which are master-password-equivalent and785            // restore the whole vault anywhere, forever, with no sync involved.786            // Say so here rather than leaving the user falsely reassured.787            if (confirmUnpair == d.deviceId) {788                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {789                    Text(790                        "Stops this device receiving future changes. It doesn't lock it " +791                            "out of what it already has - if it's lost or stolen, also " +792                            "change your master password and generate new recovery words.",793                        color = fg.copy(alpha = 0.7f),794                        fontFamily = PeachType.Body,795                        fontSize = 11.sp,796                        lineHeight = 16.sp,797                    )798                    Row(799                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),800                        horizontalArrangement = Arrangement.End,801                    ) {802                        SubtleButton("cancel", fg) { confirmUnpair = null }803                        Spacer(Modifier.width(PeachSpacing.sm))804                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }805                    }806                }807            }808        }809    }810811    SyncDisclosure(812        title = "Connection",813        summary = "Background sync, relay, and privacy choices",814        expanded = showConnection,815        fg = fg,816        onToggle = { showConnection = !showConnection },817    )818    if (showConnection) {819        RowLine(fg) {820            Column(Modifier.weight(1f)) {821                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)822                Text(823                    when {824                        backgroundSyncEnabled && !notificationsAvailable ->825                            "Action needed — Android notifications are off, so background sync is not visible."826                        backgroundSyncEnabled ->827                            "On — keeps this phone reachable for paired devices."828                        else ->829                            "Off — Peach syncs only while you are using the app."830                    },831                    color = fg.copy(alpha = 0.7f),832                    fontFamily = PeachType.Body,833                    fontSize = 11.sp,834                    lineHeight = 16.sp,835                    modifier = Modifier.padding(top = 2.dp),836                )837            }838            SubtleButton(839                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",840                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,841            ) { showBackgroundSyncPrompt = true }842        }843        SyncModeRow(844            title = "Public relay",845            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",846            selected = syncMode == SyncMode.PUBLIC,847            fg = fg,848        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }849        SyncModeRow(850            title = "Private relay",851            detail = "Use your own peach-relay on your own domain.",852            selected = syncMode == SyncMode.PRIVATE,853            fg = fg,854        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }855856        if (syncMode == SyncMode.PRIVATE) {857            Text(858                "RELAY URL",859                color = fg.copy(alpha = 0.6f),860                fontFamily = PeachType.Mono,861                fontSize = 8.sp,862                letterSpacing = 0.22.em,863                modifier = Modifier.padding(top = 8.dp),864            )865            Box(866                Modifier.fillMaxWidth().padding(top = 6.dp)867                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))868                    .padding(horizontal = 12.dp, vertical = 10.dp),869            ) {870                BasicTextField(871                    value = relayUrlDraft,872                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },873                    singleLine = true,874                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),875                    cursorBrush = SolidColor(fg),876                    modifier = Modifier.fillMaxWidth(),877                )878            }879            val needsRelayUrl = configuredRelayUrl.isEmpty()880            RowLine(fg) {881                val message = relayUrlStatus882                    ?: if (needsRelayUrl) {883                        "Sync is paused until you enter your relay URL."884                    } else {885                        "Only secure wss:// relays are accepted."886                    }887                val warn = relayUrlStatus?.startsWith("Use") == true ||888                    (relayUrlStatus == null && needsRelayUrl)889                ValueText(message, if (warn) peachColors.Breach else fg)890                SubtleButton("save", fg) {891                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {892                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)893                        "Saved"894                    } else {895                        "Use a valid wss:// relay URL"896                    }897                }898            }899        }900901        SyncModeRow(902            title = "No sync",903            detail = "Keep this device separate and move backups by hand.",904            selected = syncMode == SyncMode.OFF,905            fg = fg,906        ) { syncVm.setSyncMode(SyncMode.OFF) }907        Hint("Pairing authorizes a device, not a cloud account.", fg)908    }909910    if (pendingVouched.isNotEmpty()) {911        SubLabel("PENDING DEVICES", fg)912        pendingVouched.forEach { dev ->913            RowLine(fg) {914                Column(Modifier.weight(1f)) {915                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)916                    Text(917                        "Was added by another device. Allow it to sync?",918                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,919                        modifier = Modifier.padding(top = 2.dp),920                    )921                }922                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {923                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }924                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }925                }926            }927        }928    }929930    SyncDisclosure(931        title = "Recovery & security",932        summary = "For a lost or stolen device",933        expanded = showRecovery,934        fg = fg,935        onToggle = { showRecovery = !showRecovery },936    )937    if (showRecovery) {938        RowLine(fg) {939            Column(Modifier.weight(1f)) {940                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)941                Text(942                    "Scan only when this populated vault previously trusted the source device.",943                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,944                    modifier = Modifier.padding(top = 2.dp),945                )946            }947            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }948        }949        if (rotateConfirmOpen) {950            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)951            rotateError?.let {952                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))953            }954            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {955                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }956                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {957                    if (rotating) return@SubtleButton958                    rotating = true959                    rotateError = null960                    scope.launch {961                        val ok = syncVm.rotateSyncIdentity()962                        rotating = false963                        if (ok) {964                            rotateConfirmOpen = false965                            onNavigate(PeachNav.RecoverySetup)966                        } else {967                            rotateError = "Rotation failed. Unlock your real vault and try again."968                        }969                    }970                }971            }972        } else {973            RowLine(fg) {974                Column(Modifier.weight(1f)) {975                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)976                    Text(977                        "Disconnects every device and replaces the keys used for sync.",978                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,979                        modifier = Modifier.padding(top = 2.dp),980                    )981                }982                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }983            }984        }985    }986987    if (showBackgroundSyncPrompt) {988        BackgroundSyncDialog { enabled ->989            showBackgroundSyncPrompt = false990            notificationsAvailable = canShowBackgroundSyncNotification(context)991            syncVm.setBackgroundSyncEnabled(enabled)992        }993    }994}995996private fun deviceTypeLabel(type: String?): String = when (type) {997    "android" -> "Android"998    "phone" -> "Phone"999    "tablet" -> "Tablet"1000    "browser" -> "Browser"1001    else -> "Device"1002}10031004private fun syncTimeLabel(lastSyncedAt: Long?): String {1005    if (lastSyncedAt == null) return "never synced"1006    val diff = System.currentTimeMillis() - lastSyncedAt1007    if (diff < 60_000) return "just now"1008    if (diff < 3_600_000) return "${diff / 60_000}m ago"1009    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1010    return "${diff / 86_400_000}d ago"1011}10121013private fun isSyncStale(lastSyncedAt: Long?): Boolean =1014    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L10151016/* ────────────────────────────────  Pro  ─────────────────────────────────── */10171018@Composable1019private fun ProBody(fg: Color, vm: SettingsViewModel) {1020    val ctx = LocalContext.current1021    val isPro by vm.isPro.collectAsState()1022    if (isPro) {1023        ProActiveBody(fg, vm)1024        return1025    }1026    val features = listOf(1027        "Peach Sync" to "End-to-end encrypted",1028        "Peach Codex" to "Restore from paper",1029        "Passkeys" to "Zero-knowledge WebAuthn",1030        "Advanced Vault Health" to "Reuse · age · exposure",1031        "Canary entries" to "Detects vault compromise",1032        "Duress vault" to "Decoy under coercion",1033    )1034    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1035    Text(1036        "Everything, everywhere.",1037        modifier = Modifier.padding(top = 8.dp),1038        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1039    )1040    Text(1041        "One key unlocks every Pro feature — for the lifetime of the app.",1042        modifier = Modifier.padding(top = 6.dp),1043        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1044    )1045    Spacer(Modifier.height(10.dp))1046    features.forEachIndexed { i, (name, desc) ->1047        // Compact rows (6dp vs. the shared 9dp default) — this card is the1048        // tallest in the deck (6 features + price + CTA) and used to leave1049        // its button with no breathing room before the fanned tab stacked1050        // right beneath it in preview mode.1051        RowLine(fg, verticalPadding = 6.dp) {1052            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1053                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1054                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1055            }1056            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1057        }1058    }1059    Spacer(Modifier.height(12.dp))1060    Text(1061        "$39",1062        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1063    )1064    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1065    Spacer(Modifier.height(10.dp))1066    Box(1067        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1068            .background(fg.copy(alpha = 0.16f))1069            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1070            .clickable {1071                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1072            }1073            .padding(vertical = 12.dp),1074        contentAlignment = Alignment.Center,1075    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10761077    // Trailing breathing room so the button clears the fanned tab stacked1078    // beneath this card in preview mode, where the scrollable viewport clips1079    // right around here (the license-key section below is already out of1080    // view at that height).1081    Spacer(Modifier.height(44.dp))10821083    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1084    var key by remember { mutableStateOf("") }1085    val activating by vm.activating.collectAsState()1086    val activationError by vm.activationError.collectAsState()1087    Box(1088        Modifier.fillMaxWidth().padding(top = 6.dp)1089            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1090            .padding(horizontal = 12.dp, vertical = 10.dp),1091    ) {1092        BasicTextField(1093            value = key,1094            // NOT .uppercase() — base58 (the license payload encoding) is1095            // case-sensitive; forcing the whole string to uppercase silently1096            // corrupted every real key before it ever reached signature1097            // verification, so activation always failed with a real key.1098            onValueChange = { key = it; vm.clearActivationError() },1099            singleLine = true,1100            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1101            cursorBrush = SolidColor(fg),1102            modifier = Modifier.fillMaxWidth(),1103            decorationBox = { inner ->1104                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1105                inner()1106            },1107        )1108    }1109    if (activationError != null) {1110        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1111    }1112    Spacer(Modifier.height(8.dp))1113    Box(1114        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1115            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1116            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1117            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1118            .padding(vertical = 12.dp),1119        contentAlignment = Alignment.Center,1120    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1121}11221123@Composable1124private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1125    val ctx = LocalContext.current1126    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1127    val licenseKey by vm.licenseKey.collectAsState()1128    val issuedAt by vm.licenseIssuedAt.collectAsState()1129    var showKey by remember { mutableStateOf(false) }1130    var copied by remember { mutableStateOf(false) }1131    val scope = rememberCoroutineScope()1132    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1133        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1134    }1135    Text(1136        "Peach Pro",1137        modifier = Modifier.fillMaxWidth(),1138        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1139        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1140    )1141    Text(1142        formatLicenseActiveSince(issuedAt),1143        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1144        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1145        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1146    )1147    licenseKey?.let { key ->1148        Spacer(Modifier.height(12.dp))1149        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1150        RowLine(fg) {1151            Text(1152                if (showKey) key else "************************",1153                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1154                fontFamily = PeachType.Mono,1155                fontSize = 10.sp,1156                modifier = Modifier.weight(1f),1157            )1158            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1159                SubtleButton(if (copied) "copied" else "copy", fg) {1160                    PeachClipboard.copy(ctx, "peach license", key)1161                    copied = true1162                    scope.launch {1163                        delay(1_500)1164                        copied = false1165                    }1166                }1167                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1168            }1169        }1170    }1171    Spacer(Modifier.height(12.dp))1172    features.forEachIndexed { i, name ->1173        RowLine(fg) {1174            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1175                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1176                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1177            }1178            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1179        }1180    }1181    Spacer(Modifier.height(12.dp))1182    Box(1183        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1184            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1185            .clickable { vm.deactivate() }1186            .padding(vertical = 12.dp),1187        contentAlignment = Alignment.Center,1188    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1189}11901191private fun formatLicenseActiveSince(issuedAt: Long?): String {1192    if (issuedAt == null) return "Active"1193    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1194    return "Active since $monthYear"1195}11961197/* ──────────────────────────────  Feedback  ───────────────────────────────── */11981199@OptIn(ExperimentalLayoutApi::class)1200@Composable1201private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1202    val categories = listOf("Bug report", "Feature request", "Question", "Other")1203    var category by remember { mutableStateOf(categories[0]) }1204    var message by remember { mutableStateOf("") }1205    val state by vm.feedbackState.collectAsState()12061207    // Reset transient state when the card leaves composition so reopening starts fresh.1208    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }12091210    if (state is FeedbackState.Sent) {1211        Box(1212            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1213                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1214        ) {1215            Column {1216                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1217                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))1218            }1219        }1220        return1221    }12221223    val sending = state is FeedbackState.Sending12241225    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)1226    SubLabel("TYPE", fg)1227    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1228        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1229    }1230    SubLabel("MESSAGE", fg)1231    Box(1232        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1233            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1234            .padding(10.dp),1235    ) {1236        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1237        BasicTextField(1238            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1239            enabled = !sending,1240            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1241            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1242        )1243    }1244    Text(1245        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1246        modifier = Modifier.padding(top = 6.dp),1247        color = fg.copy(alpha = 0.52f),1248        fontFamily = PeachType.Mono,1249        fontSize = 10.sp,1250        lineHeight = 14.sp,1251    )1252    Spacer(Modifier.height(8.dp))12531254    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1255        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1256        Box(1257            Modifier.clip(RoundedCornerShape(8.dp))1258                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1259                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1260                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1261                .padding(horizontal = 12.dp, vertical = 6.dp),1262        ) {1263            Text(1264                feedbackSubmitLabel(state),1265                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1266                fontFamily = PeachType.Mono, fontSize = 11.sp,1267            )1268        }1269        if (sending) {1270            CircularProgressIndicator(1271                color = LocalPeachAccent.current,1272                strokeWidth = 2.dp,1273                modifier = Modifier.size(16.dp),1274            )1275        }1276    }12771278    (state as? FeedbackState.Error)?.let { err ->1279        Text(1280            err.message,1281            modifier = Modifier.padding(top = 8.dp),1282            color = peachColors.Breach,1283            fontFamily = PeachType.Mono,1284            fontSize = 11.sp,1285            lineHeight = 15.sp,1286        )1287    }12881289    Hint("Sent anonymously · no account required", fg)1290}12911292/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */12931294@Composable1295internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1296    val configured by vm.duressConfigured.collectAsState()1297    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1298}12991300@Composable1301private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1302    Text(1303        "Duress password is active",1304        color = peachColors.HealthOk,1305        fontFamily = PeachType.Mono,1306        fontSize = 11.sp,1307        modifier = Modifier.padding(top = 6.dp),1308    )1309    Spacer(Modifier.height(8.dp))1310    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1311    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1312}13131314@Composable1315private fun DuressForm(fg: Color, vm: SettingsViewModel) {1316    var enabled by remember { mutableStateOf(false) }1317    var password by remember { mutableStateOf("") }1318    var confirm by remember { mutableStateOf("") }1319    var action by remember { mutableStateOf(DuressAction.DECOY) }1320    var error by remember { mutableStateOf<String?>(null) }1321    val scope = rememberCoroutineScope()13221323    RowLine(fg) {1324        Name("Duress password", fg)1325        Toggle(enabled, fg) { on ->1326            enabled = on1327            if (!on) { password = ""; confirm = ""; error = null }1328        }1329    }1330    Text(1331        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1332        color = fg.copy(alpha = 0.7f),1333        fontFamily = PeachType.Mono,1334        fontSize = 10.sp,1335        modifier = Modifier.padding(top = 6.dp),1336    )13371338    AnimatedVisibility(enabled) {1339        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1340            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1341            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13421343            Text(1344                "When your duress password is entered at the lock screen, Peach will:",1345                color = fg.copy(alpha = 0.7f),1346                fontFamily = PeachType.Mono,1347                fontSize = 10.sp,1348                modifier = Modifier.padding(top = 4.dp),1349            )13501351            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1352            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13531354            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13551356            val canSave = password.isNotBlank() && confirm.isNotBlank()1357            DuressSaveButton("Save duress password", fg, canSave) {1358                error = when {1359                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1360                    password != confirm -> "Passwords do not match"1361                    else -> null1362                }1363                if (error == null) scope.launch {1364                    if (vm.saveDuress(password, action)) {1365                        password = ""; confirm = ""; enabled = false1366                    } else {1367                        error = "Couldn't save — the duress passphrase must differ from your real password"1368                    }1369                }1370            }1371        }1372    }1373}13741375@Composable1376private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1377    val accent = LocalPeachAccent.current1378    Row(1379        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1380        verticalAlignment = Alignment.CenterVertically,1381        horizontalArrangement = Arrangement.spacedBy(10.dp),1382    ) {1383        Box(1384            Modifier.size(16.dp).clip(CircleShape)1385                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1386            contentAlignment = Alignment.Center,1387        ) {1388            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1389        }1390        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1391    }1392}13931394@Composable1395private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1396    val accent = LocalPeachAccent.current1397    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1398    Box(1399        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1400            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1401            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1402            .let { if (enabled) it.clickable(onClick = onClick) else it }1403            .padding(vertical = 11.dp),1404        contentAlignment = Alignment.Center,1405    ) {1406        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1407    }1408}14091410@Composable1411private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1412    Box(1413        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1414            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1415            .clickable(onClick = onClick)1416            .padding(vertical = 11.dp),1417        contentAlignment = Alignment.Center,1418    ) {1419        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1420    }1421}14221423/** One choice in the three-way sync selector. */1424@Composable1425private fun SyncModeRow(1426    title: String,1427    detail: String,1428    selected: Boolean,1429    fg: Color,1430    onSelect: () -> Unit,1431) {1432    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1433        Column(Modifier.weight(1f)) {1434            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1435            Text(1436                detail,1437                color = fg.copy(alpha = 0.7f),1438                fontFamily = PeachType.Body,1439                fontSize = 11.sp,1440                lineHeight = 16.sp,1441                modifier = Modifier.padding(top = 2.dp),1442            )1443        }1444        Text(1445            if (selected) "●" else "○",1446            color = if (selected) fg else fg.copy(alpha = 0.45f),1447            fontFamily = PeachType.Mono,1448            fontSize = 14.sp,1449            modifier = Modifier.padding(start = 10.dp),1450        )1451    }1452}