SettingsCardBodies.kt

73.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.runtime.DisposableEffect30import androidx.compose.runtime.collectAsState31import androidx.compose.runtime.getValue32import com.peachpasswords.feedback.FeedbackState33import com.peachpasswords.shared.PeachClipboard34import com.peachpasswords.sync.SyncMode35import com.peachpasswords.sync.canShowBackgroundSyncNotification36import com.peachpasswords.sync.describeSyncOutcome37import com.peachpasswords.sync.privateRelayDraftOf38import com.peachpasswords.sync.syncIndicator39import com.peachpasswords.sync.syncIndicatorLabel40import com.peachpasswords.ui.components.SyncIndicatorDot41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.vault.DuressAction43import com.peachpasswords.ui.theme.peachColors44import androidx.compose.runtime.mutableStateOf45import androidx.compose.runtime.remember46import androidx.compose.runtime.rememberCoroutineScope47import androidx.compose.runtime.setValue48import androidx.compose.ui.Alignment49import androidx.compose.ui.Modifier50import androidx.compose.ui.draw.clip51import androidx.compose.ui.graphics.Color52import androidx.compose.ui.graphics.SolidColor53import androidx.compose.ui.platform.LocalContext54import androidx.compose.ui.text.TextStyle55import androidx.compose.ui.text.font.FontStyle56import androidx.compose.ui.text.input.PasswordVisualTransformation57import androidx.compose.ui.text.input.KeyboardType58import androidx.compose.ui.unit.Dp59import androidx.compose.ui.unit.dp60import androidx.compose.ui.unit.em61import androidx.compose.ui.unit.sp62import androidx.compose.material3.Text63import com.peachpasswords.BuildConfig64import com.peachpasswords.autofill.AutofillEnablement65import com.peachpasswords.ui.PeachNav66import com.peachpasswords.ui.components.PeachEmptyOrEndState67import com.peachpasswords.ui.components.PeachField68import com.peachpasswords.ui.pairing.BackgroundSyncDialog69import com.peachpasswords.ui.sync.SyncViewModel70import com.peachpasswords.ui.theme.ACCENT_COLORS71import com.peachpasswords.ui.theme.PeachSpacing72import com.peachpasswords.ui.theme.PeachType73import com.peachpasswords.ui.updates.ChangelogPhase74import com.peachpasswords.ui.updates.UpdatePhase75import com.peachpasswords.ui.updates.UpdateViewModel76import com.peachpasswords.updates.compareVersions77import androidx.hilt.navigation.compose.hiltViewModel78import com.peachpasswords.vault.VaultSettings79import com.peachpasswords.vault.VaultSnapshot80import kotlinx.coroutines.launch81import kotlinx.coroutines.delay82import java.text.SimpleDateFormat83import java.util.Date84import java.util.Locale8586private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8788/**89 * Body content for the active stack card. Everything is tinted with the card's90 * own foreground [fg] (the browser's `currentColor` convention), so each card's91 * rows inherit its palette. Rows whose backend doesn't exist yet (duress,92 * canary, license activation) render their browser visual but defer the action93 * to a later phase — noted inline where that's the case.94 */95@Composable96fun CardBody(97    id: CardId,98    fg: Color,99    vm: SettingsViewModel,100    settings: VaultSettings,101    entriesCount: Int,102    onNavigate: (PeachNav) -> Unit,103) {104    when (id) {105        CardId.Appearance -> AppearanceBody(fg, vm, settings)106        CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)107        CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)108        CardId.Sync -> SyncBody(fg, onNavigate)109        CardId.Pro -> ProBody(fg, vm)110        CardId.Feedback -> FeedbackBody(fg, vm)111    }112}113114/* ─────────────────────────  shared row vocabulary  ───────────────────────── */115116@Composable117private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {118    Text(119        text,120        modifier = Modifier.padding(top = top, bottom = 4.dp),121        color = fg.copy(alpha = 0.58f),122        fontFamily = PeachType.Mono,123        fontSize = 8.sp,124        letterSpacing = 0.22.em,125    )126}127128/**129 * Collapsible group inside a card body: the uppercase heading row toggles its130 * rows, so busy cards can fold rarely-used sections away. Groups holding131 * dangerous or rarely-used actions start collapsed.132 */133@Composable134private fun SettingsGroup(135    label: String,136    fg: Color,137    initiallyOpen: Boolean = true,138    top: Dp = 14.dp,139    content: @Composable () -> Unit,140) {141    var open by remember { mutableStateOf(initiallyOpen) }142    Row(143        Modifier.fillMaxWidth()144            .clip(RoundedCornerShape(6.dp))145            .clickable { open = !open }146            .padding(top = top, bottom = 4.dp),147        verticalAlignment = Alignment.CenterVertically,148    ) {149        Text(150            label,151            modifier = Modifier.weight(1f),152            color = fg.copy(alpha = 0.58f),153            fontFamily = PeachType.Mono,154            fontSize = 8.sp,155            letterSpacing = 0.22.em,156        )157        Text(158            if (open) "–" else "+",159            color = fg.copy(alpha = 0.58f),160            fontFamily = PeachType.Mono,161            fontSize = 10.sp,162        )163    }164    // AnimatedVisibility stacks sibling children like a Box, so multi-row group165    // content must sit in an explicit Column or the rows overlap.166    AnimatedVisibility(visible = open) {167        Column(Modifier.fillMaxWidth()) { content() }168    }169}170171@Composable172private fun RowLine(173    fg: Color,174    modifier: Modifier = Modifier,175    verticalPadding: Dp = 9.dp,176    content: @Composable RowScope.() -> Unit,177) {178    Row(179        Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),180        verticalAlignment = Alignment.CenterVertically,181        horizontalArrangement = Arrangement.SpaceBetween,182        content = content,183    )184    Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))185}186187@Composable188private fun RowScope.Name(text: String, fg: Color) {189    Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))190}191192@Composable193private fun ValueText(text: String, fg: Color, modifier: Modifier = Modifier) {194    Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier)195}196197@Composable198private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {199    Box(200        Modifier.clip(RoundedCornerShape(8.dp))201            .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))202            .background(fg.copy(alpha = 0.08f))203            .clickable(onClick = onClick)204            .padding(horizontal = 12.dp, vertical = 6.dp),205    ) {206        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1)207    }208}209210@Composable211private fun SyncDisclosure(212    title: String,213    summary: String,214    expanded: Boolean,215    fg: Color,216    onToggle: () -> Unit,217) {218    Row(219        modifier = Modifier220            .fillMaxWidth()221            .heightIn(min = 48.dp)222            .clip(RoundedCornerShape(8.dp))223            .clickable(onClick = onToggle)224            .padding(vertical = 8.dp),225        verticalAlignment = Alignment.CenterVertically,226    ) {227        Column(Modifier.weight(1f)) {228            Text(229                title.uppercase(),230                color = fg.copy(alpha = 0.58f),231                fontFamily = PeachType.Mono,232                fontSize = 8.sp,233                letterSpacing = 0.22.em,234            )235            Text(236                summary,237                color = fg.copy(alpha = 0.7f),238                fontFamily = PeachType.Body,239                fontSize = 11.sp,240                modifier = Modifier.padding(top = 3.dp),241            )242        }243        Text(244            if (expanded) "hide" else "show",245            color = fg,246            fontFamily = PeachType.Mono,247            fontSize = 10.sp,248        )249    }250}251252@Composable253private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {254    val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")255    Box(256        Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))257            .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))258            .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))259            .clickable { onToggle(!on) },260    ) {261        Box(262            Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)263                .padding(start = knob - 2.dp)264                .size(14.dp).clip(CircleShape)265                .background(if (on) fg else fg.copy(alpha = 0.58f)),266        )267    }268}269270@Composable271private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {272    Box(273        Modifier.clip(RoundedCornerShape(999.dp))274            .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))275            .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)276            .clickable(onClick = onClick)277            .padding(horizontal = 12.dp, vertical = 5.dp),278    ) {279        Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)280    }281}282283@Composable284private fun ProBadge(fg: Color) {285    Box(286        Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))287            .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),288    ) {289        Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)290    }291}292293@Composable294private fun Hint(text: String, fg: Color) {295    Text(296        text,297        modifier = Modifier.padding(top = 6.dp),298        color = fg.copy(alpha = 0.6f),299        fontFamily = PeachType.Display,300        fontStyle = FontStyle.Italic,301        fontSize = 11.5.sp,302        lineHeight = 16.sp,303    )304}305306/* ──────────────────────────────  Appearance  ─────────────────────────────── */307308@OptIn(ExperimentalLayoutApi::class)309@Composable310private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {311    SubLabel("ACCENT", fg, top = 0.dp)312    FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {313        ACCENT_COLORS.forEach { (name, color) ->314            Box(315                Modifier.size(22.dp).clip(CircleShape).background(color)316                    .border(317                        if (s.accentColor == name) 2.dp else 1.dp,318                        if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),319                        CircleShape,320                    )321                    .clickable { vm.setAccent(name) },322            )323        }324    }325    SubLabel("TEXT SIZE", fg)326    RowLine(fg) {327        Name("Text size", fg)328        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {329            Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }330            Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }331            Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }332        }333    }334    SubLabel("UI SCALE", fg)335    RowLine(fg) {336        Name("Large-screen sizing", fg)337        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {338            Pill("compact", s.uiScale == "compact", fg) { vm.setUiScale("compact") }339            Pill("auto", s.uiScale == "auto", fg) { vm.setUiScale("auto") }340            Pill("large", s.uiScale == "large", fg) { vm.setUiScale("large") }341        }342    }343    Hint("Auto keeps foldables roomy while preventing tablet controls from inflating.", fg)344    SubLabel("ICONS", fg)345    RowLine(fg) {346        Name("Site icons", fg)347        SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) { vm.setFaviconsEnabled(!s.faviconsEnabled) }348    }349    SubLabel("MOTION", fg)350    RowLine(fg) {351        Name("Reduce motion", fg)352        SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }353    }354}355356/* ──────────────────────────────  Protection  ─────────────────────────────── */357358@Composable359private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {360    val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")361    val current = s.autoLockMinutes ?: 0362    val pinEnabled by vm.pinEnabled.collectAsState()363    var pinOpen by remember { mutableStateOf(false) }364    var pinRemovalOpen by remember { mutableStateOf(false) }365    var pinMaster by remember { mutableStateOf("") }366    var pin by remember { mutableStateOf("") }367    var pinConfirm by remember { mutableStateOf("") }368    var pinRemovalMaster by remember { mutableStateOf("") }369    var pinError by remember { mutableStateOf<String?>(null) }370    val pinScope = rememberCoroutineScope()371372    SubLabel("LOCKING", fg, top = 0.dp)373    RowLine(fg) {374        Name("Allow screenshots", fg)375        Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }376    }377    RowLine(fg) {378        Name("Auto-lock after", fg)379        val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)380        SubtleButton(lockOptions[idx].second, fg) {381            val next = lockOptions[(idx + 1) % lockOptions.size].first382            vm.setAutoLockMinutes(if (next == 0) 0 else next)383        }384    }385    SubLabel("PIN UNLOCK", fg)386    RowLine(fg) {387        Name("Unlock with PIN", fg)388        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {389            ValueText(if (pinEnabled) "enabled" else "off", fg)390            SubtleButton(if (pinEnabled) "change" else "set", fg) {391                pin = ""392                pinConfirm = ""393                pinMaster = ""394                pinError = null395                pinOpen = !pinOpen396            }397        }398    }399    if (pinEnabled) {400        RowLine(fg) {401            Name("Remove PIN unlock", fg)402            SubtleButton("turn off", fg) {403                pinRemovalMaster = ""404                pinError = null405                pinRemovalOpen = !pinRemovalOpen406            }407        }408    }409    AnimatedVisibility(pinRemovalOpen) {410        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {411            PeachField(412                label = "master password",413                value = pinRemovalMaster,414                onValueChange = { pinRemovalMaster = it; pinError = null },415                keyboardType = KeyboardType.Password,416                visualTransformation = PasswordVisualTransformation(),417            )418            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }419            SubtleButton("remove PIN", fg) {420                pinScope.launch {421                    val error = vm.disablePin(pinRemovalMaster)422                    if (error == null) {423                        pinRemovalMaster = ""424                        pinRemovalOpen = false425                    } else {426                        pinError = error427                    }428                }429            }430        }431    }432    AnimatedVisibility(pinOpen) {433        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {434            PeachField(435                label = "master password",436                value = pinMaster,437                onValueChange = { pinMaster = it; pinError = null },438                keyboardType = KeyboardType.Password,439                visualTransformation = PasswordVisualTransformation(),440            )441            PeachField(442                label = "new PIN (4–12 digits)",443                value = pin,444                onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },445                keyboardType = KeyboardType.NumberPassword,446                visualTransformation = PasswordVisualTransformation(),447            )448            PeachField(449                label = "confirm PIN",450                value = pinConfirm,451                onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },452                keyboardType = KeyboardType.NumberPassword,453                visualTransformation = PasswordVisualTransformation(),454            )455            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }456            SubtleButton("save PIN", fg) {457                pinError = if (pin != pinConfirm) "PINs do not match" else null458                if (pinError == null) pinScope.launch {459                    val error = vm.savePin(pinMaster, pin)460                    if (error == null) {461                        pinMaster = ""462                        pin = ""463                        pinConfirm = ""464                        pinOpen = false465                    } else {466                        pinError = error467                    }468                }469            }470        }471    }472    SubLabel("BIOMETRICS", fg)473    val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity474    RowLine(fg) {475        Name("Unlock with biometrics", fg)476        Toggle(s.biometricsEnabled, fg) { on ->477            if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()478        }479    }480    val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)481    if (bioError != null) {482        Text(483            bioError,484            color = com.peachpasswords.ui.theme.peachColors.Breach,485            fontFamily = PeachType.Mono, fontSize = 10.sp,486            modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),487        )488    }489    SubLabel("AUTOFILL", fg)490    run {491        // Read live rather than cached: the user leaves for system settings and492        // comes back, so a remembered value would show the old answer.493        val ctx = LocalContext.current494        val enabled = AutofillEnablement.isEnabled(ctx)495        RowLine(fg) {496            Column(Modifier.weight(1f)) {497                Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)498                Text(499                    if (enabled) {500                        "Peach is your autofill service."501                    } else {502                        "Android decides which app fills passwords. Turn Peach on in system settings."503                    },504                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,505                    modifier = Modifier.padding(top = 2.dp),506                )507            }508            if (!enabled) {509                SubtleButton("enable", fg) {510                    // Straight to the autofill picker with Peach preselected;511                    // some OEM builds ship no handler, so fall back rather than512                    // dead-ending on a button that does nothing.513                    runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }514                        .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }515                }516            } else {517                ValueText("on", fg)518            }519        }520    }521522    RowLine(fg) {523        Column(Modifier.weight(1f)) {524            Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)525            Text(526                "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",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        Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }532    }533534    SubLabel("PASSKEYS", fg)535    RowLine(fg) {536        Column(Modifier.weight(1f)) {537            Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)538            Text(539                "Peach can handle passkey sign-in and creation through Android Credential Manager.",540                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,541                modifier = Modifier.padding(top = 2.dp),542            )543        }544        Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }545    }546    RowLine(fg) {547        Name("Status", fg)548        ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)549    }550    SubLabel("RECOVERY", fg)551    RowLine(fg) {552        Column(Modifier.weight(1f)) {553            Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)554            Text(555                "Keep the paper copy current before rotating sync identity or replacing devices.",556                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,557                modifier = Modifier.padding(top = 2.dp),558            )559        }560        SubtleButton("view", fg) {561            if (bioActivity != null) {562                com.peachpasswords.ui.lock.Biometrics.authenticate(563                    activity = bioActivity,564                    title = "View recovery words",565                    subtitle = "Confirm your identity to view your recovery words",566                    onSuccess = { onNavigate(PeachNav.RecoveryView) },567                )568            } else onNavigate(PeachNav.RecoveryView)569        }570    }571    SubLabel("BREACH PROTECTION", fg)572    val checkingBreaches by vm.checkingBreaches.collectAsState()573    val breachResult by vm.breachResult.collectAsState()574    RowLine(fg) {575        Name("Breach monitoring", fg)576        Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }577    }578    RowLine(fg) {579        Name("Check all passwords", fg)580        SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }581    }582    breachResult?.let { result ->583        RowLine(fg) {584            Name("Last check", fg)585            ValueText(result, fg)586        }587    }588589    SubLabel("MASTER PASSWORD", fg)590    ChangePasswordRow(fg, vm)591592    SubLabel("DURESS PASSWORD", fg)593    val duressIsPro by vm.isPro.collectAsState()594    if (!duressIsPro) {595        RowLine(fg) {596            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {597                Name("Duress password", fg); ProBadge(fg)598            }599            ValueText("off", fg)600        }601        Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)602    } else {603        DuressSection(fg, vm)604    }605}606607@Composable608private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {609    var open by remember { mutableStateOf(false) }610    var current by remember { mutableStateOf("") }611    var next by remember { mutableStateOf("") }612    var confirm by remember { mutableStateOf("") }613    var error by remember { mutableStateOf<String?>(null) }614    var done by remember { mutableStateOf(false) }615    val scope = rememberCoroutineScope()616617    RowLine(fg) {618        Name("Master password", fg)619        SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }620    }621    AnimatedVisibility(open) {622        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {623            PwField("current password", current, fg) { current = it }624            PwField("new password (min 12 chars)", next, fg) { next = it }625            PwField("confirm new password", confirm, fg) { confirm = it }626            error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }627            if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)628            SubtleButton("save new password", fg) {629                error = when {630                    current.isBlank() -> "enter your current password"631                    next.length < 12 -> "new password must be at least 12 characters"632                    next != confirm -> "new passwords do not match"633                    next == current -> "new password must differ from current"634                    else -> null635                }636                if (error == null) scope.launch {637                    if (vm.changePassword(current, next)) {638                        current = ""; next = ""; confirm = ""; open = false; done = true639                    } else error = "incorrect current password"640                }641            }642        }643    }644}645646@Composable647private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {648    PeachField(649        label = placeholder,650        value = value,651        onValueChange = onChange,652        keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,653        visualTransformation = PasswordVisualTransformation(),654    )655}656657/* ────────────────────────────────  Vault  ───────────────────────────────── */658659@Composable660private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {661    val vm: UpdateViewModel = hiltViewModel()662    val available by vm.available.collectAsState()663    val state by vm.state.collectAsState()664    val changelog by vm.changelog.collectAsState()665    var changelogOpen by remember { mutableStateOf(false) }666667    RowLine(fg) {668        Name("Version", fg)669        ValueText(BuildConfig.VERSION_NAME, fg)670    }671    RowLine(fg) {672        Column(Modifier.weight(1f)) {673            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)674            Text(675                when {676                    available != null -> "v${available?.version} is available"677                    state.phase == UpdatePhase.Checking -> "Checking…"678                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"679                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"680                    else -> "Peach checks once a day and tells you when there's a new version."681                },682                color = fg.copy(alpha = 0.7f),683                fontFamily = PeachType.Body,684                fontSize = 11.sp,685                lineHeight = 16.sp,686                modifier = Modifier.padding(top = 2.dp),687            )688        }689        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {690            SubtleButton("changelog", fg) {691                changelogOpen = !changelogOpen692                if (changelogOpen) vm.loadChangelog()693            }694            if (available != null) {695                SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }696            } else {697                SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {698                    vm.check()699                }700            }701        }702    }703    if (changelogOpen) {704        when (changelog.phase) {705            ChangelogPhase.Idle -> {}706            ChangelogPhase.Loading -> Text(707                "Loading changelog…",708                color = fg.copy(alpha = 0.68f),709                fontFamily = PeachType.Body,710                fontSize = 11.sp,711                modifier = Modifier.padding(vertical = 8.dp),712            )713            ChangelogPhase.Failed -> RowLine(fg) {714                Name("Couldn't reach the update server", fg)715                SubtleButton("retry", fg) { vm.loadChangelog() }716            }717            ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {718                changelog.entries.forEachIndexed { index, entry ->719                    Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {720                        Row(verticalAlignment = Alignment.CenterVertically) {721                            Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)722                            if (compareVersions(entry.version, vm.currentVersion) == 0) {723                                Text("  current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)724                            }725                        }726                        Text(727                            entry.notes ?: "No notes for this release.",728                            color = fg.copy(alpha = 0.72f),729                            fontFamily = PeachType.Body,730                            fontSize = 11.sp,731                            lineHeight = 16.sp,732                            modifier = Modifier.padding(top = 3.dp),733                        )734                    }735                    if (index < changelog.entries.lastIndex) {736                        Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))737                    }738                }739            }740        }741    }742}743744@Composable745private fun VaultBody(746    fg: Color,747    vm: SettingsViewModel,748    s: VaultSettings,749    entriesCount: Int,750    onNavigate: (PeachNav) -> Unit,751) {752    val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")753    val current = s.trashRetentionDays ?: 0754    val isPro by vm.isPro.collectAsState()755    val dedupCount by vm.dedupCount.collectAsState()756    val dedupRunning by vm.dedupRunning.collectAsState()757    val dedupDone by vm.dedupDone.collectAsState()758    val history by vm.vaultHistory.collectAsState()759    val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()760761    SettingsGroup("ENTRIES", fg, top = 0.dp) {762    RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }763    RowLine(fg) {764        Name("Duplicates", fg)765        val done = dedupDone766        if (done != null) {767            ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)768        } else {769            val count = dedupCount770            val label = when {771                dedupRunning -> "scanning..."772                count == null -> "scan"773                count == 0 -> "none found"774                else -> "$count found"775            }776            SubtleButton(label, fg) {777                if (count == null || count == 0) vm.scanDuplicates()778            }779        }780    }781    if ((dedupCount ?: 0) > 0 && dedupDone == null) {782        Column(783            Modifier.fillMaxWidth()784                .padding(top = 2.dp, bottom = 8.dp)785                .clip(RoundedCornerShape(8.dp))786                .background(fg.copy(alpha = 0.08f))787                .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))788                .padding(10.dp),789            verticalArrangement = Arrangement.spacedBy(8.dp),790        ) {791            val count = dedupCount ?: 0792            Text(793                "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",794                color = fg.copy(alpha = 0.82f),795                fontFamily = PeachType.Body,796                fontSize = 11.sp,797                lineHeight = 16.sp,798            )799            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {800                SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }801                SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }802            }803        }804    }805    }806807    SettingsGroup("MAINTENANCE", fg) {808    RowLine(fg) {809        Name("Trash auto-delete", fg)810        val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)811        SubtleButton(trashOptions[idx].second, fg) {812            vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)813        }814    }815    RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }816    RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }817    }818819    SettingsGroup("DATA", fg) {820    RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }821    RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }822    RowLine(fg) {823        Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }824        SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }825    }826    RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }827    }828829    SettingsGroup("UPDATES", fg) {830    UpdatesRows(fg, onNavigate)831    }832833    SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {834    var historyShowAll by remember { mutableStateOf(false) }835    if (history.isEmpty()) {836        Text(837            "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",838            color = fg.copy(alpha = 0.68f),839            fontFamily = PeachType.Body,840            fontSize = 11.sp,841            lineHeight = 16.sp,842            modifier = Modifier.padding(bottom = 6.dp),843        )844    } else {845        Text(846            "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",847            color = fg.copy(alpha = 0.68f),848            fontFamily = PeachType.Body,849            fontSize = 11.sp,850            lineHeight = 16.sp,851            modifier = Modifier.padding(bottom = 4.dp),852        )853        history.take(if (historyShowAll) history.size else 3).forEach { snap ->854            RowLine(fg) {855                Column(Modifier.weight(1f)) {856                    Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)857                    Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)858                }859                SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {860                    vm.restoreVaultSnapshot(snap.id)861                }862            }863        }864        if (history.size > 3) {865            RowLine(fg) {866                Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)867                SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }868            }869        }870        Text(871            "${history.size} of 5 snapshots",872            color = fg.copy(alpha = 0.58f),873            fontFamily = PeachType.Mono,874            fontSize = 10.sp,875            modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),876        )877    }878    }879880    SettingsGroup("ADVANCED", fg, initiallyOpen = false) {881        RowLine(fg) {882            Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)883            SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }884        }885        RowLine(fg) {886            Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)887            SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }888        }889    }890}891892private fun snapshotSubLabel(snapshot: VaultSnapshot): String {893    val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }894    return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")895}896897private fun snapshotReasonLabel(reason: String): String = when (reason) {898    "local_save" -> "saved"899    "sync_received" -> "synced"900    "import" -> "imported"901    "restore" -> "restored"902    else -> reason903}904905private fun snapshotTimeLabel(savedAt: Long): String {906    val diff = System.currentTimeMillis() - savedAt907    if (diff < 60_000) return "Just now"908    if (diff < 3_600_000) return "${diff / 60_000}m ago"909    val date = Date(savedAt)910    val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())911    val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)912    val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)913    if (today == thenDay) return "Today $time"914    val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))915    if (yesterday == thenDay) return "Yesterday $time"916    return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)917}918919/* ────────────────────────────────  Sync  ────────────────────────────────── */920921@Composable922private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {923    val context = LocalContext.current924    val syncVm: SyncViewModel = hiltViewModel()925    val pairedDevices by syncVm.pairedDevices.collectAsState()926    val pendingVouched by syncVm.pendingVouched.collectAsState()927    val syncStatus by syncVm.status.collectAsState()928    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()929    val syncMode by syncVm.syncMode.collectAsState()930    val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()931    var notificationsAvailable by remember {932        mutableStateOf(canShowBackgroundSyncNotification(context))933    }934    var showBackgroundSyncPrompt by remember { mutableStateOf(false) }935    val scope = rememberCoroutineScope()936    var confirmUnpair by remember { mutableStateOf<String?>(null) }937    var renamingDeviceId by remember { mutableStateOf<String?>(null) }938    var renameDraft by remember { mutableStateOf("") }939    var rotateConfirmOpen by remember { mutableStateOf(false) }940    var rotating by remember { mutableStateOf(false) }941    var rotateError by remember { mutableStateOf<String?>(null) }942    var relayUrlDraft by remember(configuredRelayUrl) {943        mutableStateOf(privateRelayDraftOf(configuredRelayUrl))944    }945    var relayUrlStatus by remember { mutableStateOf<String?>(null) }946    var showConnection by remember { mutableStateOf(false) }947    var showRecovery by remember { mutableStateOf(false) }948949    SubLabel("ADD A DEVICE", fg, top = 0.dp)950    RowLine(fg) {951        Column(Modifier.weight(1f)) {952            Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)953            Text(954                "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",955                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,956                modifier = Modifier.padding(top = 2.dp),957            )958        }959        // "Scan" opens the camera-driven AdoptVault flow: the phone is the960        // receiver. "Show a code" opens CodePair: the phone is the source.961        // Both are valid pairing entry points; without scan, a phone that wants962        // to join an existing browser-only vault has no path.963        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }964        Spacer(Modifier.width(PeachSpacing.xs))965        SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }966    }967968    SubLabel("SYNC STATUS", fg)969    val indicator = syncIndicator(syncStatus, pairedDevices.size)970    RowLine(fg) {971        SyncIndicatorDot(972            indicator = indicator,973            label = syncIndicatorLabel(indicator, syncStatus),974            modifier = Modifier.padding(end = PeachSpacing.sm),975            size = 8.dp,976        )977        Text(978            describeSyncOutcome(syncStatus),979            color = fg.copy(alpha = 0.7f),980            fontFamily = PeachType.Body,981            fontSize = 11.sp,982            lineHeight = 16.sp,983            modifier = Modifier.weight(1f),984        )985        SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {986            if (!syncStatus.syncing) syncVm.syncNow()987        }988    }989990    SubLabel("PAIRED DEVICES", fg)991    if (pairedDevices.isEmpty()) {992        PeachEmptyOrEndState(993            message = "No paired devices yet.",994            detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",995            modifier = Modifier.padding(top = PeachSpacing.xs),996        )997    } else {998        pairedDevices.forEach { d ->999            RowLine(fg) {1000                Column(Modifier.weight(1f)) {1001                    if (renamingDeviceId == d.deviceId) {1002                        Box(1003                            Modifier.fillMaxWidth()1004                                .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1005                                .padding(horizontal = 10.dp, vertical = 8.dp),1006                        ) {1007                            BasicTextField(1008                                value = renameDraft,1009                                onValueChange = { renameDraft = it.take(64) },1010                                singleLine = true,1011                                textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1012                                cursorBrush = SolidColor(fg),1013                                modifier = Modifier.fillMaxWidth(),1014                            )1015                        }1016                    } else {1017                        Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1018                    }1019                    Text(1020                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1021                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1022                        modifier = Modifier.padding(top = 2.dp),1023                    )1024                }1025                if (confirmUnpair != d.deviceId) {1026                    if (renamingDeviceId == d.deviceId) {1027                        SubtleButton("cancel", fg) { renamingDeviceId = null }1028                        Spacer(Modifier.width(PeachSpacing.xs))1029                        SubtleButton("save", fg) {1030                            if (renameDraft.isNotBlank()) {1031                                syncVm.renameDevice(d.deviceId, renameDraft)1032                                renamingDeviceId = null1033                            }1034                        }1035                    } else {1036                        SubtleButton("rename", fg) {1037                            renameDraft = d.localAlias ?: d.deviceName ?: ""1038                            renamingDeviceId = d.deviceId1039                        }1040                    }1041                    if (renamingDeviceId != d.deviceId) {1042                        Spacer(Modifier.width(PeachSpacing.xs))1043                        SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1044                    }1045                }1046            }1047            // HOLE 4: revocation stops this device receiving future changes. It1048            // does NOT secure the vault against it — that device may already1049            // hold the recovery words, which are master-password-equivalent and1050            // restore the whole vault anywhere, forever, with no sync involved.1051            // Say so here rather than leaving the user falsely reassured.1052            if (confirmUnpair == d.deviceId) {1053                Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1054                    Text(1055                        "Stops this device receiving future changes. It doesn't lock it " +1056                            "out of what it already has - if it's lost or stolen, also " +1057                            "change your master password and generate new recovery words.",1058                        color = fg.copy(alpha = 0.7f),1059                        fontFamily = PeachType.Body,1060                        fontSize = 11.sp,1061                        lineHeight = 16.sp,1062                    )1063                    Row(1064                        modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1065                        horizontalArrangement = Arrangement.End,1066                    ) {1067                        SubtleButton("cancel", fg) { confirmUnpair = null }1068                        Spacer(Modifier.width(PeachSpacing.sm))1069                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1070                    }1071                }1072            }1073        }1074    }10751076    SyncDisclosure(1077        title = "Connection",1078        summary = "Background sync, relay, and privacy choices",1079        expanded = showConnection,1080        fg = fg,1081        onToggle = { showConnection = !showConnection },1082    )1083    if (showConnection) {1084        RowLine(fg) {1085            Column(Modifier.weight(1f)) {1086                Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1087                Text(1088                    when {1089                        backgroundSyncEnabled && !notificationsAvailable ->1090                            "Action needed — Android notifications are off, so background sync is not visible."1091                        backgroundSyncEnabled ->1092                            "On — keeps this phone reachable for paired devices."1093                        else ->1094                            "Off — Peach syncs only while you are using the app."1095                    },1096                    color = fg.copy(alpha = 0.7f),1097                    fontFamily = PeachType.Body,1098                    fontSize = 11.sp,1099                    lineHeight = 16.sp,1100                    modifier = Modifier.padding(top = 2.dp),1101                )1102            }1103            SubtleButton(1104                if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1105                if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1106            ) { showBackgroundSyncPrompt = true }1107        }1108        SyncModeRow(1109            title = "Public relay",1110            detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1111            selected = syncMode == SyncMode.PUBLIC,1112            fg = fg,1113        ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1114        SyncModeRow(1115            title = "Private relay",1116            detail = "Use your own peach-relay on your own domain.",1117            selected = syncMode == SyncMode.PRIVATE,1118            fg = fg,1119        ) { syncVm.setSyncMode(SyncMode.PRIVATE) }11201121        if (syncMode == SyncMode.PRIVATE) {1122            Text(1123                "RELAY URL",1124                color = fg.copy(alpha = 0.6f),1125                fontFamily = PeachType.Mono,1126                fontSize = 8.sp,1127                letterSpacing = 0.22.em,1128                modifier = Modifier.padding(top = 8.dp),1129            )1130            Box(1131                Modifier.fillMaxWidth().padding(top = 6.dp)1132                    .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1133                    .padding(horizontal = 12.dp, vertical = 10.dp),1134            ) {1135                BasicTextField(1136                    value = relayUrlDraft,1137                    onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1138                    singleLine = true,1139                    textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1140                    cursorBrush = SolidColor(fg),1141                    modifier = Modifier.fillMaxWidth(),1142                )1143            }1144            val needsRelayUrl = configuredRelayUrl.isEmpty()1145            RowLine(fg) {1146                val message = relayUrlStatus1147                    ?: if (needsRelayUrl) {1148                        "Sync is paused until you enter your relay URL."1149                    } else {1150                        "Only secure wss:// relays are accepted."1151                    }1152                val warn = relayUrlStatus?.startsWith("Use") == true ||1153                    (relayUrlStatus == null && needsRelayUrl)1154                ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1155                Spacer(Modifier.width(PeachSpacing.xs))1156                SubtleButton("save", fg) {1157                    relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1158                        relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1159                        "Saved"1160                    } else {1161                        "Use a valid wss:// relay URL"1162                    }1163                }1164            }1165        }11661167        SyncModeRow(1168            title = "No sync",1169            detail = "Keep this device separate and move backups by hand.",1170            selected = syncMode == SyncMode.OFF,1171            fg = fg,1172        ) { syncVm.setSyncMode(SyncMode.OFF) }1173        Hint("Pairing authorizes a device, not a cloud account.", fg)1174    }11751176    if (pendingVouched.isNotEmpty()) {1177        SubLabel("PENDING DEVICES", fg)1178        pendingVouched.forEach { dev ->1179            RowLine(fg) {1180                Column(Modifier.weight(1f)) {1181                    Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1182                    Text(1183                        "Was added by another device. Allow it to sync?",1184                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1185                        modifier = Modifier.padding(top = 2.dp),1186                    )1187                }1188                Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1189                    SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1190                    SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1191                }1192            }1193        }1194    }11951196    SyncDisclosure(1197        title = "Recovery & security",1198        summary = "For a lost or stolen device",1199        expanded = showRecovery,1200        fg = fg,1201        onToggle = { showRecovery = !showRecovery },1202    )1203    if (showRecovery) {1204        RowLine(fg) {1205            Column(Modifier.weight(1f)) {1206                Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1207                Text(1208                    "Scan only when this populated vault previously trusted the source device.",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            SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1214        }1215        if (rotateConfirmOpen) {1216            Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1217            rotateError?.let {1218                Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1219            }1220            Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1221                SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1222                SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1223                    if (rotating) return@SubtleButton1224                    rotating = true1225                    rotateError = null1226                    scope.launch {1227                        val ok = syncVm.rotateSyncIdentity()1228                        rotating = false1229                        if (ok) {1230                            rotateConfirmOpen = false1231                            onNavigate(PeachNav.RecoverySetup)1232                        } else {1233                            rotateError = "Rotation failed. Unlock your real vault and try again."1234                        }1235                    }1236                }1237            }1238        } else {1239            RowLine(fg) {1240                Column(Modifier.weight(1f)) {1241                    Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1242                    Text(1243                        "Disconnects every device and replaces the keys used for sync.",1244                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1245                        modifier = Modifier.padding(top = 2.dp),1246                    )1247                }1248                SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1249            }1250        }1251    }12521253    if (showBackgroundSyncPrompt) {1254        BackgroundSyncDialog { enabled ->1255            showBackgroundSyncPrompt = false1256            notificationsAvailable = canShowBackgroundSyncNotification(context)1257            syncVm.setBackgroundSyncEnabled(enabled)1258        }1259    }1260}12611262private fun deviceTypeLabel(type: String?): String = when (type) {1263    "android" -> "Android"1264    "phone" -> "Phone"1265    "tablet" -> "Tablet"1266    "browser" -> "Browser"1267    else -> "Device"1268}12691270private fun syncTimeLabel(lastSyncedAt: Long?): String {1271    if (lastSyncedAt == null) return "never synced"1272    val diff = System.currentTimeMillis() - lastSyncedAt1273    if (diff < 60_000) return "just now"1274    if (diff < 3_600_000) return "${diff / 60_000}m ago"1275    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1276    return "${diff / 86_400_000}d ago"1277}12781279private fun isSyncStale(lastSyncedAt: Long?): Boolean =1280    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L12811282/* ────────────────────────────────  Pro  ─────────────────────────────────── */12831284@Composable1285private fun ProBody(fg: Color, vm: SettingsViewModel) {1286    val ctx = LocalContext.current1287    val isPro by vm.isPro.collectAsState()1288    if (isPro) {1289        ProActiveBody(fg, vm)1290        return1291    }1292    val features = listOf(1293        "Peach Sync" to "End-to-end encrypted",1294        "Peach Codex" to "Restore from paper",1295        "Passkeys" to "Zero-knowledge WebAuthn",1296        "Advanced Vault Health" to "Reuse · age · exposure",1297        "Canary entries" to "Detects vault compromise",1298        "Duress vault" to "Decoy under coercion",1299    )1300    Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1301    Text(1302        "Everything, everywhere.",1303        modifier = Modifier.padding(top = 8.dp),1304        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1305    )1306    Text(1307        "One key unlocks every Pro feature — for the lifetime of the app.",1308        modifier = Modifier.padding(top = 6.dp),1309        color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1310    )1311    Spacer(Modifier.height(10.dp))1312    features.forEachIndexed { i, (name, desc) ->1313        // Compact rows (6dp vs. the shared 9dp default) — this card is the1314        // tallest in the deck (6 features + price + CTA) and used to leave1315        // its button with no breathing room before the fanned tab stacked1316        // right beneath it in preview mode.1317        RowLine(fg, verticalPadding = 6.dp) {1318            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1319                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1320                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1321            }1322            Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1323        }1324    }1325    Spacer(Modifier.height(12.dp))1326    Text(1327        "$39",1328        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1329    )1330    Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1331    Spacer(Modifier.height(10.dp))1332    Box(1333        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1334            .background(fg.copy(alpha = 0.16f))1335            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1336            .clickable {1337                runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1338            }1339            .padding(vertical = 12.dp),1340        contentAlignment = Alignment.Center,1341    ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }13421343    // Trailing breathing room so the button clears the fanned tab stacked1344    // beneath this card in preview mode, where the scrollable viewport clips1345    // right around here (the license-key section below is already out of1346    // view at that height).1347    Spacer(Modifier.height(44.dp))13481349    Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1350    var key by remember { mutableStateOf("") }1351    val activating by vm.activating.collectAsState()1352    val activationError by vm.activationError.collectAsState()1353    Box(1354        Modifier.fillMaxWidth().padding(top = 6.dp)1355            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1356            .padding(horizontal = 12.dp, vertical = 10.dp),1357    ) {1358        BasicTextField(1359            value = key,1360            // NOT .uppercase() — base58 (the license payload encoding) is1361            // case-sensitive; forcing the whole string to uppercase silently1362            // corrupted every real key before it ever reached signature1363            // verification, so activation always failed with a real key.1364            onValueChange = { key = it; vm.clearActivationError() },1365            singleLine = true,1366            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1367            cursorBrush = SolidColor(fg),1368            modifier = Modifier.fillMaxWidth(),1369            decorationBox = { inner ->1370                if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1371                inner()1372            },1373        )1374    }1375    if (activationError != null) {1376        Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1377    }1378    Spacer(Modifier.height(8.dp))1379    Box(1380        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1381            .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1382            .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1383            .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1384            .padding(vertical = 12.dp),1385        contentAlignment = Alignment.Center,1386    ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1387}13881389@Composable1390private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1391    val ctx = LocalContext.current1392    val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1393    val licenseKey by vm.licenseKey.collectAsState()1394    val issuedAt by vm.licenseIssuedAt.collectAsState()1395    var showKey by remember { mutableStateOf(false) }1396    var copied by remember { mutableStateOf(false) }1397    val scope = rememberCoroutineScope()1398    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1399        com.peachpasswords.ui.components.PeachMark(size = 60.dp)1400    }1401    Text(1402        "Peach Pro",1403        modifier = Modifier.fillMaxWidth(),1404        color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1405        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1406    )1407    Text(1408        formatLicenseActiveSince(issuedAt),1409        modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1410        color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1411        textAlign = androidx.compose.ui.text.style.TextAlign.Center,1412    )1413    licenseKey?.let { key ->1414        Spacer(Modifier.height(12.dp))1415        Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1416        RowLine(fg) {1417            Text(1418                if (showKey) key else "************************",1419                color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1420                fontFamily = PeachType.Mono,1421                fontSize = 10.sp,1422                modifier = Modifier.weight(1f),1423            )1424            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1425                SubtleButton(if (copied) "copied" else "copy", fg) {1426                    PeachClipboard.copy(ctx, "peach license", key)1427                    copied = true1428                    scope.launch {1429                        delay(1_500)1430                        copied = false1431                    }1432                }1433                SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1434            }1435        }1436    }1437    Spacer(Modifier.height(12.dp))1438    features.forEachIndexed { i, name ->1439        RowLine(fg) {1440            Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1441                Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1442                Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1443            }1444            Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1445        }1446    }1447    Spacer(Modifier.height(12.dp))1448    Box(1449        Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1450            .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1451            .clickable { vm.deactivate() }1452            .padding(vertical = 12.dp),1453        contentAlignment = Alignment.Center,1454    ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1455}14561457private fun formatLicenseActiveSince(issuedAt: Long?): String {1458    if (issuedAt == null) return "Active"1459    val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1460    return "Active since $monthYear"1461}14621463/* ──────────────────────────────  Feedback  ───────────────────────────────── */14641465@OptIn(ExperimentalLayoutApi::class)1466@Composable1467private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1468    val categories = listOf("Bug report", "Feature request", "Question", "Other")1469    var category by remember { mutableStateOf(categories[0]) }1470    var message by remember { mutableStateOf("") }1471    val state by vm.feedbackState.collectAsState()14721473    // Reset transient state when the card leaves composition so reopening starts fresh.1474    DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }14751476    if (state is FeedbackState.Sent) {1477        Box(1478            Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1479                .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1480        ) {1481            Column {1482                Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1483                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))1484            }1485        }1486        return1487    }14881489    val sending = state is FeedbackState.Sending14901491    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)1492    SubLabel("TYPE", fg)1493    FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1494        categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1495    }1496    SubLabel("MESSAGE", fg)1497    Box(1498        Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1499            .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1500            .padding(10.dp),1501    ) {1502        if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1503        BasicTextField(1504            value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1505            enabled = !sending,1506            textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1507            cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1508        )1509    }1510    Text(1511        "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1512        modifier = Modifier.padding(top = 6.dp),1513        color = fg.copy(alpha = 0.52f),1514        fontFamily = PeachType.Mono,1515        fontSize = 10.sp,1516        lineHeight = 14.sp,1517    )1518    Spacer(Modifier.height(8.dp))15191520    Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1521        val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1522        Box(1523            Modifier.clip(RoundedCornerShape(8.dp))1524                .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1525                .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1526                .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1527                .padding(horizontal = 12.dp, vertical = 6.dp),1528        ) {1529            Text(1530                feedbackSubmitLabel(state),1531                color = fg.copy(alpha = if (canSend) 1f else 0.4f),1532                fontFamily = PeachType.Mono, fontSize = 11.sp,1533            )1534        }1535        if (sending) {1536            CircularProgressIndicator(1537                color = LocalPeachAccent.current,1538                strokeWidth = 2.dp,1539                modifier = Modifier.size(16.dp),1540            )1541        }1542    }15431544    (state as? FeedbackState.Error)?.let { err ->1545        Text(1546            err.message,1547            modifier = Modifier.padding(top = 8.dp),1548            color = peachColors.Breach,1549            fontFamily = PeachType.Mono,1550            fontSize = 11.sp,1551            lineHeight = 15.sp,1552        )1553    }15541555    Hint("Sent anonymously · no account required", fg)1556}15571558/* ──────────────────────────────  D13 Duress  ─────────────────────────────── */15591560@Composable1561internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1562    val configured by vm.duressConfigured.collectAsState()1563    if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1564}15651566@Composable1567private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1568    Text(1569        "Duress password is active",1570        color = peachColors.HealthOk,1571        fontFamily = PeachType.Mono,1572        fontSize = 11.sp,1573        modifier = Modifier.padding(top = 6.dp),1574    )1575    Spacer(Modifier.height(8.dp))1576    DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1577    Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1578}15791580@Composable1581private fun DuressForm(fg: Color, vm: SettingsViewModel) {1582    var enabled by remember { mutableStateOf(false) }1583    var password by remember { mutableStateOf("") }1584    var confirm by remember { mutableStateOf("") }1585    var action by remember { mutableStateOf(DuressAction.DECOY) }1586    var error by remember { mutableStateOf<String?>(null) }1587    val scope = rememberCoroutineScope()15881589    RowLine(fg) {1590        Name("Duress password", fg)1591        Toggle(enabled, fg) { on ->1592            enabled = on1593            if (!on) { password = ""; confirm = ""; error = null }1594        }1595    }1596    Text(1597        "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1598        color = fg.copy(alpha = 0.7f),1599        fontFamily = PeachType.Mono,1600        fontSize = 10.sp,1601        modifier = Modifier.padding(top = 6.dp),1602    )16031604    AnimatedVisibility(enabled) {1605        Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1606            PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1607            PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }16081609            Text(1610                "When your duress password is entered at the lock screen, Peach will:",1611                color = fg.copy(alpha = 0.7f),1612                fontFamily = PeachType.Mono,1613                fontSize = 10.sp,1614                modifier = Modifier.padding(top = 4.dp),1615            )16161617            DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1618            DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }16191620            error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }16211622            val canSave = password.isNotBlank() && confirm.isNotBlank()1623            DuressSaveButton("Save duress password", fg, canSave) {1624                error = when {1625                    password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1626                    password != confirm -> "Passwords do not match"1627                    else -> null1628                }1629                if (error == null) scope.launch {1630                    if (vm.saveDuress(password, action)) {1631                        password = ""; confirm = ""; enabled = false1632                    } else {1633                        error = "Couldn't save — the duress passphrase must differ from your real password"1634                    }1635                }1636            }1637        }1638    }1639}16401641@Composable1642private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1643    val accent = LocalPeachAccent.current1644    Row(1645        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1646        verticalAlignment = Alignment.CenterVertically,1647        horizontalArrangement = Arrangement.spacedBy(10.dp),1648    ) {1649        Box(1650            Modifier.size(16.dp).clip(CircleShape)1651                .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1652            contentAlignment = Alignment.Center,1653        ) {1654            if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1655        }1656        Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1657    }1658}16591660@Composable1661private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1662    val accent = LocalPeachAccent.current1663    val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1664    Box(1665        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1666            .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1667            .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1668            .let { if (enabled) it.clickable(onClick = onClick) else it }1669            .padding(vertical = 11.dp),1670        contentAlignment = Alignment.Center,1671    ) {1672        Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1673    }1674}16751676@Composable1677private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1678    Box(1679        Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1680            .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1681            .clickable(onClick = onClick)1682            .padding(vertical = 11.dp),1683        contentAlignment = Alignment.Center,1684    ) {1685        Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1686    }1687}16881689/** One choice in the three-way sync selector. */1690@Composable1691private fun SyncModeRow(1692    title: String,1693    detail: String,1694    selected: Boolean,1695    fg: Color,1696    onSelect: () -> Unit,1697) {1698    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1699        Column(Modifier.weight(1f)) {1700            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1701            Text(1702                detail,1703                color = fg.copy(alpha = 0.7f),1704                fontFamily = PeachType.Body,1705                fontSize = 11.sp,1706                lineHeight = 16.sp,1707                modifier = Modifier.padding(top = 2.dp),1708            )1709        }1710        Text(1711            if (selected) "●" else "○",1712            color = if (selected) fg else fg.copy(alpha = 0.45f),1713            fontFamily = PeachType.Mono,1714            fontSize = 14.sp,1715            modifier = Modifier.padding(start = 10.dp),1716        )1717    }1718}