SettingsCardBodies.kt

80.7 KB · raw

1package com.peachpasswords.ui.settings23import android.content.Intent4import android.net.Uri5import androidx.compose.animation.AnimatedVisibility6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.FlowRow14import androidx.compose.foundation.layout.ExperimentalLayoutApi15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.RowScope17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.heightIn21import androidx.compose.foundation.layout.padding22import androidx.compose.foundation.layout.size23import androidx.compose.foundation.layout.width24import androidx.compose.foundation.shape.CircleShape25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.runtime.Composable28import androidx.compose.material3.CircularProgressIndicator29import androidx.compose.material3.AlertDialog30import androidx.compose.runtime.DisposableEffect31import androidx.compose.runtime.collectAsState32import androidx.compose.runtime.getValue33import com.peachpasswords.feedback.FeedbackState34import com.peachpasswords.shared.PeachClipboard35import com.peachpasswords.sync.LEGACY_ACTIVE36import com.peachpasswords.sync.REPAIRING37import com.peachpasswords.sync.RESET_CONFIRMED38import com.peachpasswords.sync.RESET_REQUIRED39import com.peachpasswords.sync.SyncMode40import com.peachpasswords.sync.UNKNOWN_STATE41import com.peachpasswords.sync.V2_ACTIVE42import com.peachpasswords.sync.V2_ANCHOR43import com.peachpasswords.sync.canShowBackgroundSyncNotification44import com.peachpasswords.sync.describeSyncOutcome45import com.peachpasswords.sync.privateRelayDraftOf46import com.peachpasswords.sync.syncIndicator47import com.peachpasswords.sync.syncIndicatorLabel48import com.peachpasswords.ui.components.SyncIndicatorDot49import com.peachpasswords.ui.theme.LocalPeachAccent50import com.peachpasswords.vault.DuressAction51import com.peachpasswords.ui.theme.peachColors52import androidx.compose.runtime.mutableStateOf53import androidx.compose.runtime.remember54import androidx.compose.runtime.rememberCoroutineScope55import androidx.compose.runtime.setValue56import androidx.compose.ui.Alignment57import androidx.compose.ui.Modifier58import androidx.compose.ui.draw.clip59import androidx.compose.ui.graphics.Color60import androidx.compose.ui.graphics.SolidColor61import androidx.compose.ui.platform.LocalContext62import androidx.compose.ui.text.TextStyle63import androidx.compose.ui.text.font.FontStyle64import androidx.compose.ui.text.input.PasswordVisualTransformation65import androidx.compose.ui.text.input.KeyboardType66import androidx.compose.ui.unit.Dp67import androidx.compose.ui.unit.dp68import androidx.compose.ui.unit.em69import androidx.compose.ui.unit.sp70import androidx.compose.material3.Text71import com.peachpasswords.BuildConfig72import com.peachpasswords.autofill.AutofillEnablement73import com.peachpasswords.ui.PeachNav74import com.peachpasswords.ui.components.PeachEmptyOrEndState75import com.peachpasswords.ui.components.PeachField76import com.peachpasswords.ui.pairing.BackgroundSyncDialog77import com.peachpasswords.ui.sync.SyncViewModel78import com.peachpasswords.ui.theme.ACCENT_COLORS79import com.peachpasswords.ui.theme.PeachSpacing80import com.peachpasswords.ui.theme.PeachType81import com.peachpasswords.ui.updates.ChangelogPhase82import com.peachpasswords.ui.updates.UpdatePhase83import com.peachpasswords.ui.updates.UpdateViewModel84import com.peachpasswords.updates.compareVersions85import androidx.hilt.navigation.compose.hiltViewModel86import com.peachpasswords.vault.VaultSettings87import com.peachpasswords.vault.VaultSnapshot88import com.peachpasswords.vault.faviconsAllowed89import kotlinx.coroutines.launch90import kotlinx.coroutines.delay91import java.text.SimpleDateFormat92import java.util.Date93import java.util.Locale9495private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"9697internal const val FAVICON_DISCLOSURE_TEXT =98    "Peach requests site icons from DuckDuckGo. Turning this on discloses your full vault domain inventory to DuckDuckGo as icons are requested."99100/**101 * Body content for the active stack card. Everything is tinted with the card's102 * own foreground [fg] (the browser's `currentColor` convention), so each card's103 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,104 * canary, license activation) render their browser visual but defer the action105 * to a later phase — noted inline where that's the case.106 */107@Composable108fun CardBody(109    id: CardId,110    fg: Color,111    vm: SettingsViewModel,112    settings: VaultSettings,113    entriesCount: Int,114    onNavigate: (PeachNav) -> Unit,115) {116    when (id) {117        CardId.Appearance -> AppearanceBody(fg, vm, settings)118        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)119        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)120        CardId.Sync -> SyncBody(fg, onNavigate)121        CardId.Pro -> ProBody(fg, vm)122        CardId.Feedback -> FeedbackBody(fg, vm)123    }124}125126/* ─────────────────────────  shared row vocabulary  ───────────────────────── */127128@Composable129private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {130    Text(131        text,132        modifier = Modifier.padding(top = top, bottom = 4.dp),133        color = fg.copy(alpha = 0.58f),134        fontFamily = PeachType.Mono,135        fontSize = 8.sp,136        letterSpacing = 0.22.em,137    )138}139140/**141 * Collapsible group inside a card body: the uppercase heading row toggles its142 * rows, so busy cards can fold rarely-used sections away. Groups holding143 * dangerous or rarely-used actions start collapsed.144 */145@Composable146private fun SettingsGroup(147    label: String,148    fg: Color,149    initiallyOpen: Boolean = true,150    top: Dp = 14.dp,151    content: @Composable () -> Unit,152) {153    var open by remember { mutableStateOf(initiallyOpen) }154    Row(155        Modifier.fillMaxWidth()156            .clip(RoundedCornerShape(6.dp))157            .clickable { open = !open }158            .padding(top = top, bottom = 4.dp),159        verticalAlignment = Alignment.CenterVertically,160    ) {161        Text(162            label,163            modifier = Modifier.weight(1f),164            color = fg.copy(alpha = 0.58f),165            fontFamily = PeachType.Mono,166            fontSize = 8.sp,167            letterSpacing = 0.22.em,168        )169        Text(170            if (open) "–" else "+",171            color = fg.copy(alpha = 0.58f),172            fontFamily = PeachType.Mono,173            fontSize = 10.sp,174        )175    }176    // AnimatedVisibility stacks sibling children like a Box, so multi-row group177    // content must sit in an explicit Column or the rows overlap.178    AnimatedVisibility(visible = open) {179        Column(Modifier.fillMaxWidth()) { content() }180    }181}182183@Composable184private fun RowLine(185    fg: Color,186    modifier: Modifier = Modifier,187    verticalPadding: Dp = 9.dp,188    content: @Composable RowScope.() -> Unit,189) {190    Row(191        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),192        verticalAlignment = Alignment.CenterVertically,193        horizontalArrangement = Arrangement.SpaceBetween,194        content = content,195    )196    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))197}198199@Composable200private fun RowScope.Name(text: String, fg: Color) {201    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))202}203204@Composable205private fun ValueText(text: String, fg: Color, modifier: Modifier = Modifier) {206    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier)207}208209@Composable210private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {211    Box(212        Modifier.clip(RoundedCornerShape(8.dp))213            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))214            .background(fg.copy(alpha = 0.08f))215            .clickable(onClick = onClick)216            .padding(horizontal = 12.dp, vertical = 6.dp),217    ) {218        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1)219    }220}221222@Composable223private fun SyncDisclosure(224    title: String,225    summary: String,226    expanded: Boolean,227    fg: Color,228    onToggle: () -> Unit,229) {230    Row(231        modifier = Modifier232            .fillMaxWidth()233            .heightIn(min = 48.dp)234            .clip(RoundedCornerShape(8.dp))235            .clickable(onClick = onToggle)236            .padding(vertical = 8.dp),237        verticalAlignment = Alignment.CenterVertically,238    ) {239        Column(Modifier.weight(1f)) {240            Text(241                title.uppercase(),242                color = fg.copy(alpha = 0.58f),243                fontFamily = PeachType.Mono,244                fontSize = 8.sp,245                letterSpacing = 0.22.em,246            )247            Text(248                summary,249                color = fg.copy(alpha = 0.7f),250                fontFamily = PeachType.Body,251                fontSize = 11.sp,252                modifier = Modifier.padding(top = 3.dp),253            )254        }255        Text(256            if (expanded) "hide" else "show",257            color = fg,258            fontFamily = PeachType.Mono,259            fontSize = 10.sp,260        )261    }262}263264@Composable265private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {266    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")267    Box(268        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))269            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))270            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))271            .clickable { onToggle(!on) },272    ) {273        Box(274            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)275                .padding(start = knob - 2.dp)276                .size(14.dp).clip(CircleShape)277                .background(if (on) fg else fg.copy(alpha = 0.58f)),278        )279    }280}281282@Composable283private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {284    Box(285        Modifier.clip(RoundedCornerShape(999.dp))286            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))287            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)288            .clickable(onClick = onClick)289            .padding(horizontal = 12.dp, vertical = 5.dp),290    ) {291        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)292    }293}294295@Composable296private fun ProBadge(fg: Color) {297    Box(298        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))299            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),300    ) {301        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)302    }303}304305@Composable306private fun Hint(text: String, fg: Color) {307    Text(308        text,309        modifier = Modifier.padding(top = 6.dp),310        color = fg.copy(alpha = 0.6f),311        fontFamily = PeachType.Display,312        fontStyle = FontStyle.Italic,313        fontSize = 11.5.sp,314        lineHeight = 16.sp,315    )316}317318/* ──────────────────────────────  Appearance  ─────────────────────────────── */319320@OptIn(ExperimentalLayoutApi::class)321@Composable322private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {323    var showFaviconDisclosure by remember { mutableStateOf(false) }324    SubLabel("ACCENT", fg, top = 0.dp)325    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {326        ACCENT_COLORS.forEach { (name, color) ->327            Box(328                Modifier.size(22.dp).clip(CircleShape).background(color)329                    .border(330                        if (s.accentColor == name) 2.dp else 1.dp,331                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),332                        CircleShape,333                    )334                    .clickable { vm.setAccent(name) },335            )336        }337    }338    SubLabel("TEXT SIZE", fg)339    RowLine(fg) {340        Name("Text size", fg)341        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {342            Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }343            Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }344            Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }345        }346    }347    SubLabel("UI SCALE", fg)348    RowLine(fg) {349        Name("Large-screen sizing", fg)350        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {351            Pill("compact", s.uiScale == "compact", fg) { vm.setUiScale("compact") }352            Pill("auto", s.uiScale == "auto", fg) { vm.setUiScale("auto") }353            Pill("large", s.uiScale == "large", fg) { vm.setUiScale("large") }354        }355    }356    Hint("Auto keeps foldables roomy while preventing tablet controls from inflating.", fg)357    SubLabel("ICONS", fg)358    RowLine(fg) {359        Name("Site icons", fg)360        val siteIconsAllowed = faviconsAllowed(s)361        SubtleButton(if (siteIconsAllowed) "On" else "Off", fg) {362            if (siteIconsAllowed) vm.setFaviconsEnabled(false) else showFaviconDisclosure = true363        }364    }365    if (showFaviconDisclosure) {366        AlertDialog(367            onDismissRequest = { showFaviconDisclosure = false },368            title = { Text("Enable site icons?", color = fg, fontFamily = PeachType.Body) },369            text = { Text(FAVICON_DISCLOSURE_TEXT, color = fg, fontFamily = PeachType.Body) },370            confirmButton = {371                SubtleButton("Enable", fg) {372                    showFaviconDisclosure = false373                    vm.setFaviconsEnabled(true)374                }375            },376            dismissButton = {377                SubtleButton("Cancel", fg) { showFaviconDisclosure = false }378            },379        )380    }381    SubLabel("MOTION", fg)382    RowLine(fg) {383        Name("Reduce motion", fg)384        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }385    }386}387388/* ──────────────────────────────  Protection  ─────────────────────────────── */389390@Composable391private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {392    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")393    val current = s.autoLockMinutes ?: 0394    val pinEnabled by vm.pinEnabled.collectAsState()395    var pinOpen by remember { mutableStateOf(false) }396    var pinRemovalOpen by remember { mutableStateOf(false) }397    var pinMaster by remember { mutableStateOf("") }398    var pin by remember { mutableStateOf("") }399    var pinConfirm by remember { mutableStateOf("") }400    var pinRemovalMaster by remember { mutableStateOf("") }401    var pinError by remember { mutableStateOf<String?>(null) }402    val pinScope = rememberCoroutineScope()403404    SubLabel("LOCKING", fg, top = 0.dp)405    RowLine(fg) {406        Name("Allow screenshots", fg)407        Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }408    }409    RowLine(fg) {410        Name("Auto-lock after", fg)411        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)412        SubtleButton(lockOptions[idx].second, fg) {413            val next = lockOptions[(idx + 1) % lockOptions.size].first414            vm.setAutoLockMinutes(if (next == 0) 0 else next)415        }416    }417    SubLabel("PIN UNLOCK", fg)418    RowLine(fg) {419        Name("Unlock with PIN", fg)420        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {421            ValueText(if (pinEnabled) "enabled" else "off", fg)422            SubtleButton(if (pinEnabled) "change" else "set", fg) {423                pin = ""424                pinConfirm = ""425                pinMaster = ""426                pinError = null427                pinOpen = !pinOpen428            }429        }430    }431    if (pinEnabled) {432        RowLine(fg) {433            Name("Remove PIN unlock", fg)434            SubtleButton("turn off", fg) {435                pinRemovalMaster = ""436                pinError = null437                pinRemovalOpen = !pinRemovalOpen438            }439        }440    }441    AnimatedVisibility(pinRemovalOpen) {442        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {443            PeachField(444                label = "master password",445                value = pinRemovalMaster,446                onValueChange = { pinRemovalMaster = it; pinError = null },447                keyboardType = KeyboardType.Password,448                visualTransformation = PasswordVisualTransformation(),449            )450            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }451            SubtleButton("remove PIN", fg) {452                pinScope.launch {453                    val error = vm.disablePin(pinRemovalMaster)454                    if (error == null) {455                        pinRemovalMaster = ""456                        pinRemovalOpen = false457                    } else {458                        pinError = error459                    }460                }461            }462        }463    }464    AnimatedVisibility(pinOpen) {465        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {466            PeachField(467                label = "master password",468                value = pinMaster,469                onValueChange = { pinMaster = it; pinError = null },470                keyboardType = KeyboardType.Password,471                visualTransformation = PasswordVisualTransformation(),472            )473            PeachField(474                label = "new PIN (4–12 digits)",475                value = pin,476                onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },477                keyboardType = KeyboardType.NumberPassword,478                visualTransformation = PasswordVisualTransformation(),479            )480            PeachField(481                label = "confirm PIN",482                value = pinConfirm,483                onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },484                keyboardType = KeyboardType.NumberPassword,485                visualTransformation = PasswordVisualTransformation(),486            )487            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }488            SubtleButton("save PIN", fg) {489                pinError = if (pin != pinConfirm) "PINs do not match" else null490                if (pinError == null) pinScope.launch {491                    val error = vm.savePin(pinMaster, pin)492                    if (error == null) {493                        pinMaster = ""494                        pin = ""495                        pinConfirm = ""496                        pinOpen = false497                    } else {498                        pinError = error499                    }500                }501            }502        }503    }504    SubLabel("BIOMETRICS", fg)505    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity506    RowLine(fg) {507        Name("Unlock with biometrics", fg)508        Toggle(s.biometricsEnabled, fg) { on ->509            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()510        }511    }512    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)513    if (bioError != null) {514        Text(515            bioError,516            color = com.peachpasswords.ui.theme.peachColors.Breach,517            fontFamily = PeachType.Mono, fontSize = 10.sp,518            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),519        )520    }521    SubLabel("AUTOFILL", fg)522    run {523        // Read live rather than cached: the user leaves for system settings and524        // comes back, so a remembered value would show the old answer.525        val ctx = LocalContext.current526        val enabled = AutofillEnablement.isEnabled(ctx)527        RowLine(fg) {528            Column(Modifier.weight(1f)) {529                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)530                Text(531                    if (enabled) {532                        "Peach is your autofill service."533                    } else {534                        "Android decides which app fills passwords. Turn Peach on in system settings."535                    },536                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,537                    modifier = Modifier.padding(top = 2.dp),538                )539            }540            if (!enabled) {541                SubtleButton("enable", fg) {542                    // Straight to the autofill picker with Peach preselected;543                    // some OEM builds ship no handler, so fall back rather than544                    // dead-ending on a button that does nothing.545                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }546                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }547                }548            } else {549                ValueText("on", fg)550            }551        }552    }553554    RowLine(fg) {555        Column(Modifier.weight(1f)) {556            Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)557            Text(558                "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",559                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,560                modifier = Modifier.padding(top = 2.dp),561            )562        }563        Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }564    }565566    SubLabel("PASSKEYS", fg)567    RowLine(fg) {568        Column(Modifier.weight(1f)) {569            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)570            Text(571                "Peach can handle passkey sign-in and creation through Android Credential Manager.",572                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,573                modifier = Modifier.padding(top = 2.dp),574            )575        }576        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }577    }578    RowLine(fg) {579        Name("Status", fg)580        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)581    }582    SubLabel("RECOVERY", fg)583    RowLine(fg) {584        Column(Modifier.weight(1f)) {585            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)586            Text(587                "Keep the paper copy current before rotating sync identity or replacing devices.",588                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,589                modifier = Modifier.padding(top = 2.dp),590            )591        }592        SubtleButton("view", fg) {593            if (bioActivity != null) {594                com.peachpasswords.ui.lock.Biometrics.authenticate(595                    activity = bioActivity,596                    title = "View recovery words",597                    subtitle = "Confirm your identity to view your recovery words",598                    onSuccess = { onNavigate(PeachNav.RecoveryView) },599                )600            } else onNavigate(PeachNav.RecoveryView)601        }602    }603    SubLabel("BREACH PROTECTION", fg)604    val checkingBreaches by vm.checkingBreaches.collectAsState()605    val breachResult by vm.breachResult.collectAsState()606    RowLine(fg) {607        Name("Breach monitoring", fg)608        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }609    }610    RowLine(fg) {611        Name("Check all passwords", fg)612        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }613    }614    breachResult?.let { result ->615        RowLine(fg) {616            Name("Last check", fg)617            ValueText(result, fg)618        }619    }620621    SubLabel("MASTER PASSWORD", fg)622    ChangePasswordRow(fg, vm)623624    SubLabel("DURESS PASSWORD", fg)625    // Always composed, Pro or not. The only thing the entitlement may gate is626    // SETTING a new duress password — an armed one must stay visible and627    // disarmable, or losing Pro silently removes the user's control over a628    // destructive action (PB-26-122).629    DuressSection(fg, vm)630}631632@Composable633private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {634    var open by remember { mutableStateOf(false) }635    var current by remember { mutableStateOf("") }636    var next by remember { mutableStateOf("") }637    var confirm by remember { mutableStateOf("") }638    var error by remember { mutableStateOf<String?>(null) }639    var done by remember { mutableStateOf(false) }640    val scope = rememberCoroutineScope()641642    RowLine(fg) {643        Name("Master password", fg)644        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }645    }646    AnimatedVisibility(open) {647        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {648            PwField("current password", current, fg) { current = it }649            PwField("new password (min 12 chars)", next, fg) { next = it }650            PwField("confirm new password", confirm, fg) { confirm = it }651            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }652            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)653            SubtleButton("save new password", fg) {654                error = when {655                    current.isBlank() -> "enter your current password"656                    next.length < 12 -> "new password must be at least 12 characters"657                    next != confirm -> "new passwords do not match"658                    next == current -> "new password must differ from current"659                    else -> null660                }661                if (error == null) scope.launch {662                    if (vm.changePassword(current, next)) {663                        current = ""; next = ""; confirm = ""; open = false; done = true664                    } else error = "incorrect current password"665                }666            }667        }668    }669}670671@Composable672private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {673    PeachField(674        label = placeholder,675        value = value,676        onValueChange = onChange,677        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,678        visualTransformation = PasswordVisualTransformation(),679    )680}681682/* ────────────────────────────────  Vault  ───────────────────────────────── */683684@Composable685private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {686    val vm: UpdateViewModel = hiltViewModel()687    val available by vm.available.collectAsState()688    val state by vm.state.collectAsState()689    val changelog by vm.changelog.collectAsState()690    var changelogOpen by remember { mutableStateOf(false) }691692    RowLine(fg) {693        Name("Version", fg)694        ValueText(BuildConfig.VERSION_NAME, fg)695    }696    RowLine(fg) {697        Column(Modifier.weight(1f)) {698            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)699            Text(700                when {701                    available != null -> "v${available?.version} is available"702                    state.phase == UpdatePhase.Checking -> "Checking…"703                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"704                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"705                    else -> "Peach checks once a day and tells you when there's a new version."706                },707                color = fg.copy(alpha = 0.7f),708                fontFamily = PeachType.Body,709                fontSize = 11.sp,710                lineHeight = 16.sp,711                modifier = Modifier.padding(top = 2.dp),712            )713        }714        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {715            SubtleButton("changelog", fg) {716                changelogOpen = !changelogOpen717                if (changelogOpen) vm.loadChangelog()718            }719            if (available != null) {720                SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }721            } else {722                SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {723                    vm.check()724                }725            }726        }727    }728    if (changelogOpen) {729        when (changelog.phase) {730            ChangelogPhase.Idle -> {}731            ChangelogPhase.Loading -> Text(732                "Loading changelog…",733                color = fg.copy(alpha = 0.68f),734                fontFamily = PeachType.Body,735                fontSize = 11.sp,736                modifier = Modifier.padding(vertical = 8.dp),737            )738            ChangelogPhase.Failed -> RowLine(fg) {739                Name("Couldn't reach the update server", fg)740                SubtleButton("retry", fg) { vm.loadChangelog() }741            }742            ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {743                changelog.entries.forEachIndexed { index, entry ->744                    Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {745                        Row(verticalAlignment = Alignment.CenterVertically) {746                            Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)747                            if (compareVersions(entry.version, vm.currentVersion) == 0) {748                                Text("  current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)749                            }750                        }751                        Text(752                            entry.notes ?: "No notes for this release.",753                            color = fg.copy(alpha = 0.72f),754                            fontFamily = PeachType.Body,755                            fontSize = 11.sp,756                            lineHeight = 16.sp,757                            modifier = Modifier.padding(top = 3.dp),758                        )759                    }760                    if (index < changelog.entries.lastIndex) {761                        Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))762                    }763                }764            }765        }766    }767}768769@Composable770private fun VaultBody(771    fg: Color,772    vm: SettingsViewModel,773    s: VaultSettings,774    entriesCount: Int,775    onNavigate: (PeachNav) -> Unit,776) {777    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")778    val current = s.trashRetentionDays ?: 0779    val isPro by vm.isPro.collectAsState()780    val dedupCount by vm.dedupCount.collectAsState()781    val dedupRunning by vm.dedupRunning.collectAsState()782    val dedupDone by vm.dedupDone.collectAsState()783    val history by vm.vaultHistory.collectAsState()784    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()785786    SettingsGroup("ENTRIES", fg, top = 0.dp) {787    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }788    RowLine(fg) {789        Name("Duplicates", fg)790        val done = dedupDone791        if (done != null) {792            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)793        } else {794            val count = dedupCount795            val label = when {796                dedupRunning -> "scanning..."797                count == null -> "scan"798                count == 0 -> "none found"799                else -> "$count found"800            }801            SubtleButton(label, fg) {802                if (count == null || count == 0) vm.scanDuplicates()803            }804        }805    }806    if ((dedupCount ?: 0) > 0 && dedupDone == null) {807        Column(808            Modifier.fillMaxWidth()809                .padding(top = 2.dp, bottom = 8.dp)810                .clip(RoundedCornerShape(8.dp))811                .background(fg.copy(alpha = 0.08f))812                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))813                .padding(10.dp),814            verticalArrangement = Arrangement.spacedBy(8.dp),815        ) {816            val count = dedupCount ?: 0817            Text(818                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",819                color = fg.copy(alpha = 0.82f),820                fontFamily = PeachType.Body,821                fontSize = 11.sp,822                lineHeight = 16.sp,823            )824            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {825                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }826                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }827            }828        }829    }830    }831832    SettingsGroup("MAINTENANCE", fg) {833    RowLine(fg) {834        Name("Trash auto-delete", fg)835        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)836        SubtleButton(trashOptions[idx].second, fg) {837            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)838        }839    }840    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }841    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }842    }843844    SettingsGroup("DATA", fg) {845    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }846    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }847    RowLine(fg) {848        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }849        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }850    }851    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }852    }853854    SettingsGroup("UPDATES", fg) {855    UpdatesRows(fg, onNavigate)856    }857858    SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {859    var historyShowAll by remember { mutableStateOf(false) }860    if (history.isEmpty()) {861        Text(862            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",863            color = fg.copy(alpha = 0.68f),864            fontFamily = PeachType.Body,865            fontSize = 11.sp,866            lineHeight = 16.sp,867            modifier = Modifier.padding(bottom = 6.dp),868        )869    } else {870        Text(871            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",872            color = fg.copy(alpha = 0.68f),873            fontFamily = PeachType.Body,874            fontSize = 11.sp,875            lineHeight = 16.sp,876            modifier = Modifier.padding(bottom = 4.dp),877        )878        history.take(if (historyShowAll) history.size else 3).forEach { snap ->879            RowLine(fg) {880                Column(Modifier.weight(1f)) {881                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)882                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)883                }884                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {885                    vm.restoreVaultSnapshot(snap.id)886                }887            }888        }889        if (history.size > 3) {890            RowLine(fg) {891                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)892                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }893            }894        }895        Text(896            "${history.size} of 5 snapshots",897            color = fg.copy(alpha = 0.58f),898            fontFamily = PeachType.Mono,899            fontSize = 10.sp,900            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),901        )902    }903    }904905    SettingsGroup("ADVANCED", fg, initiallyOpen = false) {906        RowLine(fg) {907            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)908            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }909        }910        RowLine(fg) {911            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)912            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }913        }914    }915}916917private fun snapshotSubLabel(snapshot: VaultSnapshot): String {918    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }919    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")920}921922private fun snapshotReasonLabel(reason: String): String = when (reason) {923    "local_save" -> "saved"924    "sync_received" -> "synced"925    "import" -> "imported"926    "restore" -> "restored"927    else -> reason928}929930private fun snapshotTimeLabel(savedAt: Long): String {931    val diff = System.currentTimeMillis() - savedAt932    if (diff < 60_000) return "Just now"933    if (diff < 3_600_000) return "${diff / 60_000}m ago"934    val date = Date(savedAt)935    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())936    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)937    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)938    if (today == thenDay) return "Today $time"939    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))940    if (yesterday == thenDay) return "Yesterday $time"941    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)942}943944/* ────────────────────────────────  Sync  ────────────────────────────────── */945946/**947 * The §11 migration copy. It must never imply that old peers keep working: the948 * whole point of the reset is that they cannot.949 */950private const val SECURE_RESET_DISCLOSURE =951    "Peach will archive this device's old Sync trust and start the new Sync generation. " +952        "Every other device must be updated first, then re-paired with this one — they will " +953        "not continue on their own. Your vault and your recovery words are not changed."954955private fun secureResetHeadline(state: String): String = when (state) {956    LEGACY_ACTIVE -> "Legacy Sync generation"957    RESET_REQUIRED -> "Reset required"958    RESET_CONFIRMED -> "Reset not completed"959    V2_ANCHOR, REPAIRING -> "Not re-paired"960    V2_ACTIVE -> "New Sync generation"961    UNKNOWN_STATE -> "Sync generation unreadable"962    else -> "Sync generation"963}964965private fun secureResetDetail(state: String): String = when (state) {966    LEGACY_ACTIVE ->967        "This device still holds the old Sync trust. Resetting retires it for good: update " +968            "Peach on every other device, then re-pair each one."969    RESET_REQUIRED ->970        "Sync is stopped until the reset completes. Update Peach on every other device, then " +971            "reset this one and re-pair each device."972    RESET_CONFIRMED ->973        "The reset did not finish. Nothing was lost — the old trust is archived. Try again."974    V2_ANCHOR, REPAIRING ->975        "This device anchors the new Sync generation and is not re-paired yet. Pair each device " +976            "again; until then it keeps its own vault and cannot sync."977    V2_ACTIVE ->978        "This device is on the new Sync generation. Each device is re-paired individually."979    UNKNOWN_STATE ->980        "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support."981    else -> "Sync state is unavailable."982}983984@Composable985private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {986    val context = LocalContext.current987    val syncVm: SyncViewModel = hiltViewModel()988    val pairedDevices by syncVm.pairedDevices.collectAsState()989    val pendingVouched by syncVm.pendingVouched.collectAsState()990    val syncStatus by syncVm.status.collectAsState()991    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()992    val syncMode by syncVm.syncMode.collectAsState()993    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()994    var notificationsAvailable by remember {995        mutableStateOf(canShowBackgroundSyncNotification(context))996    }997    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }998    val scope = rememberCoroutineScope()999    var confirmUnpair by remember { mutableStateOf<String?>(null) }1000    var renamingDeviceId by remember { mutableStateOf<String?>(null) }1001    var renameDraft by remember { mutableStateOf("") }1002    var rotateConfirmOpen by remember { mutableStateOf(false) }1003    var rotating by remember { mutableStateOf(false) }1004    var rotateError by remember { mutableStateOf<String?>(null) }1005    var relayUrlDraft by remember(configuredRelayUrl) {1006        mutableStateOf(privateRelayDraftOf(configuredRelayUrl))1007    }1008    var relayUrlStatus by remember { mutableStateOf<String?>(null) }1009    var showConnection by remember { mutableStateOf(false) }1010    var showRecovery by remember { mutableStateOf(false) }10111012    val generation by syncVm.generationState.collectAsState()1013    var resetOpen by remember { mutableStateOf(false) }1014    var resetPassword by remember { mutableStateOf("") }1015    var resetError by remember { mutableStateOf<String?>(null) }1016    var resetting by remember { mutableStateOf(false) }1017    val resetOffered = generation.state == LEGACY_ACTIVE ||1018        generation.state == RESET_REQUIRED ||1019        generation.state == RESET_CONFIRMED10201021    SubLabel("SYNC STATUS", fg)1022    val indicator = syncIndicator(syncStatus, pairedDevices.size)1023    RowLine(fg) {1024        SyncIndicatorDot(1025            indicator = indicator,1026            label = syncIndicatorLabel(indicator, syncStatus),1027            modifier = Modifier.padding(end = PeachSpacing.sm),1028            size = 8.dp,1029        )1030        Text(1031            describeSyncOutcome(syncStatus),1032            color = fg.copy(alpha = 0.7f),1033            fontFamily = PeachType.Body,1034            fontSize = 11.sp,1035            lineHeight = 16.sp,1036            modifier = Modifier.weight(1f),1037        )1038        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {1039            if (!syncStatus.syncing) syncVm.syncNow()1040        }1041    }10421043    // §11: this panel is a migration call to action, not a permanent label. On1044    // the settled new generation there is nothing left to do here, so it goes1045    // away entirely — the Browser applies the same rule. The state and its1046    // remember slots stay outside the branch so the composition stays stable.1047    if (generation.state != V2_ACTIVE) {1048        SubLabel("SECURE SYNC RESET", fg)1049        RowLine(fg) {1050            Column(Modifier.weight(1f)) {1051                Text(secureResetHeadline(generation.state), color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1052                Text(1053                    secureResetDetail(generation.state),1054                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1055                    modifier = Modifier.padding(top = 2.dp),1056                )1057                resetError?.let {1058                    Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 2.dp))1059                }1060            }1061            if (resetOffered) {1062                SubtleButton("reset sync", fg) {1063                    resetError = null1064                    resetPassword = ""1065                    resetOpen = true1066                }1067            }1068        }10691070        if (resetOpen) {1071            AlertDialog(1072                onDismissRequest = { if (!resetting) resetOpen = false },1073                title = { Text("Reset Sync on this device?", color = fg, fontFamily = PeachType.Body) },1074                text = {1075                    Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {1076                        Text(1077                            SECURE_RESET_DISCLOSURE,1078                            color = fg.copy(alpha = 0.8f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1079                        )1080                        PwField("master password", resetPassword, fg) { resetPassword = it }1081                    }1082                },1083                confirmButton = {1084                    SubtleButton(if (resetting) "resetting..." else "reset", peachColors.Breach) {1085                        if (!resetting) scope.launch {1086                            resetting = true1087                            resetError = syncVm.confirmReset(resetPassword)1088                            resetPassword = ""1089                            resetting = false1090                            if (resetError == null) resetOpen = false1091                        }1092                    }1093                },1094                dismissButton = {1095                    SubtleButton("cancel", fg) { if (!resetting) resetOpen = false }1096                },1097            )1098        }1099    }11001101    SubLabel("PAIRED DEVICES", fg)1102    if (pairedDevices.isEmpty()) {1103        PeachEmptyOrEndState(1104            message = "No paired devices yet.",1105            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",1106            modifier = Modifier.padding(top = PeachSpacing.xs),1107        )1108    } else {1109        pairedDevices.forEach { d ->1110            RowLine(fg) {1111                Column(Modifier.weight(1f)) {1112                    if (renamingDeviceId == d.deviceId) {1113                        Box(1114                            Modifier.fillMaxWidth()1115                                .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1116                                .padding(horizontal = 10.dp, vertical = 8.dp),1117                        ) {1118                            BasicTextField(1119                                value = renameDraft,1120                                onValueChange = { renameDraft = it.take(64) },1121                                singleLine = true,1122                                textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1123                                cursorBrush = SolidColor(fg),1124                                modifier = Modifier.fillMaxWidth(),1125                            )1126                        }1127                    } else {1128                        Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1129                    }1130                    Text(1131                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1132                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1133                        modifier = Modifier.padding(top = 2.dp),1134                    )1135                }1136                if (confirmUnpair != d.deviceId) {1137                    if (renamingDeviceId == d.deviceId) {1138                        SubtleButton("cancel", fg) { renamingDeviceId = null }1139                        Spacer(Modifier.width(PeachSpacing.xs))1140                        SubtleButton("save", fg) {1141                            if (renameDraft.isNotBlank()) {1142                                syncVm.renameDevice(d.deviceId, renameDraft)1143                                renamingDeviceId = null1144                            }1145                        }1146                    } else {1147                        SubtleButton("rename", fg) {1148                            renameDraft = d.localAlias ?: d.deviceName ?: ""1149                            renamingDeviceId = d.deviceId1150                        }1151                    }1152                    if (renamingDeviceId != d.deviceId) {1153                        Spacer(Modifier.width(PeachSpacing.xs))1154                        SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1155                    }1156                }1157            }1158            // HOLE 4: revocation stops this device receiving future changes. It1159            // does NOT secure the vault against it — that device may already1160            // hold the recovery words, which are master-password-equivalent and1161            // restore the whole vault anywhere, forever, with no sync involved.1162            // Say so here rather than leaving the user falsely reassured.1163            if (confirmUnpair == d.deviceId) {1164                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1165                    Text(1166                        "Stops this device receiving future changes. It doesn't lock it " +1167                            "out of what it already has - if it's lost or stolen, also " +1168                            "change your master password and generate new recovery words.",1169                        color = fg.copy(alpha = 0.7f),1170                        fontFamily = PeachType.Body,1171                        fontSize = 11.sp,1172                        lineHeight = 16.sp,1173                    )1174                    Row(1175                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1176                        horizontalArrangement = Arrangement.End,1177                    ) {1178                        SubtleButton("cancel", fg) { confirmUnpair = null }1179                        Spacer(Modifier.width(PeachSpacing.sm))1180                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1181                    }1182                }1183            }1184        }1185    }11861187    SyncDisclosure(1188        title = "Connection",1189        summary = "Background sync, relay, and privacy choices",1190        expanded = showConnection,1191        fg = fg,1192        onToggle = { showConnection = !showConnection },1193    )1194    if (showConnection) {1195        RowLine(fg) {1196            Column(Modifier.weight(1f)) {1197                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1198                Text(1199                    when {1200                        backgroundSyncEnabled && !notificationsAvailable ->1201                            "Action needed — Android notifications are off, so background sync is not visible."1202                        backgroundSyncEnabled ->1203                            "On — keeps this phone reachable for paired devices."1204                        else ->1205                            "Off — Peach syncs only while you are using the app."1206                    },1207                    color = fg.copy(alpha = 0.7f),1208                    fontFamily = PeachType.Body,1209                    fontSize = 11.sp,1210                    lineHeight = 16.sp,1211                    modifier = Modifier.padding(top = 2.dp),1212                )1213            }1214            SubtleButton(1215                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1216                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1217            ) { showBackgroundSyncPrompt = true }1218        }1219        SyncModeRow(1220            title = "Public relay",1221            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1222            selected = syncMode == SyncMode.PUBLIC,1223            fg = fg,1224        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1225        SyncModeRow(1226            title = "Private relay",1227            detail = "Use your own peach-relay on your own domain.",1228            selected = syncMode == SyncMode.PRIVATE,1229            fg = fg,1230        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }12311232        if (syncMode == SyncMode.PRIVATE) {1233            Text(1234                "RELAY URL",1235                color = fg.copy(alpha = 0.6f),1236                fontFamily = PeachType.Mono,1237                fontSize = 8.sp,1238                letterSpacing = 0.22.em,1239                modifier = Modifier.padding(top = 8.dp),1240            )1241            Box(1242                Modifier.fillMaxWidth().padding(top = 6.dp)1243                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1244                    .padding(horizontal = 12.dp, vertical = 10.dp),1245            ) {1246                BasicTextField(1247                    value = relayUrlDraft,1248                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1249                    singleLine = true,1250                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1251                    cursorBrush = SolidColor(fg),1252                    modifier = Modifier.fillMaxWidth(),1253                )1254            }1255            val needsRelayUrl = configuredRelayUrl.isEmpty()1256            RowLine(fg) {1257                val message = relayUrlStatus1258                    ?: if (needsRelayUrl) {1259                        "Sync is paused until you enter your relay URL."1260                    } else {1261                        "Only secure wss:// relays are accepted."1262                    }1263                val warn = relayUrlStatus?.startsWith("Use") == true ||1264                    (relayUrlStatus == null && needsRelayUrl)1265                ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1266                Spacer(Modifier.width(PeachSpacing.xs))1267                SubtleButton("save", fg) {1268                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1269                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1270                        "Saved"1271                    } else {1272                        "Use a valid wss:// relay URL"1273                    }1274                }1275            }1276        }12771278        SyncModeRow(1279            title = "No sync",1280            detail = "Keep this device separate and move backups by hand.",1281            selected = syncMode == SyncMode.OFF,1282            fg = fg,1283        ) { syncVm.setSyncMode(SyncMode.OFF) }1284        Hint("Pairing authorizes a device, not a cloud account.", fg)1285    }12861287    if (pendingVouched.isNotEmpty()) {1288        SubLabel("PENDING DEVICES", fg)1289        pendingVouched.forEach { dev ->1290            RowLine(fg) {1291                Column(Modifier.weight(1f)) {1292                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1293                    Text(1294                        "Was added by another device. Allow it to sync?",1295                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1296                        modifier = Modifier.padding(top = 2.dp),1297                    )1298                }1299                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1300                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1301                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1302                }1303            }1304        }1305    }13061307    SubLabel("ADD A DEVICE", fg)1308    RowLine(fg) {1309        Column(Modifier.weight(1f)) {1310            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1311            Text(1312                "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",1313                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1314                modifier = Modifier.padding(top = 2.dp),1315            )1316        }1317        // "Scan" opens the camera-driven AdoptVault flow: the phone is the1318        // receiver. "Show a code" opens CodePair: the phone is the source.1319        // Both are valid pairing entry points; without scan, a phone that wants1320        // to join an existing browser-only vault has no path.1321        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1322        Spacer(Modifier.width(PeachSpacing.xs))1323        SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }1324    }13251326    SyncDisclosure(1327        title = "Recovery & security",1328        summary = "For a lost or stolen device",1329        expanded = showRecovery,1330        fg = fg,1331        onToggle = { showRecovery = !showRecovery },1332    )1333    if (showRecovery) {1334        RowLine(fg) {1335            Column(Modifier.weight(1f)) {1336                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1337                Text(1338                    "Scan only when this populated vault previously trusted the source device.",1339                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1340                    modifier = Modifier.padding(top = 2.dp),1341                )1342            }1343            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1344        }1345        if (rotateConfirmOpen) {1346            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1347            rotateError?.let {1348                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1349            }1350            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1351                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1352                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1353                    if (rotating) return@SubtleButton1354                    rotating = true1355                    rotateError = null1356                    scope.launch {1357                        val ok = syncVm.rotateSyncIdentity()1358                        rotating = false1359                        if (ok) {1360                            rotateConfirmOpen = false1361                            onNavigate(PeachNav.RecoverySetup)1362                        } else {1363                            rotateError = "Rotation failed. Unlock your real vault and try again."1364                        }1365                    }1366                }1367            }1368        } else {1369            RowLine(fg) {1370                Column(Modifier.weight(1f)) {1371                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1372                    Text(1373                        "Disconnects every device and replaces the keys used for sync.",1374                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1375                        modifier = Modifier.padding(top = 2.dp),1376                    )1377                }1378                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1379            }1380        }1381    }13821383    if (showBackgroundSyncPrompt) {1384        BackgroundSyncDialog { enabled ->1385            showBackgroundSyncPrompt = false1386            notificationsAvailable = canShowBackgroundSyncNotification(context)1387            syncVm.setBackgroundSyncEnabled(enabled)1388        }1389    }1390}13911392private fun deviceTypeLabel(type: String?): String = when (type) {1393    "android" -> "Android"1394    "phone" -> "Phone"1395    "tablet" -> "Tablet"1396    "browser" -> "Browser"1397    else -> "Device"1398}13991400private fun syncTimeLabel(lastSyncedAt: Long?): String {1401    if (lastSyncedAt == null) return "never synced"1402    val diff = System.currentTimeMillis() - lastSyncedAt1403    if (diff < 60_000) return "just now"1404    if (diff < 3_600_000) return "${diff / 60_000}m ago"1405    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1406    return "${diff / 86_400_000}d ago"1407}14081409private fun isSyncStale(lastSyncedAt: Long?): Boolean =1410    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L14111412/* ────────────────────────────────  Pro  ─────────────────────────────────── */14131414@Composable1415private fun ProBody(fg: Color, vm: SettingsViewModel) {1416    val ctx = LocalContext.current1417    val isPro by vm.isPro.collectAsState()1418    if (isPro) {1419        ProActiveBody(fg, vm)1420        return1421    }1422    val features = listOf(1423        "Peach Sync" to "End-to-end encrypted",1424        "Peach Codex" to "Restore from paper",1425        "Passkeys" to "Zero-knowledge WebAuthn",1426        "Advanced Vault Health" to "Reuse · age · exposure",1427        "Canary entries" to "Detects vault compromise",1428        "Duress vault" to "Decoy under coercion",1429    )1430    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1431    Text(1432        "Everything, everywhere.",1433        modifier = Modifier.padding(top = 8.dp),1434        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1435    )1436    Text(1437        "One key unlocks every Pro feature — for the lifetime of the app.",1438        modifier = Modifier.padding(top = 6.dp),1439        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1440    )1441    Spacer(Modifier.height(10.dp))1442    features.forEachIndexed { i, (name, desc) ->1443        // Compact rows (6dp vs. the shared 9dp default) — this card is the1444        // tallest in the deck (6 features + price + CTA) and used to leave1445        // its button with no breathing room before the fanned tab stacked1446        // right beneath it in preview mode.1447        RowLine(fg, verticalPadding = 6.dp) {1448            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1449                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1450                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1451            }1452            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1453        }1454    }1455    Spacer(Modifier.height(12.dp))1456    Text(1457        "$39",1458        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1459    )1460    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1461    Spacer(Modifier.height(10.dp))1462    Box(1463        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1464            .background(fg.copy(alpha = 0.16f))1465            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1466            .clickable {1467                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1468            }1469            .padding(vertical = 12.dp),1470        contentAlignment = Alignment.Center,1471    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }14721473    // Trailing breathing room so the button clears the fanned tab stacked1474    // beneath this card in preview mode, where the scrollable viewport clips1475    // right around here (the license-key section below is already out of1476    // view at that height).1477    Spacer(Modifier.height(44.dp))14781479    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1480    var key by remember { mutableStateOf("") }1481    val activating by vm.activating.collectAsState()1482    val activationError by vm.activationError.collectAsState()1483    Box(1484        Modifier.fillMaxWidth().padding(top = 6.dp)1485            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1486            .padding(horizontal = 12.dp, vertical = 10.dp),1487    ) {1488        BasicTextField(1489            value = key,1490            // NOT .uppercase() — base58 (the license payload encoding) is1491            // case-sensitive; forcing the whole string to uppercase silently1492            // corrupted every real key before it ever reached signature1493            // verification, so activation always failed with a real key.1494            onValueChange = { key = it; vm.clearActivationError() },1495            singleLine = true,1496            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1497            cursorBrush = SolidColor(fg),1498            modifier = Modifier.fillMaxWidth(),1499            decorationBox = { inner ->1500                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1501                inner()1502            },1503        )1504    }1505    if (activationError != null) {1506        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1507    }1508    Spacer(Modifier.height(8.dp))1509    Box(1510        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1511            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1512            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1513            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1514            .padding(vertical = 12.dp),1515        contentAlignment = Alignment.Center,1516    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1517}15181519@Composable1520private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1521    val ctx = LocalContext.current1522    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1523    val licenseKey by vm.licenseKey.collectAsState()1524    val issuedAt by vm.licenseIssuedAt.collectAsState()1525    var showKey by remember { mutableStateOf(false) }1526    var copied by remember { mutableStateOf(false) }1527    val scope = rememberCoroutineScope()1528    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1529        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1530    }1531    Text(1532        "Peach Pro",1533        modifier = Modifier.fillMaxWidth(),1534        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1535        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1536    )1537    Text(1538        formatLicenseActiveSince(issuedAt),1539        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1540        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1541        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1542    )1543    licenseKey?.let { key ->1544        Spacer(Modifier.height(12.dp))1545        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1546        RowLine(fg) {1547            Text(1548                if (showKey) key else "************************",1549                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1550                fontFamily = PeachType.Mono,1551                fontSize = 10.sp,1552                modifier = Modifier.weight(1f),1553            )1554            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1555                SubtleButton(if (copied) "copied" else "copy", fg) {1556                    PeachClipboard.copy(ctx, "peach license", key)1557                    copied = true1558                    scope.launch {1559                        delay(1_500)1560                        copied = false1561                    }1562                }1563                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1564            }1565        }1566    }1567    Spacer(Modifier.height(12.dp))1568    features.forEachIndexed { i, name ->1569        RowLine(fg) {1570            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1571                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1572                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1573            }1574            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1575        }1576    }1577    Spacer(Modifier.height(12.dp))1578    Box(1579        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1580            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1581            .clickable { vm.deactivate() }1582            .padding(vertical = 12.dp),1583        contentAlignment = Alignment.Center,1584    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1585}15861587private fun formatLicenseActiveSince(issuedAt: Long?): String {1588    if (issuedAt == null) return "Active"1589    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1590    return "Active since $monthYear"1591}15921593/* ──────────────────────────────  Feedback  ───────────────────────────────── */15941595@OptIn(ExperimentalLayoutApi::class)1596@Composable1597private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1598    val categories = listOf("Bug report", "Feature request", "Question", "Other")1599    var category by remember { mutableStateOf(categories[0]) }1600    var message by remember { mutableStateOf("") }1601    val state by vm.feedbackState.collectAsState()16021603    // Reset transient state when the card leaves composition so reopening starts fresh.1604    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }16051606    if (state is FeedbackState.Sent) {1607        Box(1608            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1609                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1610        ) {1611            Column {1612                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1613                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))1614            }1615        }1616        return1617    }16181619    val sending = state is FeedbackState.Sending16201621    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)1622    SubLabel("TYPE", fg)1623    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1624        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1625    }1626    SubLabel("MESSAGE", fg)1627    Box(1628        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1629            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1630            .padding(10.dp),1631    ) {1632        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1633        BasicTextField(1634            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1635            enabled = !sending,1636            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1637            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1638        )1639    }1640    Text(1641        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1642        modifier = Modifier.padding(top = 6.dp),1643        color = fg.copy(alpha = 0.52f),1644        fontFamily = PeachType.Mono,1645        fontSize = 10.sp,1646        lineHeight = 14.sp,1647    )1648    Spacer(Modifier.height(8.dp))16491650    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1651        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1652        Box(1653            Modifier.clip(RoundedCornerShape(8.dp))1654                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1655                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1656                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1657                .padding(horizontal = 12.dp, vertical = 6.dp),1658        ) {1659            Text(1660                feedbackSubmitLabel(state),1661                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1662                fontFamily = PeachType.Mono, fontSize = 11.sp,1663            )1664        }1665        if (sending) {1666            CircularProgressIndicator(1667                color = LocalPeachAccent.current,1668                strokeWidth = 2.dp,1669                modifier = Modifier.size(16.dp),1670            )1671        }1672    }16731674    (state as? FeedbackState.Error)?.let { err ->1675        Text(1676            err.message,1677            modifier = Modifier.padding(top = 8.dp),1678            color = peachColors.Breach,1679            fontFamily = PeachType.Mono,1680            fontSize = 11.sp,1681            lineHeight = 15.sp,1682        )1683    }16841685    Hint("Sent anonymously · no account required", fg)1686}16871688/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */16891690@Composable1691internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1692    val configured by vm.duressConfigured.collectAsState()1693    val isPro by vm.isPro.collectAsState()1694    if (configured) {1695        if (!isPro) {1696            // Never render "off" for an armed password: the state is real, the1697            // user simply cannot save a new one until Pro returns (PB-26-122).1698            Text(1699                "Duress password is active · Pro required to change or add one",1700                color = peachColors.Breach,1701                fontFamily = PeachType.Mono,1702                fontSize = 11.sp,1703                modifier = Modifier.padding(top = 6.dp),1704            )1705            Spacer(Modifier.height(8.dp))1706        }1707        DuressActiveState(fg, vm)1708    } else if (isPro) {1709        DuressForm(fg, vm)1710    } else {1711        RowLine(fg) {1712            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1713                Name("Duress password", fg); ProBadge(fg)1714            }1715            ValueText("off", fg)1716        }1717        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)1718    }1719}17201721@Composable1722private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1723    Text(1724        "Duress password is active",1725        color = peachColors.HealthOk,1726        fontFamily = PeachType.Mono,1727        fontSize = 11.sp,1728        modifier = Modifier.padding(top = 6.dp),1729    )1730    Spacer(Modifier.height(8.dp))1731    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1732    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1733}17341735@Composable1736private fun DuressForm(fg: Color, vm: SettingsViewModel) {1737    var enabled by remember { mutableStateOf(false) }1738    var password by remember { mutableStateOf("") }1739    var confirm by remember { mutableStateOf("") }1740    var action by remember { mutableStateOf(DuressAction.DECOY) }1741    var error by remember { mutableStateOf<String?>(null) }1742    val scope = rememberCoroutineScope()17431744    RowLine(fg) {1745        Name("Duress password", fg)1746        Toggle(enabled, fg) { on ->1747            enabled = on1748            if (!on) { password = ""; confirm = ""; error = null }1749        }1750    }1751    Text(1752        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1753        color = fg.copy(alpha = 0.7f),1754        fontFamily = PeachType.Mono,1755        fontSize = 10.sp,1756        modifier = Modifier.padding(top = 6.dp),1757    )17581759    AnimatedVisibility(enabled) {1760        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1761            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1762            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }17631764            Text(1765                "When your duress password is entered at the lock screen, Peach will:",1766                color = fg.copy(alpha = 0.7f),1767                fontFamily = PeachType.Mono,1768                fontSize = 10.sp,1769                modifier = Modifier.padding(top = 4.dp),1770            )17711772            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1773            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }17741775            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }17761777            val canSave = password.isNotBlank() && confirm.isNotBlank()1778            DuressSaveButton("Save duress password", fg, canSave) {1779                error = when {1780                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1781                    password != confirm -> "Passwords do not match"1782                    else -> null1783                }1784                if (error == null) scope.launch {1785                    if (vm.saveDuress(password, action)) {1786                        password = ""; confirm = ""; enabled = false1787                    } else {1788                        error = "Couldn't save — the duress passphrase must differ from your real password"1789                    }1790                }1791            }1792        }1793    }1794}17951796@Composable1797private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1798    val accent = LocalPeachAccent.current1799    Row(1800        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1801        verticalAlignment = Alignment.CenterVertically,1802        horizontalArrangement = Arrangement.spacedBy(10.dp),1803    ) {1804        Box(1805            Modifier.size(16.dp).clip(CircleShape)1806                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1807            contentAlignment = Alignment.Center,1808        ) {1809            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1810        }1811        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1812    }1813}18141815@Composable1816private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1817    val accent = LocalPeachAccent.current1818    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1819    Box(1820        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1821            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1822            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1823            .let { if (enabled) it.clickable(onClick = onClick) else it }1824            .padding(vertical = 11.dp),1825        contentAlignment = Alignment.Center,1826    ) {1827        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1828    }1829}18301831@Composable1832private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1833    Box(1834        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1835            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1836            .clickable(onClick = onClick)1837            .padding(vertical = 11.dp),1838        contentAlignment = Alignment.Center,1839    ) {1840        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1841    }1842}18431844/** One choice in the three-way sync selector. */1845@Composable1846private fun SyncModeRow(1847    title: String,1848    detail: String,1849    selected: Boolean,1850    fg: Color,1851    onSelect: () -> Unit,1852) {1853    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1854        Column(Modifier.weight(1f)) {1855            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1856            Text(1857                detail,1858                color = fg.copy(alpha = 0.7f),1859                fontFamily = PeachType.Body,1860                fontSize = 11.sp,1861                lineHeight = 16.sp,1862                modifier = Modifier.padding(top = 2.dp),1863            )1864        }1865        Text(1866            if (selected) "●" else "○",1867            color = if (selected) fg else fg.copy(alpha = 0.45f),1868            fontFamily = PeachType.Mono,1869            fontSize = 14.sp,1870            modifier = Modifier.padding(start = 10.dp),1871        )1872    }1873}