SettingsCardBodies.kt

74.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.SyncMode36import com.peachpasswords.sync.canShowBackgroundSyncNotification37import com.peachpasswords.sync.describeSyncOutcome38import com.peachpasswords.sync.privateRelayDraftOf39import com.peachpasswords.sync.syncIndicator40import com.peachpasswords.sync.syncIndicatorLabel41import com.peachpasswords.ui.components.SyncIndicatorDot42import com.peachpasswords.ui.theme.LocalPeachAccent43import com.peachpasswords.vault.DuressAction44import com.peachpasswords.ui.theme.peachColors45import androidx.compose.runtime.mutableStateOf46import androidx.compose.runtime.remember47import androidx.compose.runtime.rememberCoroutineScope48import androidx.compose.runtime.setValue49import androidx.compose.ui.Alignment50import androidx.compose.ui.Modifier51import androidx.compose.ui.draw.clip52import androidx.compose.ui.graphics.Color53import androidx.compose.ui.graphics.SolidColor54import androidx.compose.ui.platform.LocalContext55import androidx.compose.ui.text.TextStyle56import androidx.compose.ui.text.font.FontStyle57import androidx.compose.ui.text.input.PasswordVisualTransformation58import androidx.compose.ui.text.input.KeyboardType59import androidx.compose.ui.unit.Dp60import androidx.compose.ui.unit.dp61import androidx.compose.ui.unit.em62import androidx.compose.ui.unit.sp63import androidx.compose.material3.Text64import com.peachpasswords.BuildConfig65import com.peachpasswords.autofill.AutofillEnablement66import com.peachpasswords.ui.PeachNav67import com.peachpasswords.ui.components.PeachEmptyOrEndState68import com.peachpasswords.ui.components.PeachField69import com.peachpasswords.ui.pairing.BackgroundSyncDialog70import com.peachpasswords.ui.sync.SyncViewModel71import com.peachpasswords.ui.theme.ACCENT_COLORS72import com.peachpasswords.ui.theme.PeachSpacing73import com.peachpasswords.ui.theme.PeachType74import com.peachpasswords.ui.updates.ChangelogPhase75import com.peachpasswords.ui.updates.UpdatePhase76import com.peachpasswords.ui.updates.UpdateViewModel77import com.peachpasswords.updates.compareVersions78import androidx.hilt.navigation.compose.hiltViewModel79import com.peachpasswords.vault.VaultSettings80import com.peachpasswords.vault.VaultSnapshot81import com.peachpasswords.vault.faviconsAllowed82import kotlinx.coroutines.launch83import kotlinx.coroutines.delay84import java.text.SimpleDateFormat85import java.util.Date86import java.util.Locale8788private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8990internal const val FAVICON_DISCLOSURE_TEXT =91    "Peach requests site icons from DuckDuckGo. Turning this on discloses your full vault domain inventory to DuckDuckGo as icons are requested."9293/**94 * Body content for the active stack card. Everything is tinted with the card's95 * own foreground [fg] (the browser's `currentColor` convention), so each card's96 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,97 * canary, license activation) render their browser visual but defer the action98 * to a later phase — noted inline where that's the case.99 */100@Composable101fun CardBody(102    id: CardId,103    fg: Color,104    vm: SettingsViewModel,105    settings: VaultSettings,106    entriesCount: Int,107    onNavigate: (PeachNav) -> Unit,108) {109    when (id) {110        CardId.Appearance -> AppearanceBody(fg, vm, settings)111        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)112        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)113        CardId.Sync -> SyncBody(fg, onNavigate)114        CardId.Pro -> ProBody(fg, vm)115        CardId.Feedback -> FeedbackBody(fg, vm)116    }117}118119/* ─────────────────────────  shared row vocabulary  ───────────────────────── */120121@Composable122private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {123    Text(124        text,125        modifier = Modifier.padding(top = top, bottom = 4.dp),126        color = fg.copy(alpha = 0.58f),127        fontFamily = PeachType.Mono,128        fontSize = 8.sp,129        letterSpacing = 0.22.em,130    )131}132133/**134 * Collapsible group inside a card body: the uppercase heading row toggles its135 * rows, so busy cards can fold rarely-used sections away. Groups holding136 * dangerous or rarely-used actions start collapsed.137 */138@Composable139private fun SettingsGroup(140    label: String,141    fg: Color,142    initiallyOpen: Boolean = true,143    top: Dp = 14.dp,144    content: @Composable () -> Unit,145) {146    var open by remember { mutableStateOf(initiallyOpen) }147    Row(148        Modifier.fillMaxWidth()149            .clip(RoundedCornerShape(6.dp))150            .clickable { open = !open }151            .padding(top = top, bottom = 4.dp),152        verticalAlignment = Alignment.CenterVertically,153    ) {154        Text(155            label,156            modifier = Modifier.weight(1f),157            color = fg.copy(alpha = 0.58f),158            fontFamily = PeachType.Mono,159            fontSize = 8.sp,160            letterSpacing = 0.22.em,161        )162        Text(163            if (open) "–" else "+",164            color = fg.copy(alpha = 0.58f),165            fontFamily = PeachType.Mono,166            fontSize = 10.sp,167        )168    }169    // AnimatedVisibility stacks sibling children like a Box, so multi-row group170    // content must sit in an explicit Column or the rows overlap.171    AnimatedVisibility(visible = open) {172        Column(Modifier.fillMaxWidth()) { content() }173    }174}175176@Composable177private fun RowLine(178    fg: Color,179    modifier: Modifier = Modifier,180    verticalPadding: Dp = 9.dp,181    content: @Composable RowScope.() -> Unit,182) {183    Row(184        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),185        verticalAlignment = Alignment.CenterVertically,186        horizontalArrangement = Arrangement.SpaceBetween,187        content = content,188    )189    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))190}191192@Composable193private fun RowScope.Name(text: String, fg: Color) {194    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))195}196197@Composable198private fun ValueText(text: String, fg: Color, modifier: Modifier = Modifier) {199    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier)200}201202@Composable203private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {204    Box(205        Modifier.clip(RoundedCornerShape(8.dp))206            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))207            .background(fg.copy(alpha = 0.08f))208            .clickable(onClick = onClick)209            .padding(horizontal = 12.dp, vertical = 6.dp),210    ) {211        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1)212    }213}214215@Composable216private fun SyncDisclosure(217    title: String,218    summary: String,219    expanded: Boolean,220    fg: Color,221    onToggle: () -> Unit,222) {223    Row(224        modifier = Modifier225            .fillMaxWidth()226            .heightIn(min = 48.dp)227            .clip(RoundedCornerShape(8.dp))228            .clickable(onClick = onToggle)229            .padding(vertical = 8.dp),230        verticalAlignment = Alignment.CenterVertically,231    ) {232        Column(Modifier.weight(1f)) {233            Text(234                title.uppercase(),235                color = fg.copy(alpha = 0.58f),236                fontFamily = PeachType.Mono,237                fontSize = 8.sp,238                letterSpacing = 0.22.em,239            )240            Text(241                summary,242                color = fg.copy(alpha = 0.7f),243                fontFamily = PeachType.Body,244                fontSize = 11.sp,245                modifier = Modifier.padding(top = 3.dp),246            )247        }248        Text(249            if (expanded) "hide" else "show",250            color = fg,251            fontFamily = PeachType.Mono,252            fontSize = 10.sp,253        )254    }255}256257@Composable258private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {259    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")260    Box(261        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))262            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))263            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))264            .clickable { onToggle(!on) },265    ) {266        Box(267            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)268                .padding(start = knob - 2.dp)269                .size(14.dp).clip(CircleShape)270                .background(if (on) fg else fg.copy(alpha = 0.58f)),271        )272    }273}274275@Composable276private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {277    Box(278        Modifier.clip(RoundedCornerShape(999.dp))279            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))280            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)281            .clickable(onClick = onClick)282            .padding(horizontal = 12.dp, vertical = 5.dp),283    ) {284        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)285    }286}287288@Composable289private fun ProBadge(fg: Color) {290    Box(291        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))292            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),293    ) {294        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)295    }296}297298@Composable299private fun Hint(text: String, fg: Color) {300    Text(301        text,302        modifier = Modifier.padding(top = 6.dp),303        color = fg.copy(alpha = 0.6f),304        fontFamily = PeachType.Display,305        fontStyle = FontStyle.Italic,306        fontSize = 11.5.sp,307        lineHeight = 16.sp,308    )309}310311/* ──────────────────────────────  Appearance  ─────────────────────────────── */312313@OptIn(ExperimentalLayoutApi::class)314@Composable315private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {316    var showFaviconDisclosure by remember { mutableStateOf(false) }317    SubLabel("ACCENT", fg, top = 0.dp)318    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {319        ACCENT_COLORS.forEach { (name, color) ->320            Box(321                Modifier.size(22.dp).clip(CircleShape).background(color)322                    .border(323                        if (s.accentColor == name) 2.dp else 1.dp,324                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),325                        CircleShape,326                    )327                    .clickable { vm.setAccent(name) },328            )329        }330    }331    SubLabel("TEXT SIZE", fg)332    RowLine(fg) {333        Name("Text size", fg)334        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {335            Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }336            Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }337            Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }338        }339    }340    SubLabel("UI SCALE", fg)341    RowLine(fg) {342        Name("Large-screen sizing", fg)343        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {344            Pill("compact", s.uiScale == "compact", fg) { vm.setUiScale("compact") }345            Pill("auto", s.uiScale == "auto", fg) { vm.setUiScale("auto") }346            Pill("large", s.uiScale == "large", fg) { vm.setUiScale("large") }347        }348    }349    Hint("Auto keeps foldables roomy while preventing tablet controls from inflating.", fg)350    SubLabel("ICONS", fg)351    RowLine(fg) {352        Name("Site icons", fg)353        val siteIconsAllowed = faviconsAllowed(s)354        SubtleButton(if (siteIconsAllowed) "On" else "Off", fg) {355            if (siteIconsAllowed) vm.setFaviconsEnabled(false) else showFaviconDisclosure = true356        }357    }358    if (showFaviconDisclosure) {359        AlertDialog(360            onDismissRequest = { showFaviconDisclosure = false },361            title = { Text("Enable site icons?", color = fg, fontFamily = PeachType.Body) },362            text = { Text(FAVICON_DISCLOSURE_TEXT, color = fg, fontFamily = PeachType.Body) },363            confirmButton = {364                SubtleButton("Enable", fg) {365                    showFaviconDisclosure = false366                    vm.setFaviconsEnabled(true)367                }368            },369            dismissButton = {370                SubtleButton("Cancel", fg) { showFaviconDisclosure = false }371            },372        )373    }374    SubLabel("MOTION", fg)375    RowLine(fg) {376        Name("Reduce motion", fg)377        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }378    }379}380381/* ──────────────────────────────  Protection  ─────────────────────────────── */382383@Composable384private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {385    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")386    val current = s.autoLockMinutes ?: 0387    val pinEnabled by vm.pinEnabled.collectAsState()388    var pinOpen by remember { mutableStateOf(false) }389    var pinRemovalOpen by remember { mutableStateOf(false) }390    var pinMaster by remember { mutableStateOf("") }391    var pin by remember { mutableStateOf("") }392    var pinConfirm by remember { mutableStateOf("") }393    var pinRemovalMaster by remember { mutableStateOf("") }394    var pinError by remember { mutableStateOf<String?>(null) }395    val pinScope = rememberCoroutineScope()396397    SubLabel("LOCKING", fg, top = 0.dp)398    RowLine(fg) {399        Name("Allow screenshots", fg)400        Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }401    }402    RowLine(fg) {403        Name("Auto-lock after", fg)404        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)405        SubtleButton(lockOptions[idx].second, fg) {406            val next = lockOptions[(idx + 1) % lockOptions.size].first407            vm.setAutoLockMinutes(if (next == 0) 0 else next)408        }409    }410    SubLabel("PIN UNLOCK", fg)411    RowLine(fg) {412        Name("Unlock with PIN", fg)413        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {414            ValueText(if (pinEnabled) "enabled" else "off", fg)415            SubtleButton(if (pinEnabled) "change" else "set", fg) {416                pin = ""417                pinConfirm = ""418                pinMaster = ""419                pinError = null420                pinOpen = !pinOpen421            }422        }423    }424    if (pinEnabled) {425        RowLine(fg) {426            Name("Remove PIN unlock", fg)427            SubtleButton("turn off", fg) {428                pinRemovalMaster = ""429                pinError = null430                pinRemovalOpen = !pinRemovalOpen431            }432        }433    }434    AnimatedVisibility(pinRemovalOpen) {435        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {436            PeachField(437                label = "master password",438                value = pinRemovalMaster,439                onValueChange = { pinRemovalMaster = it; pinError = null },440                keyboardType = KeyboardType.Password,441                visualTransformation = PasswordVisualTransformation(),442            )443            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }444            SubtleButton("remove PIN", fg) {445                pinScope.launch {446                    val error = vm.disablePin(pinRemovalMaster)447                    if (error == null) {448                        pinRemovalMaster = ""449                        pinRemovalOpen = false450                    } else {451                        pinError = error452                    }453                }454            }455        }456    }457    AnimatedVisibility(pinOpen) {458        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {459            PeachField(460                label = "master password",461                value = pinMaster,462                onValueChange = { pinMaster = it; pinError = null },463                keyboardType = KeyboardType.Password,464                visualTransformation = PasswordVisualTransformation(),465            )466            PeachField(467                label = "new PIN (4–12 digits)",468                value = pin,469                onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },470                keyboardType = KeyboardType.NumberPassword,471                visualTransformation = PasswordVisualTransformation(),472            )473            PeachField(474                label = "confirm PIN",475                value = pinConfirm,476                onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },477                keyboardType = KeyboardType.NumberPassword,478                visualTransformation = PasswordVisualTransformation(),479            )480            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }481            SubtleButton("save PIN", fg) {482                pinError = if (pin != pinConfirm) "PINs do not match" else null483                if (pinError == null) pinScope.launch {484                    val error = vm.savePin(pinMaster, pin)485                    if (error == null) {486                        pinMaster = ""487                        pin = ""488                        pinConfirm = ""489                        pinOpen = false490                    } else {491                        pinError = error492                    }493                }494            }495        }496    }497    SubLabel("BIOMETRICS", fg)498    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity499    RowLine(fg) {500        Name("Unlock with biometrics", fg)501        Toggle(s.biometricsEnabled, fg) { on ->502            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()503        }504    }505    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)506    if (bioError != null) {507        Text(508            bioError,509            color = com.peachpasswords.ui.theme.peachColors.Breach,510            fontFamily = PeachType.Mono, fontSize = 10.sp,511            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),512        )513    }514    SubLabel("AUTOFILL", fg)515    run {516        // Read live rather than cached: the user leaves for system settings and517        // comes back, so a remembered value would show the old answer.518        val ctx = LocalContext.current519        val enabled = AutofillEnablement.isEnabled(ctx)520        RowLine(fg) {521            Column(Modifier.weight(1f)) {522                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)523                Text(524                    if (enabled) {525                        "Peach is your autofill service."526                    } else {527                        "Android decides which app fills passwords. Turn Peach on in system settings."528                    },529                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,530                    modifier = Modifier.padding(top = 2.dp),531                )532            }533            if (!enabled) {534                SubtleButton("enable", fg) {535                    // Straight to the autofill picker with Peach preselected;536                    // some OEM builds ship no handler, so fall back rather than537                    // dead-ending on a button that does nothing.538                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }539                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }540                }541            } else {542                ValueText("on", fg)543            }544        }545    }546547    RowLine(fg) {548        Column(Modifier.weight(1f)) {549            Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)550            Text(551                "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",552                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,553                modifier = Modifier.padding(top = 2.dp),554            )555        }556        Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }557    }558559    SubLabel("PASSKEYS", fg)560    RowLine(fg) {561        Column(Modifier.weight(1f)) {562            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)563            Text(564                "Peach can handle passkey sign-in and creation through Android Credential Manager.",565                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,566                modifier = Modifier.padding(top = 2.dp),567            )568        }569        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }570    }571    RowLine(fg) {572        Name("Status", fg)573        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)574    }575    SubLabel("RECOVERY", fg)576    RowLine(fg) {577        Column(Modifier.weight(1f)) {578            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)579            Text(580                "Keep the paper copy current before rotating sync identity or replacing devices.",581                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,582                modifier = Modifier.padding(top = 2.dp),583            )584        }585        SubtleButton("view", fg) {586            if (bioActivity != null) {587                com.peachpasswords.ui.lock.Biometrics.authenticate(588                    activity = bioActivity,589                    title = "View recovery words",590                    subtitle = "Confirm your identity to view your recovery words",591                    onSuccess = { onNavigate(PeachNav.RecoveryView) },592                )593            } else onNavigate(PeachNav.RecoveryView)594        }595    }596    SubLabel("BREACH PROTECTION", fg)597    val checkingBreaches by vm.checkingBreaches.collectAsState()598    val breachResult by vm.breachResult.collectAsState()599    RowLine(fg) {600        Name("Breach monitoring", fg)601        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }602    }603    RowLine(fg) {604        Name("Check all passwords", fg)605        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }606    }607    breachResult?.let { result ->608        RowLine(fg) {609            Name("Last check", fg)610            ValueText(result, fg)611        }612    }613614    SubLabel("MASTER PASSWORD", fg)615    ChangePasswordRow(fg, vm)616617    SubLabel("DURESS PASSWORD", fg)618    val duressIsPro by vm.isPro.collectAsState()619    if (!duressIsPro) {620        RowLine(fg) {621            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {622                Name("Duress password", fg); ProBadge(fg)623            }624            ValueText("off", fg)625        }626        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)627    } else {628        DuressSection(fg, vm)629    }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@Composable947private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {948    val context = LocalContext.current949    val syncVm: SyncViewModel = hiltViewModel()950    val pairedDevices by syncVm.pairedDevices.collectAsState()951    val pendingVouched by syncVm.pendingVouched.collectAsState()952    val syncStatus by syncVm.status.collectAsState()953    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()954    val syncMode by syncVm.syncMode.collectAsState()955    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()956    var notificationsAvailable by remember {957        mutableStateOf(canShowBackgroundSyncNotification(context))958    }959    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }960    val scope = rememberCoroutineScope()961    var confirmUnpair by remember { mutableStateOf<String?>(null) }962    var renamingDeviceId by remember { mutableStateOf<String?>(null) }963    var renameDraft by remember { mutableStateOf("") }964    var rotateConfirmOpen by remember { mutableStateOf(false) }965    var rotating by remember { mutableStateOf(false) }966    var rotateError by remember { mutableStateOf<String?>(null) }967    var relayUrlDraft by remember(configuredRelayUrl) {968        mutableStateOf(privateRelayDraftOf(configuredRelayUrl))969    }970    var relayUrlStatus by remember { mutableStateOf<String?>(null) }971    var showConnection by remember { mutableStateOf(false) }972    var showRecovery by remember { mutableStateOf(false) }973974    SubLabel("ADD A DEVICE", fg, top = 0.dp)975    RowLine(fg) {976        Column(Modifier.weight(1f)) {977            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)978            Text(979                "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",980                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,981                modifier = Modifier.padding(top = 2.dp),982            )983        }984        // "Scan" opens the camera-driven AdoptVault flow: the phone is the985        // receiver. "Show a code" opens CodePair: the phone is the source.986        // Both are valid pairing entry points; without scan, a phone that wants987        // to join an existing browser-only vault has no path.988        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }989        Spacer(Modifier.width(PeachSpacing.xs))990        SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }991    }992993    SubLabel("SYNC STATUS", fg)994    val indicator = syncIndicator(syncStatus, pairedDevices.size)995    RowLine(fg) {996        SyncIndicatorDot(997            indicator = indicator,998            label = syncIndicatorLabel(indicator, syncStatus),999            modifier = Modifier.padding(end = PeachSpacing.sm),1000            size = 8.dp,1001        )1002        Text(1003            describeSyncOutcome(syncStatus),1004            color = fg.copy(alpha = 0.7f),1005            fontFamily = PeachType.Body,1006            fontSize = 11.sp,1007            lineHeight = 16.sp,1008            modifier = Modifier.weight(1f),1009        )1010        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {1011            if (!syncStatus.syncing) syncVm.syncNow()1012        }1013    }10141015    SubLabel("PAIRED DEVICES", fg)1016    if (pairedDevices.isEmpty()) {1017        PeachEmptyOrEndState(1018            message = "No paired devices yet.",1019            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",1020            modifier = Modifier.padding(top = PeachSpacing.xs),1021        )1022    } else {1023        pairedDevices.forEach { d ->1024            RowLine(fg) {1025                Column(Modifier.weight(1f)) {1026                    if (renamingDeviceId == d.deviceId) {1027                        Box(1028                            Modifier.fillMaxWidth()1029                                .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1030                                .padding(horizontal = 10.dp, vertical = 8.dp),1031                        ) {1032                            BasicTextField(1033                                value = renameDraft,1034                                onValueChange = { renameDraft = it.take(64) },1035                                singleLine = true,1036                                textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1037                                cursorBrush = SolidColor(fg),1038                                modifier = Modifier.fillMaxWidth(),1039                            )1040                        }1041                    } else {1042                        Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1043                    }1044                    Text(1045                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1046                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1047                        modifier = Modifier.padding(top = 2.dp),1048                    )1049                }1050                if (confirmUnpair != d.deviceId) {1051                    if (renamingDeviceId == d.deviceId) {1052                        SubtleButton("cancel", fg) { renamingDeviceId = null }1053                        Spacer(Modifier.width(PeachSpacing.xs))1054                        SubtleButton("save", fg) {1055                            if (renameDraft.isNotBlank()) {1056                                syncVm.renameDevice(d.deviceId, renameDraft)1057                                renamingDeviceId = null1058                            }1059                        }1060                    } else {1061                        SubtleButton("rename", fg) {1062                            renameDraft = d.localAlias ?: d.deviceName ?: ""1063                            renamingDeviceId = d.deviceId1064                        }1065                    }1066                    if (renamingDeviceId != d.deviceId) {1067                        Spacer(Modifier.width(PeachSpacing.xs))1068                        SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1069                    }1070                }1071            }1072            // HOLE 4: revocation stops this device receiving future changes. It1073            // does NOT secure the vault against it — that device may already1074            // hold the recovery words, which are master-password-equivalent and1075            // restore the whole vault anywhere, forever, with no sync involved.1076            // Say so here rather than leaving the user falsely reassured.1077            if (confirmUnpair == d.deviceId) {1078                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1079                    Text(1080                        "Stops this device receiving future changes. It doesn't lock it " +1081                            "out of what it already has - if it's lost or stolen, also " +1082                            "change your master password and generate new recovery words.",1083                        color = fg.copy(alpha = 0.7f),1084                        fontFamily = PeachType.Body,1085                        fontSize = 11.sp,1086                        lineHeight = 16.sp,1087                    )1088                    Row(1089                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1090                        horizontalArrangement = Arrangement.End,1091                    ) {1092                        SubtleButton("cancel", fg) { confirmUnpair = null }1093                        Spacer(Modifier.width(PeachSpacing.sm))1094                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1095                    }1096                }1097            }1098        }1099    }11001101    SyncDisclosure(1102        title = "Connection",1103        summary = "Background sync, relay, and privacy choices",1104        expanded = showConnection,1105        fg = fg,1106        onToggle = { showConnection = !showConnection },1107    )1108    if (showConnection) {1109        RowLine(fg) {1110            Column(Modifier.weight(1f)) {1111                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1112                Text(1113                    when {1114                        backgroundSyncEnabled && !notificationsAvailable ->1115                            "Action needed — Android notifications are off, so background sync is not visible."1116                        backgroundSyncEnabled ->1117                            "On — keeps this phone reachable for paired devices."1118                        else ->1119                            "Off — Peach syncs only while you are using the app."1120                    },1121                    color = fg.copy(alpha = 0.7f),1122                    fontFamily = PeachType.Body,1123                    fontSize = 11.sp,1124                    lineHeight = 16.sp,1125                    modifier = Modifier.padding(top = 2.dp),1126                )1127            }1128            SubtleButton(1129                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1130                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1131            ) { showBackgroundSyncPrompt = true }1132        }1133        SyncModeRow(1134            title = "Public relay",1135            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1136            selected = syncMode == SyncMode.PUBLIC,1137            fg = fg,1138        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1139        SyncModeRow(1140            title = "Private relay",1141            detail = "Use your own peach-relay on your own domain.",1142            selected = syncMode == SyncMode.PRIVATE,1143            fg = fg,1144        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }11451146        if (syncMode == SyncMode.PRIVATE) {1147            Text(1148                "RELAY URL",1149                color = fg.copy(alpha = 0.6f),1150                fontFamily = PeachType.Mono,1151                fontSize = 8.sp,1152                letterSpacing = 0.22.em,1153                modifier = Modifier.padding(top = 8.dp),1154            )1155            Box(1156                Modifier.fillMaxWidth().padding(top = 6.dp)1157                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1158                    .padding(horizontal = 12.dp, vertical = 10.dp),1159            ) {1160                BasicTextField(1161                    value = relayUrlDraft,1162                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1163                    singleLine = true,1164                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1165                    cursorBrush = SolidColor(fg),1166                    modifier = Modifier.fillMaxWidth(),1167                )1168            }1169            val needsRelayUrl = configuredRelayUrl.isEmpty()1170            RowLine(fg) {1171                val message = relayUrlStatus1172                    ?: if (needsRelayUrl) {1173                        "Sync is paused until you enter your relay URL."1174                    } else {1175                        "Only secure wss:// relays are accepted."1176                    }1177                val warn = relayUrlStatus?.startsWith("Use") == true ||1178                    (relayUrlStatus == null && needsRelayUrl)1179                ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1180                Spacer(Modifier.width(PeachSpacing.xs))1181                SubtleButton("save", fg) {1182                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1183                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1184                        "Saved"1185                    } else {1186                        "Use a valid wss:// relay URL"1187                    }1188                }1189            }1190        }11911192        SyncModeRow(1193            title = "No sync",1194            detail = "Keep this device separate and move backups by hand.",1195            selected = syncMode == SyncMode.OFF,1196            fg = fg,1197        ) { syncVm.setSyncMode(SyncMode.OFF) }1198        Hint("Pairing authorizes a device, not a cloud account.", fg)1199    }12001201    if (pendingVouched.isNotEmpty()) {1202        SubLabel("PENDING DEVICES", fg)1203        pendingVouched.forEach { dev ->1204            RowLine(fg) {1205                Column(Modifier.weight(1f)) {1206                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1207                    Text(1208                        "Was added by another device. Allow it to sync?",1209                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1210                        modifier = Modifier.padding(top = 2.dp),1211                    )1212                }1213                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1214                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1215                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1216                }1217            }1218        }1219    }12201221    SyncDisclosure(1222        title = "Recovery & security",1223        summary = "For a lost or stolen device",1224        expanded = showRecovery,1225        fg = fg,1226        onToggle = { showRecovery = !showRecovery },1227    )1228    if (showRecovery) {1229        RowLine(fg) {1230            Column(Modifier.weight(1f)) {1231                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1232                Text(1233                    "Scan only when this populated vault previously trusted the source device.",1234                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1235                    modifier = Modifier.padding(top = 2.dp),1236                )1237            }1238            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1239        }1240        if (rotateConfirmOpen) {1241            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1242            rotateError?.let {1243                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1244            }1245            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1246                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1247                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1248                    if (rotating) return@SubtleButton1249                    rotating = true1250                    rotateError = null1251                    scope.launch {1252                        val ok = syncVm.rotateSyncIdentity()1253                        rotating = false1254                        if (ok) {1255                            rotateConfirmOpen = false1256                            onNavigate(PeachNav.RecoverySetup)1257                        } else {1258                            rotateError = "Rotation failed. Unlock your real vault and try again."1259                        }1260                    }1261                }1262            }1263        } else {1264            RowLine(fg) {1265                Column(Modifier.weight(1f)) {1266                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1267                    Text(1268                        "Disconnects every device and replaces the keys used for sync.",1269                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1270                        modifier = Modifier.padding(top = 2.dp),1271                    )1272                }1273                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1274            }1275        }1276    }12771278    if (showBackgroundSyncPrompt) {1279        BackgroundSyncDialog { enabled ->1280            showBackgroundSyncPrompt = false1281            notificationsAvailable = canShowBackgroundSyncNotification(context)1282            syncVm.setBackgroundSyncEnabled(enabled)1283        }1284    }1285}12861287private fun deviceTypeLabel(type: String?): String = when (type) {1288    "android" -> "Android"1289    "phone" -> "Phone"1290    "tablet" -> "Tablet"1291    "browser" -> "Browser"1292    else -> "Device"1293}12941295private fun syncTimeLabel(lastSyncedAt: Long?): String {1296    if (lastSyncedAt == null) return "never synced"1297    val diff = System.currentTimeMillis() - lastSyncedAt1298    if (diff < 60_000) return "just now"1299    if (diff < 3_600_000) return "${diff / 60_000}m ago"1300    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1301    return "${diff / 86_400_000}d ago"1302}13031304private fun isSyncStale(lastSyncedAt: Long?): Boolean =1305    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L13061307/* ────────────────────────────────  Pro  ─────────────────────────────────── */13081309@Composable1310private fun ProBody(fg: Color, vm: SettingsViewModel) {1311    val ctx = LocalContext.current1312    val isPro by vm.isPro.collectAsState()1313    if (isPro) {1314        ProActiveBody(fg, vm)1315        return1316    }1317    val features = listOf(1318        "Peach Sync" to "End-to-end encrypted",1319        "Peach Codex" to "Restore from paper",1320        "Passkeys" to "Zero-knowledge WebAuthn",1321        "Advanced Vault Health" to "Reuse · age · exposure",1322        "Canary entries" to "Detects vault compromise",1323        "Duress vault" to "Decoy under coercion",1324    )1325    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1326    Text(1327        "Everything, everywhere.",1328        modifier = Modifier.padding(top = 8.dp),1329        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1330    )1331    Text(1332        "One key unlocks every Pro feature — for the lifetime of the app.",1333        modifier = Modifier.padding(top = 6.dp),1334        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1335    )1336    Spacer(Modifier.height(10.dp))1337    features.forEachIndexed { i, (name, desc) ->1338        // Compact rows (6dp vs. the shared 9dp default) — this card is the1339        // tallest in the deck (6 features + price + CTA) and used to leave1340        // its button with no breathing room before the fanned tab stacked1341        // right beneath it in preview mode.1342        RowLine(fg, verticalPadding = 6.dp) {1343            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1344                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1345                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1346            }1347            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1348        }1349    }1350    Spacer(Modifier.height(12.dp))1351    Text(1352        "$39",1353        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1354    )1355    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1356    Spacer(Modifier.height(10.dp))1357    Box(1358        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1359            .background(fg.copy(alpha = 0.16f))1360            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1361            .clickable {1362                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1363            }1364            .padding(vertical = 12.dp),1365        contentAlignment = Alignment.Center,1366    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }13671368    // Trailing breathing room so the button clears the fanned tab stacked1369    // beneath this card in preview mode, where the scrollable viewport clips1370    // right around here (the license-key section below is already out of1371    // view at that height).1372    Spacer(Modifier.height(44.dp))13731374    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1375    var key by remember { mutableStateOf("") }1376    val activating by vm.activating.collectAsState()1377    val activationError by vm.activationError.collectAsState()1378    Box(1379        Modifier.fillMaxWidth().padding(top = 6.dp)1380            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1381            .padding(horizontal = 12.dp, vertical = 10.dp),1382    ) {1383        BasicTextField(1384            value = key,1385            // NOT .uppercase() — base58 (the license payload encoding) is1386            // case-sensitive; forcing the whole string to uppercase silently1387            // corrupted every real key before it ever reached signature1388            // verification, so activation always failed with a real key.1389            onValueChange = { key = it; vm.clearActivationError() },1390            singleLine = true,1391            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1392            cursorBrush = SolidColor(fg),1393            modifier = Modifier.fillMaxWidth(),1394            decorationBox = { inner ->1395                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1396                inner()1397            },1398        )1399    }1400    if (activationError != null) {1401        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1402    }1403    Spacer(Modifier.height(8.dp))1404    Box(1405        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1406            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1407            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1408            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1409            .padding(vertical = 12.dp),1410        contentAlignment = Alignment.Center,1411    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1412}14131414@Composable1415private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1416    val ctx = LocalContext.current1417    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1418    val licenseKey by vm.licenseKey.collectAsState()1419    val issuedAt by vm.licenseIssuedAt.collectAsState()1420    var showKey by remember { mutableStateOf(false) }1421    var copied by remember { mutableStateOf(false) }1422    val scope = rememberCoroutineScope()1423    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1424        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1425    }1426    Text(1427        "Peach Pro",1428        modifier = Modifier.fillMaxWidth(),1429        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1430        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1431    )1432    Text(1433        formatLicenseActiveSince(issuedAt),1434        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1435        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1436        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1437    )1438    licenseKey?.let { key ->1439        Spacer(Modifier.height(12.dp))1440        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1441        RowLine(fg) {1442            Text(1443                if (showKey) key else "************************",1444                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1445                fontFamily = PeachType.Mono,1446                fontSize = 10.sp,1447                modifier = Modifier.weight(1f),1448            )1449            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1450                SubtleButton(if (copied) "copied" else "copy", fg) {1451                    PeachClipboard.copy(ctx, "peach license", key)1452                    copied = true1453                    scope.launch {1454                        delay(1_500)1455                        copied = false1456                    }1457                }1458                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1459            }1460        }1461    }1462    Spacer(Modifier.height(12.dp))1463    features.forEachIndexed { i, name ->1464        RowLine(fg) {1465            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1466                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1467                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1468            }1469            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1470        }1471    }1472    Spacer(Modifier.height(12.dp))1473    Box(1474        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1475            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1476            .clickable { vm.deactivate() }1477            .padding(vertical = 12.dp),1478        contentAlignment = Alignment.Center,1479    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1480}14811482private fun formatLicenseActiveSince(issuedAt: Long?): String {1483    if (issuedAt == null) return "Active"1484    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1485    return "Active since $monthYear"1486}14871488/* ──────────────────────────────  Feedback  ───────────────────────────────── */14891490@OptIn(ExperimentalLayoutApi::class)1491@Composable1492private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1493    val categories = listOf("Bug report", "Feature request", "Question", "Other")1494    var category by remember { mutableStateOf(categories[0]) }1495    var message by remember { mutableStateOf("") }1496    val state by vm.feedbackState.collectAsState()14971498    // Reset transient state when the card leaves composition so reopening starts fresh.1499    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }15001501    if (state is FeedbackState.Sent) {1502        Box(1503            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1504                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1505        ) {1506            Column {1507                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1508                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))1509            }1510        }1511        return1512    }15131514    val sending = state is FeedbackState.Sending15151516    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)1517    SubLabel("TYPE", fg)1518    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1519        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1520    }1521    SubLabel("MESSAGE", fg)1522    Box(1523        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1524            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1525            .padding(10.dp),1526    ) {1527        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1528        BasicTextField(1529            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1530            enabled = !sending,1531            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1532            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1533        )1534    }1535    Text(1536        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1537        modifier = Modifier.padding(top = 6.dp),1538        color = fg.copy(alpha = 0.52f),1539        fontFamily = PeachType.Mono,1540        fontSize = 10.sp,1541        lineHeight = 14.sp,1542    )1543    Spacer(Modifier.height(8.dp))15441545    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1546        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1547        Box(1548            Modifier.clip(RoundedCornerShape(8.dp))1549                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1550                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1551                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1552                .padding(horizontal = 12.dp, vertical = 6.dp),1553        ) {1554            Text(1555                feedbackSubmitLabel(state),1556                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1557                fontFamily = PeachType.Mono, fontSize = 11.sp,1558            )1559        }1560        if (sending) {1561            CircularProgressIndicator(1562                color = LocalPeachAccent.current,1563                strokeWidth = 2.dp,1564                modifier = Modifier.size(16.dp),1565            )1566        }1567    }15681569    (state as? FeedbackState.Error)?.let { err ->1570        Text(1571            err.message,1572            modifier = Modifier.padding(top = 8.dp),1573            color = peachColors.Breach,1574            fontFamily = PeachType.Mono,1575            fontSize = 11.sp,1576            lineHeight = 15.sp,1577        )1578    }15791580    Hint("Sent anonymously · no account required", fg)1581}15821583/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */15841585@Composable1586internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1587    val configured by vm.duressConfigured.collectAsState()1588    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1589}15901591@Composable1592private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1593    Text(1594        "Duress password is active",1595        color = peachColors.HealthOk,1596        fontFamily = PeachType.Mono,1597        fontSize = 11.sp,1598        modifier = Modifier.padding(top = 6.dp),1599    )1600    Spacer(Modifier.height(8.dp))1601    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1602    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1603}16041605@Composable1606private fun DuressForm(fg: Color, vm: SettingsViewModel) {1607    var enabled by remember { mutableStateOf(false) }1608    var password by remember { mutableStateOf("") }1609    var confirm by remember { mutableStateOf("") }1610    var action by remember { mutableStateOf(DuressAction.DECOY) }1611    var error by remember { mutableStateOf<String?>(null) }1612    val scope = rememberCoroutineScope()16131614    RowLine(fg) {1615        Name("Duress password", fg)1616        Toggle(enabled, fg) { on ->1617            enabled = on1618            if (!on) { password = ""; confirm = ""; error = null }1619        }1620    }1621    Text(1622        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1623        color = fg.copy(alpha = 0.7f),1624        fontFamily = PeachType.Mono,1625        fontSize = 10.sp,1626        modifier = Modifier.padding(top = 6.dp),1627    )16281629    AnimatedVisibility(enabled) {1630        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1631            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1632            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }16331634            Text(1635                "When your duress password is entered at the lock screen, Peach will:",1636                color = fg.copy(alpha = 0.7f),1637                fontFamily = PeachType.Mono,1638                fontSize = 10.sp,1639                modifier = Modifier.padding(top = 4.dp),1640            )16411642            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1643            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }16441645            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }16461647            val canSave = password.isNotBlank() && confirm.isNotBlank()1648            DuressSaveButton("Save duress password", fg, canSave) {1649                error = when {1650                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1651                    password != confirm -> "Passwords do not match"1652                    else -> null1653                }1654                if (error == null) scope.launch {1655                    if (vm.saveDuress(password, action)) {1656                        password = ""; confirm = ""; enabled = false1657                    } else {1658                        error = "Couldn't save — the duress passphrase must differ from your real password"1659                    }1660                }1661            }1662        }1663    }1664}16651666@Composable1667private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1668    val accent = LocalPeachAccent.current1669    Row(1670        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1671        verticalAlignment = Alignment.CenterVertically,1672        horizontalArrangement = Arrangement.spacedBy(10.dp),1673    ) {1674        Box(1675            Modifier.size(16.dp).clip(CircleShape)1676                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1677            contentAlignment = Alignment.Center,1678        ) {1679            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1680        }1681        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1682    }1683}16841685@Composable1686private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1687    val accent = LocalPeachAccent.current1688    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1689    Box(1690        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1691            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1692            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1693            .let { if (enabled) it.clickable(onClick = onClick) else it }1694            .padding(vertical = 11.dp),1695        contentAlignment = Alignment.Center,1696    ) {1697        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1698    }1699}17001701@Composable1702private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1703    Box(1704        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1705            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1706            .clickable(onClick = onClick)1707            .padding(vertical = 11.dp),1708        contentAlignment = Alignment.Center,1709    ) {1710        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1711    }1712}17131714/** One choice in the three-way sync selector. */1715@Composable1716private fun SyncModeRow(1717    title: String,1718    detail: String,1719    selected: Boolean,1720    fg: Color,1721    onSelect: () -> Unit,1722) {1723    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1724        Column(Modifier.weight(1f)) {1725            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1726            Text(1727                detail,1728                color = fg.copy(alpha = 0.7f),1729                fontFamily = PeachType.Body,1730                fontSize = 11.sp,1731                lineHeight = 16.sp,1732                modifier = Modifier.padding(top = 2.dp),1733            )1734        }1735        Text(1736            if (selected) "●" else "○",1737            color = if (selected) fg else fg.copy(alpha = 0.45f),1738            fontFamily = PeachType.Mono,1739            fontSize = 14.sp,1740            modifier = Modifier.padding(start = 10.dp),1741        )1742    }1743}