SettingsCardBodies.kt

74.6 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 kotlinx.coroutines.launch82import kotlinx.coroutines.delay83import java.text.SimpleDateFormat84import java.util.Date85import java.util.Locale8687private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8889internal const val FAVICON_DISCLOSURE_TEXT =90    "Peach requests site icons from DuckDuckGo. Turning this on discloses your full vault domain inventory to DuckDuckGo as icons are requested."9192/**93 * Body content for the active stack card. Everything is tinted with the card's94 * own foreground [fg] (the browser's `currentColor` convention), so each card's95 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,96 * canary, license activation) render their browser visual but defer the action97 * to a later phase — noted inline where that's the case.98 */99@Composable100fun CardBody(101    id: CardId,102    fg: Color,103    vm: SettingsViewModel,104    settings: VaultSettings,105    entriesCount: Int,106    onNavigate: (PeachNav) -> Unit,107) {108    when (id) {109        CardId.Appearance -> AppearanceBody(fg, vm, settings)110        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)111        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)112        CardId.Sync -> SyncBody(fg, onNavigate)113        CardId.Pro -> ProBody(fg, vm)114        CardId.Feedback -> FeedbackBody(fg, vm)115    }116}117118/* ─────────────────────────  shared row vocabulary  ───────────────────────── */119120@Composable121private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {122    Text(123        text,124        modifier = Modifier.padding(top = top, bottom = 4.dp),125        color = fg.copy(alpha = 0.58f),126        fontFamily = PeachType.Mono,127        fontSize = 8.sp,128        letterSpacing = 0.22.em,129    )130}131132/**133 * Collapsible group inside a card body: the uppercase heading row toggles its134 * rows, so busy cards can fold rarely-used sections away. Groups holding135 * dangerous or rarely-used actions start collapsed.136 */137@Composable138private fun SettingsGroup(139    label: String,140    fg: Color,141    initiallyOpen: Boolean = true,142    top: Dp = 14.dp,143    content: @Composable () -> Unit,144) {145    var open by remember { mutableStateOf(initiallyOpen) }146    Row(147        Modifier.fillMaxWidth()148            .clip(RoundedCornerShape(6.dp))149            .clickable { open = !open }150            .padding(top = top, bottom = 4.dp),151        verticalAlignment = Alignment.CenterVertically,152    ) {153        Text(154            label,155            modifier = Modifier.weight(1f),156            color = fg.copy(alpha = 0.58f),157            fontFamily = PeachType.Mono,158            fontSize = 8.sp,159            letterSpacing = 0.22.em,160        )161        Text(162            if (open) "–" else "+",163            color = fg.copy(alpha = 0.58f),164            fontFamily = PeachType.Mono,165            fontSize = 10.sp,166        )167    }168    // AnimatedVisibility stacks sibling children like a Box, so multi-row group169    // content must sit in an explicit Column or the rows overlap.170    AnimatedVisibility(visible = open) {171        Column(Modifier.fillMaxWidth()) { content() }172    }173}174175@Composable176private fun RowLine(177    fg: Color,178    modifier: Modifier = Modifier,179    verticalPadding: Dp = 9.dp,180    content: @Composable RowScope.() -> Unit,181) {182    Row(183        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),184        verticalAlignment = Alignment.CenterVertically,185        horizontalArrangement = Arrangement.SpaceBetween,186        content = content,187    )188    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))189}190191@Composable192private fun RowScope.Name(text: String, fg: Color) {193    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))194}195196@Composable197private fun ValueText(text: String, fg: Color, modifier: Modifier = Modifier) {198    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier)199}200201@Composable202private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {203    Box(204        Modifier.clip(RoundedCornerShape(8.dp))205            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))206            .background(fg.copy(alpha = 0.08f))207            .clickable(onClick = onClick)208            .padding(horizontal = 12.dp, vertical = 6.dp),209    ) {210        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1)211    }212}213214@Composable215private fun SyncDisclosure(216    title: String,217    summary: String,218    expanded: Boolean,219    fg: Color,220    onToggle: () -> Unit,221) {222    Row(223        modifier = Modifier224            .fillMaxWidth()225            .heightIn(min = 48.dp)226            .clip(RoundedCornerShape(8.dp))227            .clickable(onClick = onToggle)228            .padding(vertical = 8.dp),229        verticalAlignment = Alignment.CenterVertically,230    ) {231        Column(Modifier.weight(1f)) {232            Text(233                title.uppercase(),234                color = fg.copy(alpha = 0.58f),235                fontFamily = PeachType.Mono,236                fontSize = 8.sp,237                letterSpacing = 0.22.em,238            )239            Text(240                summary,241                color = fg.copy(alpha = 0.7f),242                fontFamily = PeachType.Body,243                fontSize = 11.sp,244                modifier = Modifier.padding(top = 3.dp),245            )246        }247        Text(248            if (expanded) "hide" else "show",249            color = fg,250            fontFamily = PeachType.Mono,251            fontSize = 10.sp,252        )253    }254}255256@Composable257private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {258    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")259    Box(260        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))261            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))262            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))263            .clickable { onToggle(!on) },264    ) {265        Box(266            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)267                .padding(start = knob - 2.dp)268                .size(14.dp).clip(CircleShape)269                .background(if (on) fg else fg.copy(alpha = 0.58f)),270        )271    }272}273274@Composable275private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {276    Box(277        Modifier.clip(RoundedCornerShape(999.dp))278            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))279            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)280            .clickable(onClick = onClick)281            .padding(horizontal = 12.dp, vertical = 5.dp),282    ) {283        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)284    }285}286287@Composable288private fun ProBadge(fg: Color) {289    Box(290        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))291            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),292    ) {293        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)294    }295}296297@Composable298private fun Hint(text: String, fg: Color) {299    Text(300        text,301        modifier = Modifier.padding(top = 6.dp),302        color = fg.copy(alpha = 0.6f),303        fontFamily = PeachType.Display,304        fontStyle = FontStyle.Italic,305        fontSize = 11.5.sp,306        lineHeight = 16.sp,307    )308}309310/* ──────────────────────────────  Appearance  ─────────────────────────────── */311312@OptIn(ExperimentalLayoutApi::class)313@Composable314private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {315    var showFaviconDisclosure by remember { mutableStateOf(false) }316    SubLabel("ACCENT", fg, top = 0.dp)317    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {318        ACCENT_COLORS.forEach { (name, color) ->319            Box(320                Modifier.size(22.dp).clip(CircleShape).background(color)321                    .border(322                        if (s.accentColor == name) 2.dp else 1.dp,323                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),324                        CircleShape,325                    )326                    .clickable { vm.setAccent(name) },327            )328        }329    }330    SubLabel("TEXT SIZE", fg)331    RowLine(fg) {332        Name("Text size", fg)333        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {334            Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }335            Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }336            Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }337        }338    }339    SubLabel("UI SCALE", fg)340    RowLine(fg) {341        Name("Large-screen sizing", fg)342        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {343            Pill("compact", s.uiScale == "compact", fg) { vm.setUiScale("compact") }344            Pill("auto", s.uiScale == "auto", fg) { vm.setUiScale("auto") }345            Pill("large", s.uiScale == "large", fg) { vm.setUiScale("large") }346        }347    }348    Hint("Auto keeps foldables roomy while preventing tablet controls from inflating.", fg)349    SubLabel("ICONS", fg)350    RowLine(fg) {351        Name("Site icons", fg)352        SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) {353            if (s.faviconsEnabled) vm.setFaviconsEnabled(false) else showFaviconDisclosure = true354        }355    }356    if (showFaviconDisclosure) {357        AlertDialog(358            onDismissRequest = { showFaviconDisclosure = false },359            title = { Text("Enable site icons?", color = fg, fontFamily = PeachType.Body) },360            text = { Text(FAVICON_DISCLOSURE_TEXT, color = fg, fontFamily = PeachType.Body) },361            confirmButton = {362                SubtleButton("Enable", fg) {363                    showFaviconDisclosure = false364                    vm.setFaviconsEnabled(true)365                }366            },367            dismissButton = {368                SubtleButton("Cancel", fg) { showFaviconDisclosure = false }369            },370        )371    }372    SubLabel("MOTION", fg)373    RowLine(fg) {374        Name("Reduce motion", fg)375        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }376    }377}378379/* ──────────────────────────────  Protection  ─────────────────────────────── */380381@Composable382private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {383    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")384    val current = s.autoLockMinutes ?: 0385    val pinEnabled by vm.pinEnabled.collectAsState()386    var pinOpen by remember { mutableStateOf(false) }387    var pinRemovalOpen by remember { mutableStateOf(false) }388    var pinMaster by remember { mutableStateOf("") }389    var pin by remember { mutableStateOf("") }390    var pinConfirm by remember { mutableStateOf("") }391    var pinRemovalMaster by remember { mutableStateOf("") }392    var pinError by remember { mutableStateOf<String?>(null) }393    val pinScope = rememberCoroutineScope()394395    SubLabel("LOCKING", fg, top = 0.dp)396    RowLine(fg) {397        Name("Allow screenshots", fg)398        Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }399    }400    RowLine(fg) {401        Name("Auto-lock after", fg)402        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)403        SubtleButton(lockOptions[idx].second, fg) {404            val next = lockOptions[(idx + 1) % lockOptions.size].first405            vm.setAutoLockMinutes(if (next == 0) 0 else next)406        }407    }408    SubLabel("PIN UNLOCK", fg)409    RowLine(fg) {410        Name("Unlock with PIN", fg)411        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {412            ValueText(if (pinEnabled) "enabled" else "off", fg)413            SubtleButton(if (pinEnabled) "change" else "set", fg) {414                pin = ""415                pinConfirm = ""416                pinMaster = ""417                pinError = null418                pinOpen = !pinOpen419            }420        }421    }422    if (pinEnabled) {423        RowLine(fg) {424            Name("Remove PIN unlock", fg)425            SubtleButton("turn off", fg) {426                pinRemovalMaster = ""427                pinError = null428                pinRemovalOpen = !pinRemovalOpen429            }430        }431    }432    AnimatedVisibility(pinRemovalOpen) {433        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {434            PeachField(435                label = "master password",436                value = pinRemovalMaster,437                onValueChange = { pinRemovalMaster = it; pinError = null },438                keyboardType = KeyboardType.Password,439                visualTransformation = PasswordVisualTransformation(),440            )441            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }442            SubtleButton("remove PIN", fg) {443                pinScope.launch {444                    val error = vm.disablePin(pinRemovalMaster)445                    if (error == null) {446                        pinRemovalMaster = ""447                        pinRemovalOpen = false448                    } else {449                        pinError = error450                    }451                }452            }453        }454    }455    AnimatedVisibility(pinOpen) {456        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {457            PeachField(458                label = "master password",459                value = pinMaster,460                onValueChange = { pinMaster = it; pinError = null },461                keyboardType = KeyboardType.Password,462                visualTransformation = PasswordVisualTransformation(),463            )464            PeachField(465                label = "new PIN (4–12 digits)",466                value = pin,467                onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },468                keyboardType = KeyboardType.NumberPassword,469                visualTransformation = PasswordVisualTransformation(),470            )471            PeachField(472                label = "confirm PIN",473                value = pinConfirm,474                onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },475                keyboardType = KeyboardType.NumberPassword,476                visualTransformation = PasswordVisualTransformation(),477            )478            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }479            SubtleButton("save PIN", fg) {480                pinError = if (pin != pinConfirm) "PINs do not match" else null481                if (pinError == null) pinScope.launch {482                    val error = vm.savePin(pinMaster, pin)483                    if (error == null) {484                        pinMaster = ""485                        pin = ""486                        pinConfirm = ""487                        pinOpen = false488                    } else {489                        pinError = error490                    }491                }492            }493        }494    }495    SubLabel("BIOMETRICS", fg)496    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity497    RowLine(fg) {498        Name("Unlock with biometrics", fg)499        Toggle(s.biometricsEnabled, fg) { on ->500            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()501        }502    }503    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)504    if (bioError != null) {505        Text(506            bioError,507            color = com.peachpasswords.ui.theme.peachColors.Breach,508            fontFamily = PeachType.Mono, fontSize = 10.sp,509            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),510        )511    }512    SubLabel("AUTOFILL", fg)513    run {514        // Read live rather than cached: the user leaves for system settings and515        // comes back, so a remembered value would show the old answer.516        val ctx = LocalContext.current517        val enabled = AutofillEnablement.isEnabled(ctx)518        RowLine(fg) {519            Column(Modifier.weight(1f)) {520                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)521                Text(522                    if (enabled) {523                        "Peach is your autofill service."524                    } else {525                        "Android decides which app fills passwords. Turn Peach on in system settings."526                    },527                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,528                    modifier = Modifier.padding(top = 2.dp),529                )530            }531            if (!enabled) {532                SubtleButton("enable", fg) {533                    // Straight to the autofill picker with Peach preselected;534                    // some OEM builds ship no handler, so fall back rather than535                    // dead-ending on a button that does nothing.536                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }537                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }538                }539            } else {540                ValueText("on", fg)541            }542        }543    }544545    RowLine(fg) {546        Column(Modifier.weight(1f)) {547            Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)548            Text(549                "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",550                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,551                modifier = Modifier.padding(top = 2.dp),552            )553        }554        Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }555    }556557    SubLabel("PASSKEYS", fg)558    RowLine(fg) {559        Column(Modifier.weight(1f)) {560            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)561            Text(562                "Peach can handle passkey sign-in and creation through Android Credential Manager.",563                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,564                modifier = Modifier.padding(top = 2.dp),565            )566        }567        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }568    }569    RowLine(fg) {570        Name("Status", fg)571        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)572    }573    SubLabel("RECOVERY", fg)574    RowLine(fg) {575        Column(Modifier.weight(1f)) {576            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)577            Text(578                "Keep the paper copy current before rotating sync identity or replacing devices.",579                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,580                modifier = Modifier.padding(top = 2.dp),581            )582        }583        SubtleButton("view", fg) {584            if (bioActivity != null) {585                com.peachpasswords.ui.lock.Biometrics.authenticate(586                    activity = bioActivity,587                    title = "View recovery words",588                    subtitle = "Confirm your identity to view your recovery words",589                    onSuccess = { onNavigate(PeachNav.RecoveryView) },590                )591            } else onNavigate(PeachNav.RecoveryView)592        }593    }594    SubLabel("BREACH PROTECTION", fg)595    val checkingBreaches by vm.checkingBreaches.collectAsState()596    val breachResult by vm.breachResult.collectAsState()597    RowLine(fg) {598        Name("Breach monitoring", fg)599        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }600    }601    RowLine(fg) {602        Name("Check all passwords", fg)603        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }604    }605    breachResult?.let { result ->606        RowLine(fg) {607            Name("Last check", fg)608            ValueText(result, fg)609        }610    }611612    SubLabel("MASTER PASSWORD", fg)613    ChangePasswordRow(fg, vm)614615    SubLabel("DURESS PASSWORD", fg)616    val duressIsPro by vm.isPro.collectAsState()617    if (!duressIsPro) {618        RowLine(fg) {619            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {620                Name("Duress password", fg); ProBadge(fg)621            }622            ValueText("off", fg)623        }624        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)625    } else {626        DuressSection(fg, vm)627    }628}629630@Composable631private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {632    var open by remember { mutableStateOf(false) }633    var current by remember { mutableStateOf("") }634    var next by remember { mutableStateOf("") }635    var confirm by remember { mutableStateOf("") }636    var error by remember { mutableStateOf<String?>(null) }637    var done by remember { mutableStateOf(false) }638    val scope = rememberCoroutineScope()639640    RowLine(fg) {641        Name("Master password", fg)642        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }643    }644    AnimatedVisibility(open) {645        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {646            PwField("current password", current, fg) { current = it }647            PwField("new password (min 12 chars)", next, fg) { next = it }648            PwField("confirm new password", confirm, fg) { confirm = it }649            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }650            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)651            SubtleButton("save new password", fg) {652                error = when {653                    current.isBlank() -> "enter your current password"654                    next.length < 12 -> "new password must be at least 12 characters"655                    next != confirm -> "new passwords do not match"656                    next == current -> "new password must differ from current"657                    else -> null658                }659                if (error == null) scope.launch {660                    if (vm.changePassword(current, next)) {661                        current = ""; next = ""; confirm = ""; open = false; done = true662                    } else error = "incorrect current password"663                }664            }665        }666    }667}668669@Composable670private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {671    PeachField(672        label = placeholder,673        value = value,674        onValueChange = onChange,675        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,676        visualTransformation = PasswordVisualTransformation(),677    )678}679680/* ────────────────────────────────  Vault  ───────────────────────────────── */681682@Composable683private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {684    val vm: UpdateViewModel = hiltViewModel()685    val available by vm.available.collectAsState()686    val state by vm.state.collectAsState()687    val changelog by vm.changelog.collectAsState()688    var changelogOpen by remember { mutableStateOf(false) }689690    RowLine(fg) {691        Name("Version", fg)692        ValueText(BuildConfig.VERSION_NAME, fg)693    }694    RowLine(fg) {695        Column(Modifier.weight(1f)) {696            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)697            Text(698                when {699                    available != null -> "v${available?.version} is available"700                    state.phase == UpdatePhase.Checking -> "Checking…"701                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"702                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"703                    else -> "Peach checks once a day and tells you when there's a new version."704                },705                color = fg.copy(alpha = 0.7f),706                fontFamily = PeachType.Body,707                fontSize = 11.sp,708                lineHeight = 16.sp,709                modifier = Modifier.padding(top = 2.dp),710            )711        }712        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {713            SubtleButton("changelog", fg) {714                changelogOpen = !changelogOpen715                if (changelogOpen) vm.loadChangelog()716            }717            if (available != null) {718                SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }719            } else {720                SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {721                    vm.check()722                }723            }724        }725    }726    if (changelogOpen) {727        when (changelog.phase) {728            ChangelogPhase.Idle -> {}729            ChangelogPhase.Loading -> Text(730                "Loading changelog…",731                color = fg.copy(alpha = 0.68f),732                fontFamily = PeachType.Body,733                fontSize = 11.sp,734                modifier = Modifier.padding(vertical = 8.dp),735            )736            ChangelogPhase.Failed -> RowLine(fg) {737                Name("Couldn't reach the update server", fg)738                SubtleButton("retry", fg) { vm.loadChangelog() }739            }740            ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {741                changelog.entries.forEachIndexed { index, entry ->742                    Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {743                        Row(verticalAlignment = Alignment.CenterVertically) {744                            Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)745                            if (compareVersions(entry.version, vm.currentVersion) == 0) {746                                Text("  current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)747                            }748                        }749                        Text(750                            entry.notes ?: "No notes for this release.",751                            color = fg.copy(alpha = 0.72f),752                            fontFamily = PeachType.Body,753                            fontSize = 11.sp,754                            lineHeight = 16.sp,755                            modifier = Modifier.padding(top = 3.dp),756                        )757                    }758                    if (index < changelog.entries.lastIndex) {759                        Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))760                    }761                }762            }763        }764    }765}766767@Composable768private fun VaultBody(769    fg: Color,770    vm: SettingsViewModel,771    s: VaultSettings,772    entriesCount: Int,773    onNavigate: (PeachNav) -> Unit,774) {775    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")776    val current = s.trashRetentionDays ?: 0777    val isPro by vm.isPro.collectAsState()778    val dedupCount by vm.dedupCount.collectAsState()779    val dedupRunning by vm.dedupRunning.collectAsState()780    val dedupDone by vm.dedupDone.collectAsState()781    val history by vm.vaultHistory.collectAsState()782    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()783784    SettingsGroup("ENTRIES", fg, top = 0.dp) {785    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }786    RowLine(fg) {787        Name("Duplicates", fg)788        val done = dedupDone789        if (done != null) {790            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)791        } else {792            val count = dedupCount793            val label = when {794                dedupRunning -> "scanning..."795                count == null -> "scan"796                count == 0 -> "none found"797                else -> "$count found"798            }799            SubtleButton(label, fg) {800                if (count == null || count == 0) vm.scanDuplicates()801            }802        }803    }804    if ((dedupCount ?: 0) > 0 && dedupDone == null) {805        Column(806            Modifier.fillMaxWidth()807                .padding(top = 2.dp, bottom = 8.dp)808                .clip(RoundedCornerShape(8.dp))809                .background(fg.copy(alpha = 0.08f))810                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))811                .padding(10.dp),812            verticalArrangement = Arrangement.spacedBy(8.dp),813        ) {814            val count = dedupCount ?: 0815            Text(816                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",817                color = fg.copy(alpha = 0.82f),818                fontFamily = PeachType.Body,819                fontSize = 11.sp,820                lineHeight = 16.sp,821            )822            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {823                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }824                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }825            }826        }827    }828    }829830    SettingsGroup("MAINTENANCE", fg) {831    RowLine(fg) {832        Name("Trash auto-delete", fg)833        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)834        SubtleButton(trashOptions[idx].second, fg) {835            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)836        }837    }838    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }839    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }840    }841842    SettingsGroup("DATA", fg) {843    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }844    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }845    RowLine(fg) {846        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }847        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }848    }849    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }850    }851852    SettingsGroup("UPDATES", fg) {853    UpdatesRows(fg, onNavigate)854    }855856    SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {857    var historyShowAll by remember { mutableStateOf(false) }858    if (history.isEmpty()) {859        Text(860            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",861            color = fg.copy(alpha = 0.68f),862            fontFamily = PeachType.Body,863            fontSize = 11.sp,864            lineHeight = 16.sp,865            modifier = Modifier.padding(bottom = 6.dp),866        )867    } else {868        Text(869            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",870            color = fg.copy(alpha = 0.68f),871            fontFamily = PeachType.Body,872            fontSize = 11.sp,873            lineHeight = 16.sp,874            modifier = Modifier.padding(bottom = 4.dp),875        )876        history.take(if (historyShowAll) history.size else 3).forEach { snap ->877            RowLine(fg) {878                Column(Modifier.weight(1f)) {879                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)880                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)881                }882                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {883                    vm.restoreVaultSnapshot(snap.id)884                }885            }886        }887        if (history.size > 3) {888            RowLine(fg) {889                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)890                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }891            }892        }893        Text(894            "${history.size} of 5 snapshots",895            color = fg.copy(alpha = 0.58f),896            fontFamily = PeachType.Mono,897            fontSize = 10.sp,898            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),899        )900    }901    }902903    SettingsGroup("ADVANCED", fg, initiallyOpen = false) {904        RowLine(fg) {905            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)906            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }907        }908        RowLine(fg) {909            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)910            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }911        }912    }913}914915private fun snapshotSubLabel(snapshot: VaultSnapshot): String {916    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }917    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")918}919920private fun snapshotReasonLabel(reason: String): String = when (reason) {921    "local_save" -> "saved"922    "sync_received" -> "synced"923    "import" -> "imported"924    "restore" -> "restored"925    else -> reason926}927928private fun snapshotTimeLabel(savedAt: Long): String {929    val diff = System.currentTimeMillis() - savedAt930    if (diff < 60_000) return "Just now"931    if (diff < 3_600_000) return "${diff / 60_000}m ago"932    val date = Date(savedAt)933    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())934    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)935    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)936    if (today == thenDay) return "Today $time"937    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))938    if (yesterday == thenDay) return "Yesterday $time"939    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)940}941942/* ────────────────────────────────  Sync  ────────────────────────────────── */943944@Composable945private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {946    val context = LocalContext.current947    val syncVm: SyncViewModel = hiltViewModel()948    val pairedDevices by syncVm.pairedDevices.collectAsState()949    val pendingVouched by syncVm.pendingVouched.collectAsState()950    val syncStatus by syncVm.status.collectAsState()951    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()952    val syncMode by syncVm.syncMode.collectAsState()953    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()954    var notificationsAvailable by remember {955        mutableStateOf(canShowBackgroundSyncNotification(context))956    }957    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }958    val scope = rememberCoroutineScope()959    var confirmUnpair by remember { mutableStateOf<String?>(null) }960    var renamingDeviceId by remember { mutableStateOf<String?>(null) }961    var renameDraft by remember { mutableStateOf("") }962    var rotateConfirmOpen by remember { mutableStateOf(false) }963    var rotating by remember { mutableStateOf(false) }964    var rotateError by remember { mutableStateOf<String?>(null) }965    var relayUrlDraft by remember(configuredRelayUrl) {966        mutableStateOf(privateRelayDraftOf(configuredRelayUrl))967    }968    var relayUrlStatus by remember { mutableStateOf<String?>(null) }969    var showConnection by remember { mutableStateOf(false) }970    var showRecovery by remember { mutableStateOf(false) }971972    SubLabel("ADD A DEVICE", fg, top = 0.dp)973    RowLine(fg) {974        Column(Modifier.weight(1f)) {975            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)976            Text(977                "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",978                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,979                modifier = Modifier.padding(top = 2.dp),980            )981        }982        // "Scan" opens the camera-driven AdoptVault flow: the phone is the983        // receiver. "Show a code" opens CodePair: the phone is the source.984        // Both are valid pairing entry points; without scan, a phone that wants985        // to join an existing browser-only vault has no path.986        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }987        Spacer(Modifier.width(PeachSpacing.xs))988        SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }989    }990991    SubLabel("SYNC STATUS", fg)992    val indicator = syncIndicator(syncStatus, pairedDevices.size)993    RowLine(fg) {994        SyncIndicatorDot(995            indicator = indicator,996            label = syncIndicatorLabel(indicator, syncStatus),997            modifier = Modifier.padding(end = PeachSpacing.sm),998            size = 8.dp,999        )1000        Text(1001            describeSyncOutcome(syncStatus),1002            color = fg.copy(alpha = 0.7f),1003            fontFamily = PeachType.Body,1004            fontSize = 11.sp,1005            lineHeight = 16.sp,1006            modifier = Modifier.weight(1f),1007        )1008        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {1009            if (!syncStatus.syncing) syncVm.syncNow()1010        }1011    }10121013    SubLabel("PAIRED DEVICES", fg)1014    if (pairedDevices.isEmpty()) {1015        PeachEmptyOrEndState(1016            message = "No paired devices yet.",1017            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",1018            modifier = Modifier.padding(top = PeachSpacing.xs),1019        )1020    } else {1021        pairedDevices.forEach { d ->1022            RowLine(fg) {1023                Column(Modifier.weight(1f)) {1024                    if (renamingDeviceId == d.deviceId) {1025                        Box(1026                            Modifier.fillMaxWidth()1027                                .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1028                                .padding(horizontal = 10.dp, vertical = 8.dp),1029                        ) {1030                            BasicTextField(1031                                value = renameDraft,1032                                onValueChange = { renameDraft = it.take(64) },1033                                singleLine = true,1034                                textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1035                                cursorBrush = SolidColor(fg),1036                                modifier = Modifier.fillMaxWidth(),1037                            )1038                        }1039                    } else {1040                        Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1041                    }1042                    Text(1043                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1044                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1045                        modifier = Modifier.padding(top = 2.dp),1046                    )1047                }1048                if (confirmUnpair != d.deviceId) {1049                    if (renamingDeviceId == d.deviceId) {1050                        SubtleButton("cancel", fg) { renamingDeviceId = null }1051                        Spacer(Modifier.width(PeachSpacing.xs))1052                        SubtleButton("save", fg) {1053                            if (renameDraft.isNotBlank()) {1054                                syncVm.renameDevice(d.deviceId, renameDraft)1055                                renamingDeviceId = null1056                            }1057                        }1058                    } else {1059                        SubtleButton("rename", fg) {1060                            renameDraft = d.localAlias ?: d.deviceName ?: ""1061                            renamingDeviceId = d.deviceId1062                        }1063                    }1064                    if (renamingDeviceId != d.deviceId) {1065                        Spacer(Modifier.width(PeachSpacing.xs))1066                        SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1067                    }1068                }1069            }1070            // HOLE 4: revocation stops this device receiving future changes. It1071            // does NOT secure the vault against it — that device may already1072            // hold the recovery words, which are master-password-equivalent and1073            // restore the whole vault anywhere, forever, with no sync involved.1074            // Say so here rather than leaving the user falsely reassured.1075            if (confirmUnpair == d.deviceId) {1076                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1077                    Text(1078                        "Stops this device receiving future changes. It doesn't lock it " +1079                            "out of what it already has - if it's lost or stolen, also " +1080                            "change your master password and generate new recovery words.",1081                        color = fg.copy(alpha = 0.7f),1082                        fontFamily = PeachType.Body,1083                        fontSize = 11.sp,1084                        lineHeight = 16.sp,1085                    )1086                    Row(1087                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1088                        horizontalArrangement = Arrangement.End,1089                    ) {1090                        SubtleButton("cancel", fg) { confirmUnpair = null }1091                        Spacer(Modifier.width(PeachSpacing.sm))1092                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1093                    }1094                }1095            }1096        }1097    }10981099    SyncDisclosure(1100        title = "Connection",1101        summary = "Background sync, relay, and privacy choices",1102        expanded = showConnection,1103        fg = fg,1104        onToggle = { showConnection = !showConnection },1105    )1106    if (showConnection) {1107        RowLine(fg) {1108            Column(Modifier.weight(1f)) {1109                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1110                Text(1111                    when {1112                        backgroundSyncEnabled && !notificationsAvailable ->1113                            "Action needed — Android notifications are off, so background sync is not visible."1114                        backgroundSyncEnabled ->1115                            "On — keeps this phone reachable for paired devices."1116                        else ->1117                            "Off — Peach syncs only while you are using the app."1118                    },1119                    color = fg.copy(alpha = 0.7f),1120                    fontFamily = PeachType.Body,1121                    fontSize = 11.sp,1122                    lineHeight = 16.sp,1123                    modifier = Modifier.padding(top = 2.dp),1124                )1125            }1126            SubtleButton(1127                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1128                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1129            ) { showBackgroundSyncPrompt = true }1130        }1131        SyncModeRow(1132            title = "Public relay",1133            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1134            selected = syncMode == SyncMode.PUBLIC,1135            fg = fg,1136        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1137        SyncModeRow(1138            title = "Private relay",1139            detail = "Use your own peach-relay on your own domain.",1140            selected = syncMode == SyncMode.PRIVATE,1141            fg = fg,1142        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }11431144        if (syncMode == SyncMode.PRIVATE) {1145            Text(1146                "RELAY URL",1147                color = fg.copy(alpha = 0.6f),1148                fontFamily = PeachType.Mono,1149                fontSize = 8.sp,1150                letterSpacing = 0.22.em,1151                modifier = Modifier.padding(top = 8.dp),1152            )1153            Box(1154                Modifier.fillMaxWidth().padding(top = 6.dp)1155                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1156                    .padding(horizontal = 12.dp, vertical = 10.dp),1157            ) {1158                BasicTextField(1159                    value = relayUrlDraft,1160                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1161                    singleLine = true,1162                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1163                    cursorBrush = SolidColor(fg),1164                    modifier = Modifier.fillMaxWidth(),1165                )1166            }1167            val needsRelayUrl = configuredRelayUrl.isEmpty()1168            RowLine(fg) {1169                val message = relayUrlStatus1170                    ?: if (needsRelayUrl) {1171                        "Sync is paused until you enter your relay URL."1172                    } else {1173                        "Only secure wss:// relays are accepted."1174                    }1175                val warn = relayUrlStatus?.startsWith("Use") == true ||1176                    (relayUrlStatus == null && needsRelayUrl)1177                ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1178                Spacer(Modifier.width(PeachSpacing.xs))1179                SubtleButton("save", fg) {1180                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1181                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1182                        "Saved"1183                    } else {1184                        "Use a valid wss:// relay URL"1185                    }1186                }1187            }1188        }11891190        SyncModeRow(1191            title = "No sync",1192            detail = "Keep this device separate and move backups by hand.",1193            selected = syncMode == SyncMode.OFF,1194            fg = fg,1195        ) { syncVm.setSyncMode(SyncMode.OFF) }1196        Hint("Pairing authorizes a device, not a cloud account.", fg)1197    }11981199    if (pendingVouched.isNotEmpty()) {1200        SubLabel("PENDING DEVICES", fg)1201        pendingVouched.forEach { dev ->1202            RowLine(fg) {1203                Column(Modifier.weight(1f)) {1204                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1205                    Text(1206                        "Was added by another device. Allow it to sync?",1207                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1208                        modifier = Modifier.padding(top = 2.dp),1209                    )1210                }1211                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1212                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1213                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1214                }1215            }1216        }1217    }12181219    SyncDisclosure(1220        title = "Recovery & security",1221        summary = "For a lost or stolen device",1222        expanded = showRecovery,1223        fg = fg,1224        onToggle = { showRecovery = !showRecovery },1225    )1226    if (showRecovery) {1227        RowLine(fg) {1228            Column(Modifier.weight(1f)) {1229                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1230                Text(1231                    "Scan only when this populated vault previously trusted the source device.",1232                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1233                    modifier = Modifier.padding(top = 2.dp),1234                )1235            }1236            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1237        }1238        if (rotateConfirmOpen) {1239            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1240            rotateError?.let {1241                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1242            }1243            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1244                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1245                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1246                    if (rotating) return@SubtleButton1247                    rotating = true1248                    rotateError = null1249                    scope.launch {1250                        val ok = syncVm.rotateSyncIdentity()1251                        rotating = false1252                        if (ok) {1253                            rotateConfirmOpen = false1254                            onNavigate(PeachNav.RecoverySetup)1255                        } else {1256                            rotateError = "Rotation failed. Unlock your real vault and try again."1257                        }1258                    }1259                }1260            }1261        } else {1262            RowLine(fg) {1263                Column(Modifier.weight(1f)) {1264                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1265                    Text(1266                        "Disconnects every device and replaces the keys used for sync.",1267                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1268                        modifier = Modifier.padding(top = 2.dp),1269                    )1270                }1271                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1272            }1273        }1274    }12751276    if (showBackgroundSyncPrompt) {1277        BackgroundSyncDialog { enabled ->1278            showBackgroundSyncPrompt = false1279            notificationsAvailable = canShowBackgroundSyncNotification(context)1280            syncVm.setBackgroundSyncEnabled(enabled)1281        }1282    }1283}12841285private fun deviceTypeLabel(type: String?): String = when (type) {1286    "android" -> "Android"1287    "phone" -> "Phone"1288    "tablet" -> "Tablet"1289    "browser" -> "Browser"1290    else -> "Device"1291}12921293private fun syncTimeLabel(lastSyncedAt: Long?): String {1294    if (lastSyncedAt == null) return "never synced"1295    val diff = System.currentTimeMillis() - lastSyncedAt1296    if (diff < 60_000) return "just now"1297    if (diff < 3_600_000) return "${diff / 60_000}m ago"1298    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1299    return "${diff / 86_400_000}d ago"1300}13011302private fun isSyncStale(lastSyncedAt: Long?): Boolean =1303    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L13041305/* ────────────────────────────────  Pro  ─────────────────────────────────── */13061307@Composable1308private fun ProBody(fg: Color, vm: SettingsViewModel) {1309    val ctx = LocalContext.current1310    val isPro by vm.isPro.collectAsState()1311    if (isPro) {1312        ProActiveBody(fg, vm)1313        return1314    }1315    val features = listOf(1316        "Peach Sync" to "End-to-end encrypted",1317        "Peach Codex" to "Restore from paper",1318        "Passkeys" to "Zero-knowledge WebAuthn",1319        "Advanced Vault Health" to "Reuse · age · exposure",1320        "Canary entries" to "Detects vault compromise",1321        "Duress vault" to "Decoy under coercion",1322    )1323    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1324    Text(1325        "Everything, everywhere.",1326        modifier = Modifier.padding(top = 8.dp),1327        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1328    )1329    Text(1330        "One key unlocks every Pro feature — for the lifetime of the app.",1331        modifier = Modifier.padding(top = 6.dp),1332        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1333    )1334    Spacer(Modifier.height(10.dp))1335    features.forEachIndexed { i, (name, desc) ->1336        // Compact rows (6dp vs. the shared 9dp default) — this card is the1337        // tallest in the deck (6 features + price + CTA) and used to leave1338        // its button with no breathing room before the fanned tab stacked1339        // right beneath it in preview mode.1340        RowLine(fg, verticalPadding = 6.dp) {1341            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1342                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1343                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1344            }1345            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1346        }1347    }1348    Spacer(Modifier.height(12.dp))1349    Text(1350        "$39",1351        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1352    )1353    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1354    Spacer(Modifier.height(10.dp))1355    Box(1356        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1357            .background(fg.copy(alpha = 0.16f))1358            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1359            .clickable {1360                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1361            }1362            .padding(vertical = 12.dp),1363        contentAlignment = Alignment.Center,1364    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }13651366    // Trailing breathing room so the button clears the fanned tab stacked1367    // beneath this card in preview mode, where the scrollable viewport clips1368    // right around here (the license-key section below is already out of1369    // view at that height).1370    Spacer(Modifier.height(44.dp))13711372    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1373    var key by remember { mutableStateOf("") }1374    val activating by vm.activating.collectAsState()1375    val activationError by vm.activationError.collectAsState()1376    Box(1377        Modifier.fillMaxWidth().padding(top = 6.dp)1378            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1379            .padding(horizontal = 12.dp, vertical = 10.dp),1380    ) {1381        BasicTextField(1382            value = key,1383            // NOT .uppercase() — base58 (the license payload encoding) is1384            // case-sensitive; forcing the whole string to uppercase silently1385            // corrupted every real key before it ever reached signature1386            // verification, so activation always failed with a real key.1387            onValueChange = { key = it; vm.clearActivationError() },1388            singleLine = true,1389            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1390            cursorBrush = SolidColor(fg),1391            modifier = Modifier.fillMaxWidth(),1392            decorationBox = { inner ->1393                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1394                inner()1395            },1396        )1397    }1398    if (activationError != null) {1399        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1400    }1401    Spacer(Modifier.height(8.dp))1402    Box(1403        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1404            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1405            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1406            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1407            .padding(vertical = 12.dp),1408        contentAlignment = Alignment.Center,1409    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1410}14111412@Composable1413private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1414    val ctx = LocalContext.current1415    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1416    val licenseKey by vm.licenseKey.collectAsState()1417    val issuedAt by vm.licenseIssuedAt.collectAsState()1418    var showKey by remember { mutableStateOf(false) }1419    var copied by remember { mutableStateOf(false) }1420    val scope = rememberCoroutineScope()1421    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1422        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1423    }1424    Text(1425        "Peach Pro",1426        modifier = Modifier.fillMaxWidth(),1427        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1428        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1429    )1430    Text(1431        formatLicenseActiveSince(issuedAt),1432        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1433        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1434        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1435    )1436    licenseKey?.let { key ->1437        Spacer(Modifier.height(12.dp))1438        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1439        RowLine(fg) {1440            Text(1441                if (showKey) key else "************************",1442                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1443                fontFamily = PeachType.Mono,1444                fontSize = 10.sp,1445                modifier = Modifier.weight(1f),1446            )1447            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1448                SubtleButton(if (copied) "copied" else "copy", fg) {1449                    PeachClipboard.copy(ctx, "peach license", key)1450                    copied = true1451                    scope.launch {1452                        delay(1_500)1453                        copied = false1454                    }1455                }1456                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1457            }1458        }1459    }1460    Spacer(Modifier.height(12.dp))1461    features.forEachIndexed { i, name ->1462        RowLine(fg) {1463            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1464                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1465                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1466            }1467            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1468        }1469    }1470    Spacer(Modifier.height(12.dp))1471    Box(1472        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1473            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1474            .clickable { vm.deactivate() }1475            .padding(vertical = 12.dp),1476        contentAlignment = Alignment.Center,1477    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1478}14791480private fun formatLicenseActiveSince(issuedAt: Long?): String {1481    if (issuedAt == null) return "Active"1482    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1483    return "Active since $monthYear"1484}14851486/* ──────────────────────────────  Feedback  ───────────────────────────────── */14871488@OptIn(ExperimentalLayoutApi::class)1489@Composable1490private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1491    val categories = listOf("Bug report", "Feature request", "Question", "Other")1492    var category by remember { mutableStateOf(categories[0]) }1493    var message by remember { mutableStateOf("") }1494    val state by vm.feedbackState.collectAsState()14951496    // Reset transient state when the card leaves composition so reopening starts fresh.1497    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }14981499    if (state is FeedbackState.Sent) {1500        Box(1501            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1502                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1503        ) {1504            Column {1505                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1506                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))1507            }1508        }1509        return1510    }15111512    val sending = state is FeedbackState.Sending15131514    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)1515    SubLabel("TYPE", fg)1516    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1517        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1518    }1519    SubLabel("MESSAGE", fg)1520    Box(1521        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1522            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1523            .padding(10.dp),1524    ) {1525        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1526        BasicTextField(1527            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1528            enabled = !sending,1529            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1530            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1531        )1532    }1533    Text(1534        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1535        modifier = Modifier.padding(top = 6.dp),1536        color = fg.copy(alpha = 0.52f),1537        fontFamily = PeachType.Mono,1538        fontSize = 10.sp,1539        lineHeight = 14.sp,1540    )1541    Spacer(Modifier.height(8.dp))15421543    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1544        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1545        Box(1546            Modifier.clip(RoundedCornerShape(8.dp))1547                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1548                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1549                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1550                .padding(horizontal = 12.dp, vertical = 6.dp),1551        ) {1552            Text(1553                feedbackSubmitLabel(state),1554                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1555                fontFamily = PeachType.Mono, fontSize = 11.sp,1556            )1557        }1558        if (sending) {1559            CircularProgressIndicator(1560                color = LocalPeachAccent.current,1561                strokeWidth = 2.dp,1562                modifier = Modifier.size(16.dp),1563            )1564        }1565    }15661567    (state as? FeedbackState.Error)?.let { err ->1568        Text(1569            err.message,1570            modifier = Modifier.padding(top = 8.dp),1571            color = peachColors.Breach,1572            fontFamily = PeachType.Mono,1573            fontSize = 11.sp,1574            lineHeight = 15.sp,1575        )1576    }15771578    Hint("Sent anonymously · no account required", fg)1579}15801581/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */15821583@Composable1584internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1585    val configured by vm.duressConfigured.collectAsState()1586    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1587}15881589@Composable1590private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1591    Text(1592        "Duress password is active",1593        color = peachColors.HealthOk,1594        fontFamily = PeachType.Mono,1595        fontSize = 11.sp,1596        modifier = Modifier.padding(top = 6.dp),1597    )1598    Spacer(Modifier.height(8.dp))1599    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1600    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1601}16021603@Composable1604private fun DuressForm(fg: Color, vm: SettingsViewModel) {1605    var enabled by remember { mutableStateOf(false) }1606    var password by remember { mutableStateOf("") }1607    var confirm by remember { mutableStateOf("") }1608    var action by remember { mutableStateOf(DuressAction.DECOY) }1609    var error by remember { mutableStateOf<String?>(null) }1610    val scope = rememberCoroutineScope()16111612    RowLine(fg) {1613        Name("Duress password", fg)1614        Toggle(enabled, fg) { on ->1615            enabled = on1616            if (!on) { password = ""; confirm = ""; error = null }1617        }1618    }1619    Text(1620        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1621        color = fg.copy(alpha = 0.7f),1622        fontFamily = PeachType.Mono,1623        fontSize = 10.sp,1624        modifier = Modifier.padding(top = 6.dp),1625    )16261627    AnimatedVisibility(enabled) {1628        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1629            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1630            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }16311632            Text(1633                "When your duress password is entered at the lock screen, Peach will:",1634                color = fg.copy(alpha = 0.7f),1635                fontFamily = PeachType.Mono,1636                fontSize = 10.sp,1637                modifier = Modifier.padding(top = 4.dp),1638            )16391640            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1641            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }16421643            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }16441645            val canSave = password.isNotBlank() && confirm.isNotBlank()1646            DuressSaveButton("Save duress password", fg, canSave) {1647                error = when {1648                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1649                    password != confirm -> "Passwords do not match"1650                    else -> null1651                }1652                if (error == null) scope.launch {1653                    if (vm.saveDuress(password, action)) {1654                        password = ""; confirm = ""; enabled = false1655                    } else {1656                        error = "Couldn't save — the duress passphrase must differ from your real password"1657                    }1658                }1659            }1660        }1661    }1662}16631664@Composable1665private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1666    val accent = LocalPeachAccent.current1667    Row(1668        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1669        verticalAlignment = Alignment.CenterVertically,1670        horizontalArrangement = Arrangement.spacedBy(10.dp),1671    ) {1672        Box(1673            Modifier.size(16.dp).clip(CircleShape)1674                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1675            contentAlignment = Alignment.Center,1676        ) {1677            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1678        }1679        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1680    }1681}16821683@Composable1684private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1685    val accent = LocalPeachAccent.current1686    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1687    Box(1688        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1689            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1690            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1691            .let { if (enabled) it.clickable(onClick = onClick) else it }1692            .padding(vertical = 11.dp),1693        contentAlignment = Alignment.Center,1694    ) {1695        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1696    }1697}16981699@Composable1700private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1701    Box(1702        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1703            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1704            .clickable(onClick = onClick)1705            .padding(vertical = 11.dp),1706        contentAlignment = Alignment.Center,1707    ) {1708        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1709    }1710}17111712/** One choice in the three-way sync selector. */1713@Composable1714private fun SyncModeRow(1715    title: String,1716    detail: String,1717    selected: Boolean,1718    fg: Color,1719    onSelect: () -> Unit,1720) {1721    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1722        Column(Modifier.weight(1f)) {1723            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1724            Text(1725                detail,1726                color = fg.copy(alpha = 0.7f),1727                fontFamily = PeachType.Body,1728                fontSize = 11.sp,1729                lineHeight = 16.sp,1730                modifier = Modifier.padding(top = 2.dp),1731            )1732        }1733        Text(1734            if (selected) "●" else "○",1735            color = if (selected) fg else fg.copy(alpha = 0.45f),1736            fontFamily = PeachType.Mono,1737            fontSize = 14.sp,1738            modifier = Modifier.padding(start = 10.dp),1739        )1740    }1741}