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