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.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.shape.CircleShape24import androidx.compose.foundation.shape.RoundedCornerShape25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.runtime.Composable27import androidx.compose.material3.CircularProgressIndicator28import androidx.compose.runtime.DisposableEffect29import androidx.compose.runtime.collectAsState30import androidx.compose.runtime.getValue31import com.peachpasswords.feedback.FeedbackState32import com.peachpasswords.shared.PeachClipboard33import com.peachpasswords.sync.SyncStatus34import com.peachpasswords.ui.theme.LocalPeachAccent35import com.peachpasswords.vault.DuressAction36import com.peachpasswords.ui.theme.peachColors37import androidx.compose.runtime.mutableStateOf38import androidx.compose.runtime.remember39import androidx.compose.runtime.rememberCoroutineScope40import androidx.compose.runtime.setValue41import androidx.compose.ui.Alignment42import androidx.compose.ui.Modifier43import androidx.compose.ui.draw.clip44import androidx.compose.ui.graphics.Color45import androidx.compose.ui.graphics.SolidColor46import androidx.compose.ui.platform.LocalContext47import androidx.compose.ui.text.TextStyle48import androidx.compose.ui.text.font.FontStyle49import androidx.compose.ui.text.input.PasswordVisualTransformation50import androidx.compose.ui.unit.Dp51import androidx.compose.ui.unit.dp52import androidx.compose.ui.unit.em53import androidx.compose.ui.unit.sp54import androidx.compose.material3.Text55import com.peachpasswords.BuildConfig56import com.peachpasswords.autofill.AutofillEnablement57import com.peachpasswords.ui.PeachNav58import com.peachpasswords.ui.components.PeachEmptyOrEndState59import com.peachpasswords.ui.components.PeachField60import com.peachpasswords.ui.sync.SyncViewModel61import com.peachpasswords.ui.theme.ACCENT_COLORS62import com.peachpasswords.ui.theme.PeachSpacing63import com.peachpasswords.ui.theme.PeachType64import androidx.hilt.navigation.compose.hiltViewModel65import com.peachpasswords.vault.VaultSettings66import com.peachpasswords.vault.VaultSnapshot67import kotlinx.coroutines.launch68import kotlinx.coroutines.delay69import java.text.SimpleDateFormat70import java.util.Date71import java.util.Locale7273private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"747576777879808182@Composable83fun CardBody(84 id: CardId,85 fg: Color,86 vm: SettingsViewModel,87 settings: VaultSettings,88 entriesCount: Int,89 onNavigate: (PeachNav) -> Unit,90) {91 when (id) {92 CardId.Appearance -> AppearanceBody(fg, vm, settings)93 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)94 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)95 CardId.Sync -> SyncBody(fg, onNavigate)96 CardId.Shortcuts -> ShortcutsBody(fg)97 CardId.Pro -> ProBody(fg, vm)98 CardId.Feedback -> FeedbackBody(fg, vm)99 }100}101102103104@Composable105private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {106 Text(107 text,108 modifier = Modifier.padding(top = top, bottom = 4.dp),109 color = fg.copy(alpha = 0.58f),110 fontFamily = PeachType.Mono,111 fontSize = 8.sp,112 letterSpacing = 0.22.em,113 )114}115116@Composable117private fun RowLine(118 fg: Color,119 modifier: Modifier = Modifier,120 verticalPadding: Dp = 9.dp,121 content: @Composable RowScope.() -> Unit,122) {123 Row(124 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),125 verticalAlignment = Alignment.CenterVertically,126 horizontalArrangement = Arrangement.SpaceBetween,127 content = content,128 )129 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))130}131132@Composable133private fun RowScope.Name(text: String, fg: Color) {134 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))135}136137@Composable138private fun ValueText(text: String, fg: Color) {139 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)140}141142@Composable143private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {144 Box(145 Modifier.clip(RoundedCornerShape(8.dp))146 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))147 .background(fg.copy(alpha = 0.08f))148 .clickable(onClick = onClick)149 .padding(horizontal = 12.dp, vertical = 6.dp),150 ) {151 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)152 }153}154155@Composable156private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {157 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")158 Box(159 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))160 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))161 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))162 .clickable { onToggle(!on) },163 ) {164 Box(165 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)166 .padding(start = knob - 2.dp)167 .size(14.dp).clip(CircleShape)168 .background(if (on) fg else fg.copy(alpha = 0.58f)),169 )170 }171}172173@Composable174private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {175 Box(176 Modifier.clip(RoundedCornerShape(999.dp))177 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))178 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)179 .clickable(onClick = onClick)180 .padding(horizontal = 12.dp, vertical = 5.dp),181 ) {182 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)183 }184}185186@Composable187private fun ProBadge(fg: Color) {188 Box(189 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))190 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),191 ) {192 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)193 }194}195196@Composable197private fun Hint(text: String, fg: Color) {198 Text(199 text,200 modifier = Modifier.padding(top = 6.dp),201 color = fg.copy(alpha = 0.6f),202 fontFamily = PeachType.Display,203 fontStyle = FontStyle.Italic,204 fontSize = 11.5.sp,205 lineHeight = 16.sp,206 )207}208209210211@OptIn(ExperimentalLayoutApi::class)212@Composable213private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {214 SubLabel("THEME", fg, top = 0.dp)215 RowLine(fg) {216 Name("Theme", fg)217 val (label, next) = when (s.theme) {218 "dark" -> "Switch to light" to "light"219 else -> "Switch to dark" to "dark"220 }221 SubtleButton(label, fg) { vm.setTheme(next) }222 }223 SubLabel("ACCENT", fg)224 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {225 ACCENT_COLORS.forEach { (name, color) ->226 Box(227 Modifier.size(22.dp).clip(CircleShape).background(color)228 .border(229 if (s.accentColor == name) 2.dp else 1.dp,230 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),231 CircleShape,232 )233 .clickable { vm.setAccent(name) },234 )235 }236 }237 SubLabel("TEXT SIZE", fg)238 RowLine(fg) {239 Name("Size", fg)240 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {241 Pill("Default", s.textSize == "medium", fg) { vm.setTextSize("medium") }242 Pill("Large", s.textSize == "large", fg) { vm.setTextSize("large") }243 }244 }245 SubLabel("MOTION", fg)246 RowLine(fg) {247 Name("Reduce motion", fg)248 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }249 }250}251252253254@Composable255private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {256 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")257 val current = s.autoLockMinutes ?: 0258259 SubLabel("LOCKING", fg, top = 0.dp)260 RowLine(fg) {261 Name("Auto-lock after", fg)262 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)263 SubtleButton(lockOptions[idx].second, fg) {264 val next = lockOptions[(idx + 1) % lockOptions.size].first265 vm.setAutoLockMinutes(if (next == 0) 0 else next)266 }267 }268 SubLabel("BIOMETRICS", fg)269 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity270 RowLine(fg) {271 Name("Unlock with biometrics", fg)272 Toggle(s.biometricsEnabled, fg) { on ->273 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()274 }275 }276 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)277 if (bioError != null) {278 Text(279 bioError,280 color = com.peachpasswords.ui.theme.peachColors.Breach,281 fontFamily = PeachType.Mono, fontSize = 10.sp,282 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),283 )284 }285 SubLabel("AUTOFILL", fg)286 run {287 288 289 val ctx = LocalContext.current290 val enabled = AutofillEnablement.isEnabled(ctx)291 RowLine(fg) {292 Column(Modifier.weight(1f)) {293 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)294 Text(295 if (enabled) {296 "Peach is your autofill service."297 } else {298 "Android decides which app fills passwords. Turn Peach on in system settings."299 },300 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,301 modifier = Modifier.padding(top = 2.dp),302 )303 }304 if (!enabled) {305 SubtleButton("enable", fg) {306 307 308 309 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }310 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }311 }312 } else {313 ValueText("on", fg)314 }315 }316 }317318 SubLabel("PASSKEYS", fg)319 RowLine(fg) {320 Column(Modifier.weight(1f)) {321 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)322 Text(323 "Peach can handle passkey sign-in and creation through Android Credential Manager.",324 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,325 modifier = Modifier.padding(top = 2.dp),326 )327 }328 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }329 }330 RowLine(fg) {331 Name("Status", fg)332 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)333 }334 SubLabel("RECOVERY", fg)335 RowLine(fg) {336 Column(Modifier.weight(1f)) {337 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)338 Text(339 "Keep the paper copy current before rotating sync identity or replacing devices.",340 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,341 modifier = Modifier.padding(top = 2.dp),342 )343 }344 SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }345 }346 SubLabel("BREACH PROTECTION", fg)347 val checkingBreaches by vm.checkingBreaches.collectAsState()348 val breachResult by vm.breachResult.collectAsState()349 RowLine(fg) {350 Name("Breach monitoring", fg)351 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }352 }353 RowLine(fg) {354 Name("Check all passwords", fg)355 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }356 }357 breachResult?.let { result ->358 RowLine(fg) {359 Name("Last check", fg)360 ValueText(result, fg)361 }362 }363364 SubLabel("MASTER PASSWORD", fg)365 ChangePasswordRow(fg, vm)366367 SubLabel("DURESS PASSWORD", fg)368 val duressIsPro by vm.isPro.collectAsState()369 if (!duressIsPro) {370 RowLine(fg) {371 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {372 Name("Duress password", fg); ProBadge(fg)373 }374 ValueText("off", fg)375 }376 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)377 } else {378 DuressSection(fg, vm)379 }380}381382@Composable383private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {384 var open by remember { mutableStateOf(false) }385 var current by remember { mutableStateOf("") }386 var next by remember { mutableStateOf("") }387 var confirm by remember { mutableStateOf("") }388 var error by remember { mutableStateOf<String?>(null) }389 var done by remember { mutableStateOf(false) }390 val scope = rememberCoroutineScope()391392 RowLine(fg) {393 Name("Master password", fg)394 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }395 }396 AnimatedVisibility(open) {397 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {398 PwField("current password", current, fg) { current = it }399 PwField("new password (min 12 chars)", next, fg) { next = it }400 PwField("confirm new password", confirm, fg) { confirm = it }401 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }402 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)403 SubtleButton("save new password", fg) {404 error = when {405 current.isBlank() -> "enter your current password"406 next.length < 12 -> "new password must be at least 12 characters"407 next != confirm -> "new passwords do not match"408 next == current -> "new password must differ from current"409 else -> null410 }411 if (error == null) scope.launch {412 if (vm.changePassword(current, next)) {413 current = ""; next = ""; confirm = ""; open = false; done = true414 } else error = "incorrect current password"415 }416 }417 }418 }419}420421@Composable422private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {423 PeachField(424 label = placeholder,425 value = value,426 onValueChange = onChange,427 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,428 visualTransformation = PasswordVisualTransformation(),429 )430}431432433434@Composable435private fun VaultBody(436 fg: Color,437 vm: SettingsViewModel,438 s: VaultSettings,439 entriesCount: Int,440 onNavigate: (PeachNav) -> Unit,441) {442 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")443 val current = s.trashRetentionDays ?: 0444 val isPro by vm.isPro.collectAsState()445 val dedupCount by vm.dedupCount.collectAsState()446 val dedupRunning by vm.dedupRunning.collectAsState()447 val dedupDone by vm.dedupDone.collectAsState()448 val history by vm.vaultHistory.collectAsState()449 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()450451 SubLabel("ENTRIES", fg, top = 0.dp)452 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }453 RowLine(fg) {454 Name("Duplicates", fg)455 val done = dedupDone456 if (done != null) {457 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)458 } else {459 val count = dedupCount460 val label = when {461 dedupRunning -> "scanning..."462 count == null -> "scan"463 count == 0 -> "none found"464 else -> "$count found"465 }466 SubtleButton(label, fg) {467 if (count == null || count == 0) vm.scanDuplicates()468 }469 }470 }471 if ((dedupCount ?: 0) > 0 && dedupDone == null) {472 Column(473 Modifier.fillMaxWidth()474 .padding(top = 2.dp, bottom = 8.dp)475 .clip(RoundedCornerShape(8.dp))476 .background(fg.copy(alpha = 0.08f))477 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))478 .padding(10.dp),479 verticalArrangement = Arrangement.spacedBy(8.dp),480 ) {481 val count = dedupCount ?: 0482 Text(483 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",484 color = fg.copy(alpha = 0.82f),485 fontFamily = PeachType.Body,486 fontSize = 11.sp,487 lineHeight = 16.sp,488 )489 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {490 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }491 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }492 }493 }494 }495496 SubLabel("MAINTENANCE", fg)497 RowLine(fg) {498 Name("Trash auto-delete", fg)499 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)500 SubtleButton(trashOptions[idx].second, fg) {501 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)502 }503 }504 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }505 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }506507 SubLabel("DATA", fg)508 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }509 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }510 RowLine(fg) {511 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }512 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }513 }514 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }515516 var historyShowAll by remember { mutableStateOf(false) }517 SubLabel("VAULT HISTORY", fg)518 if (history.isEmpty()) {519 Text(520 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",521 color = fg.copy(alpha = 0.68f),522 fontFamily = PeachType.Body,523 fontSize = 11.sp,524 lineHeight = 16.sp,525 modifier = Modifier.padding(bottom = 6.dp),526 )527 } else {528 Text(529 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",530 color = fg.copy(alpha = 0.68f),531 fontFamily = PeachType.Body,532 fontSize = 11.sp,533 lineHeight = 16.sp,534 modifier = Modifier.padding(bottom = 4.dp),535 )536 history.take(if (historyShowAll) history.size else 3).forEach { snap ->537 RowLine(fg) {538 Column(Modifier.weight(1f)) {539 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)540 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)541 }542 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {543 vm.restoreVaultSnapshot(snap.id)544 }545 }546 }547 if (history.size > 3) {548 RowLine(fg) {549 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)550 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }551 }552 }553 Text(554 "${history.size} of 5 snapshots",555 color = fg.copy(alpha = 0.58f),556 fontFamily = PeachType.Mono,557 fontSize = 10.sp,558 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),559 )560 }561562 var advancedOpen by remember { mutableStateOf(false) }563 SubLabel("ADVANCED", fg)564 RowLine(fg) {565 Name("Advanced", fg)566 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }567 }568 if (advancedOpen) {569 RowLine(fg) {570 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)571 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }572 }573 RowLine(fg) {574 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)575 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }576 }577 }578}579580private fun snapshotSubLabel(snapshot: VaultSnapshot): String {581 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }582 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")583}584585private fun snapshotReasonLabel(reason: String): String = when (reason) {586 "local_save" -> "saved"587 "sync_received" -> "synced"588 "import" -> "imported"589 "restore" -> "restored"590 else -> reason591}592593private fun snapshotTimeLabel(savedAt: Long): String {594 val diff = System.currentTimeMillis() - savedAt595 if (diff < 60_000) return "Just now"596 if (diff < 3_600_000) return "${diff / 60_000}m ago"597 val date = Date(savedAt)598 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())599 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)600 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)601 if (today == thenDay) return "Today $time"602 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))603 if (yesterday == thenDay) return "Yesterday $time"604 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)605}606607608609@Composable610private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {611 val syncVm: SyncViewModel = hiltViewModel()612 val pairedDevices by syncVm.pairedDevices.collectAsState()613 val pendingVouched by syncVm.pendingVouched.collectAsState()614 val syncStatus by syncVm.status.collectAsState()615 val scope = rememberCoroutineScope()616 var confirmUnpair by remember { mutableStateOf<String?>(null) }617 var rotateConfirmOpen by remember { mutableStateOf(false) }618 var rotating by remember { mutableStateOf(false) }619 var rotateError by remember { mutableStateOf<String?>(null) }620621 SubLabel("PAIRING", fg, top = 0.dp)622 RowLine(fg) {623 Column(Modifier.weight(1f)) {624 Row(verticalAlignment = Alignment.CenterVertically) {625 Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)626 }627 Text(628 "Pair devices with a code over your local network and keep encrypted vault blobs in step without a central account.",629 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,630 modifier = Modifier.padding(top = 2.dp),631 )632 }633 }634 635 636 RowLine(fg, Modifier.clickable { onNavigate(PeachNav.CodePair) }) {637 Name("Sync hub", fg)638 Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)639 }640 RowLine(fg) {641 Column(Modifier.weight(1f)) {642 Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)643 Text(644 "This phone shows a code; type it into Peach in your browser.",645 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,646 modifier = Modifier.padding(top = 2.dp),647 )648 }649 SubtleButton("pair", fg) { onNavigate(PeachNav.CodePair) }650 }651652 SubLabel("PAIRED DEVICES", fg)653 if (pairedDevices.isEmpty()) {654 PeachEmptyOrEndState(655 message = "No paired devices yet.",656 detail = "Pair a nearby phone, browser, or desktop helper when you're ready to sync.",657 modifier = Modifier.padding(top = PeachSpacing.xs),658 )659 } else {660 pairedDevices.forEach { d ->661 RowLine(fg) {662 Column(Modifier.weight(1f)) {663 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)664 Text(665 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",666 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,667 modifier = Modifier.padding(top = 2.dp),668 )669 }670 if (confirmUnpair == d.deviceId) {671 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {672 SubtleButton("cancel", fg) { confirmUnpair = null }673 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }674 }675 } else {676 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }677 }678 }679 }680 RowLine(fg) {681 ValueText(syncFlashLabel(syncStatus), fg)682 SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {683 if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()684 }685 }686 }687688 if (pendingVouched.isNotEmpty()) {689 SubLabel("PENDING DEVICES", fg)690 pendingVouched.forEach { dev ->691 RowLine(fg) {692 Column(Modifier.weight(1f)) {693 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)694 Text(695 "Was added by another of your devices. Allow it to sync with this vault?",696 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,697 modifier = Modifier.padding(top = 2.dp),698 )699 }700 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {701 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }702 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }703 }704 }705 }706 }707708 SubLabel("RECOVERY", fg)709 if (rotateConfirmOpen) {710 Hint("This will disconnect all paired devices and your 12 recovery words will change. You'll be shown the new words next.", peachColors.Breach)711 rotateError?.let {712 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))713 }714 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {715 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }716 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {717 if (rotating) return@SubtleButton718 rotating = true719 rotateError = null720 scope.launch {721 val ok = syncVm.rotateSyncIdentity()722 rotating = false723 if (ok) {724 rotateConfirmOpen = false725 onNavigate(PeachNav.RecoverySetup)726 } else {727 rotateError = "Rotation failed. Unlock your real vault and try again."728 }729 }730 }731 }732 } else {733 RowLine(fg) {734 Column(Modifier.weight(1f)) {735 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)736 Text(737 "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.",738 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,739 modifier = Modifier.padding(top = 2.dp),740 )741 }742 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }743 }744 }745746 Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)747}748749private fun deviceTypeLabel(type: String?): String = when (type) {750 "android" -> "Android"751 "phone" -> "Phone"752 "tablet" -> "Tablet"753 "desktop" -> "Desktop"754 "browser" -> "Browser"755 else -> "Device"756}757758private fun syncTimeLabel(lastSyncedAt: Long?): String {759 if (lastSyncedAt == null) return "never synced"760 val diff = System.currentTimeMillis() - lastSyncedAt761 if (diff < 60_000) return "just now"762 if (diff < 3_600_000) return "${diff / 60_000}m ago"763 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"764 return "${diff / 86_400_000}d ago"765}766767private fun isSyncStale(lastSyncedAt: Long?): Boolean =768 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L769770private fun syncFlashLabel(status: SyncStatus): String = when (status) {771 SyncStatus.Idle -> ""772 SyncStatus.Syncing -> "syncing..."773 SyncStatus.Offline -> "offline"774 is SyncStatus.Error -> status.message775 is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}"776}777778779780@Composable781private fun ShortcutsBody(fg: Color) {782 val bindings = listOf(783 "Copy password" to "⌘C", "Copy username" to "⌘⇧C", "Open entry" to "↵",784 "Edit entry" to "E", "New entry" to "⌘N", "Search" to "/",785 )786 SubLabel("KEYBINDINGS", fg, top = 0.dp)787 Hint("Shortcuts apply when a hardware keyboard is attached. Defaults shown below.", fg)788 Spacer(Modifier.height(8.dp))789 bindings.forEach { (label, key) ->790 RowLine(fg) {791 Name(label, fg)792 Box(793 Modifier.clip(RoundedCornerShape(4.dp)).background(fg.copy(alpha = 0.06f))794 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(4.dp))795 .padding(horizontal = 7.dp, vertical = 2.dp),796 ) { Text(key, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp) }797 }798 }799}800801802803@Composable804private fun ProBody(fg: Color, vm: SettingsViewModel) {805 val ctx = LocalContext.current806 val isPro by vm.isPro.collectAsState()807 if (isPro) {808 ProActiveBody(fg, vm)809 return810 }811 val features = listOf(812 "Peach Sync" to "Encrypted · peer-to-peer",813 "Peach Codex" to "Restore from paper",814 "Passkeys" to "Zero-knowledge WebAuthn",815 "Advanced Vault Health" to "Reuse · age · exposure",816 "Canary entries" to "Detects vault compromise",817 "Duress vault" to "Decoy under coercion",818 )819 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)820 Text(821 "Everything, everywhere.",822 modifier = Modifier.padding(top = 8.dp),823 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,824 )825 Text(826 "One key unlocks every Pro feature — for the lifetime of the app.",827 modifier = Modifier.padding(top = 6.dp),828 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,829 )830 Spacer(Modifier.height(10.dp))831 features.forEachIndexed { i, (name, desc) ->832 833 834 835 836 RowLine(fg, verticalPadding = 6.dp) {837 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {838 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))839 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)840 }841 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)842 }843 }844 Spacer(Modifier.height(12.dp))845 Text(846 "$39",847 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,848 )849 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)850 Spacer(Modifier.height(10.dp))851 Box(852 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))853 .background(fg.copy(alpha = 0.16f))854 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))855 .clickable {856 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }857 }858 .padding(vertical = 12.dp),859 contentAlignment = Alignment.Center,860 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }861862 863 864 865 866 Spacer(Modifier.height(44.dp))867868 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)869 var key by remember { mutableStateOf("") }870 val activating by vm.activating.collectAsState()871 val activationError by vm.activationError.collectAsState()872 Box(873 Modifier.fillMaxWidth().padding(top = 6.dp)874 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))875 .padding(horizontal = 12.dp, vertical = 10.dp),876 ) {877 BasicTextField(878 value = key,879 880 881 882 883 onValueChange = { key = it; vm.clearActivationError() },884 singleLine = true,885 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),886 cursorBrush = SolidColor(fg),887 modifier = Modifier.fillMaxWidth(),888 decorationBox = { inner ->889 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)890 inner()891 },892 )893 }894 if (activationError != null) {895 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))896 }897 Spacer(Modifier.height(8.dp))898 Box(899 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))900 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))901 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))902 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }903 .padding(vertical = 12.dp),904 contentAlignment = Alignment.Center,905 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }906}907908@Composable909private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {910 val ctx = LocalContext.current911 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")912 val licenseKey by vm.licenseKey.collectAsState()913 val issuedAt by vm.licenseIssuedAt.collectAsState()914 var showKey by remember { mutableStateOf(false) }915 var copied by remember { mutableStateOf(false) }916 val scope = rememberCoroutineScope()917 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {918 com.peachpasswords.ui.components.PeachMark(size = 60.dp)919 }920 Text(921 "Peach Pro",922 modifier = Modifier.fillMaxWidth(),923 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,924 textAlign = androidx.compose.ui.text.style.TextAlign.Center,925 )926 Text(927 formatLicenseActiveSince(issuedAt),928 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),929 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,930 textAlign = androidx.compose.ui.text.style.TextAlign.Center,931 )932 licenseKey?.let { key ->933 Spacer(Modifier.height(12.dp))934 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)935 RowLine(fg) {936 Text(937 if (showKey) key else "************************",938 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),939 fontFamily = PeachType.Mono,940 fontSize = 10.sp,941 modifier = Modifier.weight(1f),942 )943 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {944 SubtleButton(if (copied) "copied" else "copy", fg) {945 PeachClipboard.copy(ctx, "peach license", key)946 copied = true947 scope.launch {948 delay(1_500)949 copied = false950 }951 }952 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }953 }954 }955 }956 Spacer(Modifier.height(12.dp))957 features.forEachIndexed { i, name ->958 RowLine(fg) {959 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {960 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))961 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)962 }963 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)964 }965 }966 Spacer(Modifier.height(12.dp))967 Box(968 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))969 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))970 .clickable { vm.deactivate() }971 .padding(vertical = 12.dp),972 contentAlignment = Alignment.Center,973 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }974}975976private fun formatLicenseActiveSince(issuedAt: Long?): String {977 if (issuedAt == null) return "Active"978 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))979 return "Active since $monthYear"980}981982983984@OptIn(ExperimentalLayoutApi::class)985@Composable986private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {987 val categories = listOf("Bug report", "Feature request", "Question", "Other")988 var category by remember { mutableStateOf(categories[0]) }989 var message by remember { mutableStateOf("") }990 val state by vm.feedbackState.collectAsState()991992 993 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }994995 if (state is FeedbackState.Sent) {996 Box(997 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))998 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),999 ) {1000 Column {1001 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1002 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))1003 }1004 }1005 return1006 }10071008 val sending = state is FeedbackState.Sending10091010 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)1011 SubLabel("TYPE", fg)1012 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1013 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1014 }1015 SubLabel("MESSAGE", fg)1016 Box(1017 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1018 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1019 .padding(10.dp),1020 ) {1021 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1022 BasicTextField(1023 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1024 enabled = !sending,1025 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1026 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1027 )1028 }1029 Text(1030 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1031 modifier = Modifier.padding(top = 6.dp),1032 color = fg.copy(alpha = 0.52f),1033 fontFamily = PeachType.Mono,1034 fontSize = 10.sp,1035 lineHeight = 14.sp,1036 )1037 Spacer(Modifier.height(8.dp))10381039 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1040 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1041 Box(1042 Modifier.clip(RoundedCornerShape(8.dp))1043 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1044 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1045 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1046 .padding(horizontal = 12.dp, vertical = 6.dp),1047 ) {1048 Text(1049 feedbackSubmitLabel(state),1050 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1051 fontFamily = PeachType.Mono, fontSize = 11.sp,1052 )1053 }1054 if (sending) {1055 CircularProgressIndicator(1056 color = LocalPeachAccent.current,1057 strokeWidth = 2.dp,1058 modifier = Modifier.size(16.dp),1059 )1060 }1061 }10621063 (state as? FeedbackState.Error)?.let { err ->1064 Text(1065 err.message,1066 modifier = Modifier.padding(top = 8.dp),1067 color = peachColors.Breach,1068 fontFamily = PeachType.Mono,1069 fontSize = 11.sp,1070 lineHeight = 15.sp,1071 )1072 }10731074 Hint("Sent anonymously · no account required", fg)1075}1076107710781079@Composable1080internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1081 val configured by vm.duressConfigured.collectAsState()1082 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1083}10841085@Composable1086private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1087 Text(1088 "Duress password is active",1089 color = peachColors.HealthOk,1090 fontFamily = PeachType.Mono,1091 fontSize = 11.sp,1092 modifier = Modifier.padding(top = 6.dp),1093 )1094 Spacer(Modifier.height(8.dp))1095 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1096 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1097}10981099@Composable1100private fun DuressForm(fg: Color, vm: SettingsViewModel) {1101 var enabled by remember { mutableStateOf(false) }1102 var password by remember { mutableStateOf("") }1103 var confirm by remember { mutableStateOf("") }1104 var action by remember { mutableStateOf(DuressAction.DECOY) }1105 var error by remember { mutableStateOf<String?>(null) }1106 val scope = rememberCoroutineScope()11071108 RowLine(fg) {1109 Name("Duress password", fg)1110 Toggle(enabled, fg) { on ->1111 enabled = on1112 if (!on) { password = ""; confirm = ""; error = null }1113 }1114 }1115 Text(1116 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1117 color = fg.copy(alpha = 0.7f),1118 fontFamily = PeachType.Mono,1119 fontSize = 10.sp,1120 modifier = Modifier.padding(top = 6.dp),1121 )11221123 AnimatedVisibility(enabled) {1124 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1125 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1126 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }11271128 Text(1129 "When your duress password is entered at the lock screen, Peach will:",1130 color = fg.copy(alpha = 0.7f),1131 fontFamily = PeachType.Mono,1132 fontSize = 10.sp,1133 modifier = Modifier.padding(top = 4.dp),1134 )11351136 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1137 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }11381139 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }11401141 val canSave = password.isNotBlank() && confirm.isNotBlank()1142 DuressSaveButton("Save duress password", fg, canSave) {1143 error = when {1144 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1145 password != confirm -> "Passwords do not match"1146 else -> null1147 }1148 if (error == null) scope.launch {1149 if (vm.saveDuress(password, action)) {1150 password = ""; confirm = ""; enabled = false1151 } else {1152 error = "Couldn't save — the duress passphrase must differ from your real password"1153 }1154 }1155 }1156 }1157 }1158}11591160@Composable1161private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1162 val accent = LocalPeachAccent.current1163 Row(1164 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1165 verticalAlignment = Alignment.CenterVertically,1166 horizontalArrangement = Arrangement.spacedBy(10.dp),1167 ) {1168 Box(1169 Modifier.size(16.dp).clip(CircleShape)1170 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1171 contentAlignment = Alignment.Center,1172 ) {1173 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1174 }1175 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1176 }1177}11781179@Composable1180private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1181 val accent = LocalPeachAccent.current1182 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1183 Box(1184 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1185 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1186 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1187 .let { if (enabled) it.clickable(onClick = onClick) else it }1188 .padding(vertical = 11.dp),1189 contentAlignment = Alignment.Center,1190 ) {1191 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1192 }1193}11941195@Composable1196private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1197 Box(1198 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1199 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1200 .clickable(onClick = onClick)1201 .padding(vertical = 11.dp),1202 contentAlignment = Alignment.Center,1203 ) {1204 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1205 }1206}