1package com.peachpasswords.ui.settings23import android.content.Intent4import android.net.Uri5import androidx.compose.animation.AnimatedVisibility6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.FlowRow14import androidx.compose.foundation.layout.ExperimentalLayoutApi15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.RowScope17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.heightIn21import androidx.compose.foundation.layout.padding22import androidx.compose.foundation.layout.size23import androidx.compose.foundation.layout.width24import androidx.compose.foundation.shape.CircleShape25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.runtime.Composable28import androidx.compose.material3.CircularProgressIndicator29import androidx.compose.runtime.DisposableEffect30import androidx.compose.runtime.collectAsState31import androidx.compose.runtime.getValue32import com.peachpasswords.feedback.FeedbackState33import com.peachpasswords.shared.PeachClipboard34import com.peachpasswords.sync.SyncMode35import com.peachpasswords.sync.canShowBackgroundSyncNotification36import com.peachpasswords.sync.describeSyncOutcome37import com.peachpasswords.sync.privateRelayDraftOf38import com.peachpasswords.sync.syncIndicator39import com.peachpasswords.sync.syncIndicatorLabel40import com.peachpasswords.ui.components.SyncIndicatorDot41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.vault.DuressAction43import com.peachpasswords.ui.theme.peachColors44import androidx.compose.runtime.mutableStateOf45import androidx.compose.runtime.remember46import androidx.compose.runtime.rememberCoroutineScope47import androidx.compose.runtime.setValue48import androidx.compose.ui.Alignment49import androidx.compose.ui.Modifier50import androidx.compose.ui.draw.clip51import androidx.compose.ui.graphics.Color52import androidx.compose.ui.graphics.SolidColor53import androidx.compose.ui.platform.LocalContext54import androidx.compose.ui.text.TextStyle55import androidx.compose.ui.text.font.FontStyle56import androidx.compose.ui.text.input.PasswordVisualTransformation57import androidx.compose.ui.unit.Dp58import androidx.compose.ui.unit.dp59import androidx.compose.ui.unit.em60import androidx.compose.ui.unit.sp61import androidx.compose.material3.Text62import com.peachpasswords.BuildConfig63import com.peachpasswords.autofill.AutofillEnablement64import com.peachpasswords.ui.PeachNav65import com.peachpasswords.ui.components.PeachEmptyOrEndState66import com.peachpasswords.ui.components.PeachField67import com.peachpasswords.ui.pairing.BackgroundSyncDialog68import com.peachpasswords.ui.sync.SyncViewModel69import com.peachpasswords.ui.theme.ACCENT_COLORS70import com.peachpasswords.ui.theme.PeachSpacing71import com.peachpasswords.ui.theme.PeachType72import com.peachpasswords.ui.updates.UpdatePhase73import com.peachpasswords.ui.updates.UpdateViewModel74import androidx.hilt.navigation.compose.hiltViewModel75import com.peachpasswords.vault.VaultSettings76import com.peachpasswords.vault.VaultSnapshot77import kotlinx.coroutines.launch78import kotlinx.coroutines.delay79import java.text.SimpleDateFormat80import java.util.Date81import java.util.Locale8283private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"848586878889909192@Composable93fun CardBody(94 id: CardId,95 fg: Color,96 vm: SettingsViewModel,97 settings: VaultSettings,98 entriesCount: Int,99 onNavigate: (PeachNav) -> Unit,100) {101 when (id) {102 CardId.Appearance -> AppearanceBody(fg, vm, settings)103 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)104 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)105 CardId.Sync -> SyncBody(fg, onNavigate)106 CardId.Pro -> ProBody(fg, vm)107 CardId.Feedback -> FeedbackBody(fg, vm)108 }109}110111112113@Composable114private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {115 Text(116 text,117 modifier = Modifier.padding(top = top, bottom = 4.dp),118 color = fg.copy(alpha = 0.58f),119 fontFamily = PeachType.Mono,120 fontSize = 8.sp,121 letterSpacing = 0.22.em,122 )123}124125126127128129130@Composable131private fun SettingsGroup(132 label: String,133 fg: Color,134 initiallyOpen: Boolean = true,135 top: Dp = 14.dp,136 content: @Composable () -> Unit,137) {138 var open by remember { mutableStateOf(initiallyOpen) }139 Row(140 Modifier.fillMaxWidth()141 .clip(RoundedCornerShape(6.dp))142 .clickable { open = !open }143 .padding(top = top, bottom = 4.dp),144 verticalAlignment = Alignment.CenterVertically,145 ) {146 Text(147 label,148 modifier = Modifier.weight(1f),149 color = fg.copy(alpha = 0.58f),150 fontFamily = PeachType.Mono,151 fontSize = 8.sp,152 letterSpacing = 0.22.em,153 )154 Text(155 if (open) "–" else "+",156 color = fg.copy(alpha = 0.58f),157 fontFamily = PeachType.Mono,158 fontSize = 10.sp,159 )160 }161 AnimatedVisibility(visible = open) { content() }162}163164@Composable165private fun RowLine(166 fg: Color,167 modifier: Modifier = Modifier,168 verticalPadding: Dp = 9.dp,169 content: @Composable RowScope.() -> Unit,170) {171 Row(172 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),173 verticalAlignment = Alignment.CenterVertically,174 horizontalArrangement = Arrangement.SpaceBetween,175 content = content,176 )177 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))178}179180@Composable181private fun RowScope.Name(text: String, fg: Color) {182 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))183}184185@Composable186private fun ValueText(text: String, fg: Color) {187 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)188}189190@Composable191private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {192 Box(193 Modifier.clip(RoundedCornerShape(8.dp))194 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))195 .background(fg.copy(alpha = 0.08f))196 .clickable(onClick = onClick)197 .padding(horizontal = 12.dp, vertical = 6.dp),198 ) {199 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)200 }201}202203@Composable204private fun SyncDisclosure(205 title: String,206 summary: String,207 expanded: Boolean,208 fg: Color,209 onToggle: () -> Unit,210) {211 Row(212 modifier = Modifier213 .fillMaxWidth()214 .heightIn(min = 48.dp)215 .clip(RoundedCornerShape(8.dp))216 .clickable(onClick = onToggle)217 .padding(vertical = 8.dp),218 verticalAlignment = Alignment.CenterVertically,219 ) {220 Column(Modifier.weight(1f)) {221 Text(222 title.uppercase(),223 color = fg.copy(alpha = 0.58f),224 fontFamily = PeachType.Mono,225 fontSize = 8.sp,226 letterSpacing = 0.22.em,227 )228 Text(229 summary,230 color = fg.copy(alpha = 0.7f),231 fontFamily = PeachType.Body,232 fontSize = 11.sp,233 modifier = Modifier.padding(top = 3.dp),234 )235 }236 Text(237 if (expanded) "hide" else "show",238 color = fg,239 fontFamily = PeachType.Mono,240 fontSize = 10.sp,241 )242 }243}244245@Composable246private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {247 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")248 Box(249 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))250 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))251 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))252 .clickable { onToggle(!on) },253 ) {254 Box(255 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)256 .padding(start = knob - 2.dp)257 .size(14.dp).clip(CircleShape)258 .background(if (on) fg else fg.copy(alpha = 0.58f)),259 )260 }261}262263@Composable264private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {265 Box(266 Modifier.clip(RoundedCornerShape(999.dp))267 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))268 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)269 .clickable(onClick = onClick)270 .padding(horizontal = 12.dp, vertical = 5.dp),271 ) {272 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)273 }274}275276@Composable277private fun ProBadge(fg: Color) {278 Box(279 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))280 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),281 ) {282 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)283 }284}285286@Composable287private fun Hint(text: String, fg: Color) {288 Text(289 text,290 modifier = Modifier.padding(top = 6.dp),291 color = fg.copy(alpha = 0.6f),292 fontFamily = PeachType.Display,293 fontStyle = FontStyle.Italic,294 fontSize = 11.5.sp,295 lineHeight = 16.sp,296 )297}298299300301@OptIn(ExperimentalLayoutApi::class)302@Composable303private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {304 SubLabel("ACCENT", fg, top = 0.dp)305 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {306 ACCENT_COLORS.forEach { (name, color) ->307 Box(308 Modifier.size(22.dp).clip(CircleShape).background(color)309 .border(310 if (s.accentColor == name) 2.dp else 1.dp,311 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),312 CircleShape,313 )314 .clickable { vm.setAccent(name) },315 )316 }317 }318 SubLabel("TEXT SIZE", fg)319 RowLine(fg) {320 Name("Text size", fg)321 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {322 Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }323 Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }324 Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }325 }326 }327 SubLabel("ICONS", fg)328 RowLine(fg) {329 Name("Site icons", fg)330 SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) { vm.setFaviconsEnabled(!s.faviconsEnabled) }331 }332 SubLabel("MOTION", fg)333 RowLine(fg) {334 Name("Reduce motion", fg)335 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }336 }337}338339340341@Composable342private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {343 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")344 val current = s.autoLockMinutes ?: 0345346 SubLabel("LOCKING", fg, top = 0.dp)347 RowLine(fg) {348 Name("Allow screenshots", fg)349 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }350 }351 RowLine(fg) {352 Name("Auto-lock after", fg)353 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)354 SubtleButton(lockOptions[idx].second, fg) {355 val next = lockOptions[(idx + 1) % lockOptions.size].first356 vm.setAutoLockMinutes(if (next == 0) 0 else next)357 }358 }359 SubLabel("BIOMETRICS", fg)360 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity361 RowLine(fg) {362 Name("Unlock with biometrics", fg)363 Toggle(s.biometricsEnabled, fg) { on ->364 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()365 }366 }367 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)368 if (bioError != null) {369 Text(370 bioError,371 color = com.peachpasswords.ui.theme.peachColors.Breach,372 fontFamily = PeachType.Mono, fontSize = 10.sp,373 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),374 )375 }376 SubLabel("AUTOFILL", fg)377 run {378 379 380 val ctx = LocalContext.current381 val enabled = AutofillEnablement.isEnabled(ctx)382 RowLine(fg) {383 Column(Modifier.weight(1f)) {384 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)385 Text(386 if (enabled) {387 "Peach is your autofill service."388 } else {389 "Android decides which app fills passwords. Turn Peach on in system settings."390 },391 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,392 modifier = Modifier.padding(top = 2.dp),393 )394 }395 if (!enabled) {396 SubtleButton("enable", fg) {397 398 399 400 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }401 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }402 }403 } else {404 ValueText("on", fg)405 }406 }407 }408409 SubLabel("PASSKEYS", fg)410 RowLine(fg) {411 Column(Modifier.weight(1f)) {412 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)413 Text(414 "Peach can handle passkey sign-in and creation through Android Credential Manager.",415 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,416 modifier = Modifier.padding(top = 2.dp),417 )418 }419 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }420 }421 RowLine(fg) {422 Name("Status", fg)423 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)424 }425 SubLabel("RECOVERY", fg)426 RowLine(fg) {427 Column(Modifier.weight(1f)) {428 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)429 Text(430 "Keep the paper copy current before rotating sync identity or replacing devices.",431 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,432 modifier = Modifier.padding(top = 2.dp),433 )434 }435 SubtleButton("view", fg) {436 if (bioActivity != null) {437 com.peachpasswords.ui.lock.Biometrics.authenticate(438 activity = bioActivity,439 title = "View recovery words",440 subtitle = "Confirm your identity to view your recovery words",441 onSuccess = { onNavigate(PeachNav.RecoveryView) },442 )443 } else onNavigate(PeachNav.RecoveryView)444 }445 }446 SubLabel("BREACH PROTECTION", fg)447 val checkingBreaches by vm.checkingBreaches.collectAsState()448 val breachResult by vm.breachResult.collectAsState()449 RowLine(fg) {450 Name("Breach monitoring", fg)451 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }452 }453 RowLine(fg) {454 Name("Check all passwords", fg)455 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }456 }457 breachResult?.let { result ->458 RowLine(fg) {459 Name("Last check", fg)460 ValueText(result, fg)461 }462 }463464 SubLabel("MASTER PASSWORD", fg)465 ChangePasswordRow(fg, vm)466467 SubLabel("DURESS PASSWORD", fg)468 val duressIsPro by vm.isPro.collectAsState()469 if (!duressIsPro) {470 RowLine(fg) {471 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {472 Name("Duress password", fg); ProBadge(fg)473 }474 ValueText("off", fg)475 }476 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)477 } else {478 DuressSection(fg, vm)479 }480}481482@Composable483private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {484 var open by remember { mutableStateOf(false) }485 var current by remember { mutableStateOf("") }486 var next by remember { mutableStateOf("") }487 var confirm by remember { mutableStateOf("") }488 var error by remember { mutableStateOf<String?>(null) }489 var done by remember { mutableStateOf(false) }490 val scope = rememberCoroutineScope()491492 RowLine(fg) {493 Name("Master password", fg)494 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }495 }496 AnimatedVisibility(open) {497 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {498 PwField("current password", current, fg) { current = it }499 PwField("new password (min 12 chars)", next, fg) { next = it }500 PwField("confirm new password", confirm, fg) { confirm = it }501 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }502 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)503 SubtleButton("save new password", fg) {504 error = when {505 current.isBlank() -> "enter your current password"506 next.length < 12 -> "new password must be at least 12 characters"507 next != confirm -> "new passwords do not match"508 next == current -> "new password must differ from current"509 else -> null510 }511 if (error == null) scope.launch {512 if (vm.changePassword(current, next)) {513 current = ""; next = ""; confirm = ""; open = false; done = true514 } else error = "incorrect current password"515 }516 }517 }518 }519}520521@Composable522private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {523 PeachField(524 label = placeholder,525 value = value,526 onValueChange = onChange,527 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,528 visualTransformation = PasswordVisualTransformation(),529 )530}531532533534@Composable535private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {536 val vm: UpdateViewModel = hiltViewModel()537 val available by vm.available.collectAsState()538 val state by vm.state.collectAsState()539540 RowLine(fg) {541 Name("Version", fg)542 ValueText(BuildConfig.VERSION_NAME, fg)543 }544 RowLine(fg) {545 Column(Modifier.weight(1f)) {546 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)547 Text(548 when {549 available != null -> "v${available?.version} is available"550 state.phase == UpdatePhase.Checking -> "Checking…"551 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"552 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"553 else -> "Peach checks once a day and tells you when there's a new version."554 },555 color = fg.copy(alpha = 0.7f),556 fontFamily = PeachType.Body,557 fontSize = 11.sp,558 lineHeight = 16.sp,559 modifier = Modifier.padding(top = 2.dp),560 )561 }562 if (available != null) {563 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }564 } else {565 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {566 vm.check()567 }568 }569 }570}571572@Composable573private fun VaultBody(574 fg: Color,575 vm: SettingsViewModel,576 s: VaultSettings,577 entriesCount: Int,578 onNavigate: (PeachNav) -> Unit,579) {580 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")581 val current = s.trashRetentionDays ?: 0582 val isPro by vm.isPro.collectAsState()583 val dedupCount by vm.dedupCount.collectAsState()584 val dedupRunning by vm.dedupRunning.collectAsState()585 val dedupDone by vm.dedupDone.collectAsState()586 val history by vm.vaultHistory.collectAsState()587 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()588589 SettingsGroup("ENTRIES", fg, top = 0.dp) {590 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }591 RowLine(fg) {592 Name("Duplicates", fg)593 val done = dedupDone594 if (done != null) {595 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)596 } else {597 val count = dedupCount598 val label = when {599 dedupRunning -> "scanning..."600 count == null -> "scan"601 count == 0 -> "none found"602 else -> "$count found"603 }604 SubtleButton(label, fg) {605 if (count == null || count == 0) vm.scanDuplicates()606 }607 }608 }609 if ((dedupCount ?: 0) > 0 && dedupDone == null) {610 Column(611 Modifier.fillMaxWidth()612 .padding(top = 2.dp, bottom = 8.dp)613 .clip(RoundedCornerShape(8.dp))614 .background(fg.copy(alpha = 0.08f))615 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))616 .padding(10.dp),617 verticalArrangement = Arrangement.spacedBy(8.dp),618 ) {619 val count = dedupCount ?: 0620 Text(621 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",622 color = fg.copy(alpha = 0.82f),623 fontFamily = PeachType.Body,624 fontSize = 11.sp,625 lineHeight = 16.sp,626 )627 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {628 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }629 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }630 }631 }632 }633 }634635 SettingsGroup("MAINTENANCE", fg) {636 RowLine(fg) {637 Name("Trash auto-delete", fg)638 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)639 SubtleButton(trashOptions[idx].second, fg) {640 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)641 }642 }643 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }644 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }645 }646647 SettingsGroup("DATA", fg) {648 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }649 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }650 RowLine(fg) {651 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }652 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }653 }654 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }655 }656657 SettingsGroup("UPDATES", fg) {658 UpdatesRows(fg, onNavigate)659 }660661 SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {662 var historyShowAll by remember { mutableStateOf(false) }663 if (history.isEmpty()) {664 Text(665 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",666 color = fg.copy(alpha = 0.68f),667 fontFamily = PeachType.Body,668 fontSize = 11.sp,669 lineHeight = 16.sp,670 modifier = Modifier.padding(bottom = 6.dp),671 )672 } else {673 Text(674 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",675 color = fg.copy(alpha = 0.68f),676 fontFamily = PeachType.Body,677 fontSize = 11.sp,678 lineHeight = 16.sp,679 modifier = Modifier.padding(bottom = 4.dp),680 )681 history.take(if (historyShowAll) history.size else 3).forEach { snap ->682 RowLine(fg) {683 Column(Modifier.weight(1f)) {684 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)685 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)686 }687 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {688 vm.restoreVaultSnapshot(snap.id)689 }690 }691 }692 if (history.size > 3) {693 RowLine(fg) {694 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)695 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }696 }697 }698 Text(699 "${history.size} of 5 snapshots",700 color = fg.copy(alpha = 0.58f),701 fontFamily = PeachType.Mono,702 fontSize = 10.sp,703 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),704 )705 }706 }707708 SettingsGroup("ADVANCED", fg, initiallyOpen = false) {709 RowLine(fg) {710 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)711 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }712 }713 RowLine(fg) {714 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)715 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }716 }717 }718}719720private fun snapshotSubLabel(snapshot: VaultSnapshot): String {721 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }722 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")723}724725private fun snapshotReasonLabel(reason: String): String = when (reason) {726 "local_save" -> "saved"727 "sync_received" -> "synced"728 "import" -> "imported"729 "restore" -> "restored"730 else -> reason731}732733private fun snapshotTimeLabel(savedAt: Long): String {734 val diff = System.currentTimeMillis() - savedAt735 if (diff < 60_000) return "Just now"736 if (diff < 3_600_000) return "${diff / 60_000}m ago"737 val date = Date(savedAt)738 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())739 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)740 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)741 if (today == thenDay) return "Today $time"742 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))743 if (yesterday == thenDay) return "Yesterday $time"744 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)745}746747748749@Composable750private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {751 val context = LocalContext.current752 val syncVm: SyncViewModel = hiltViewModel()753 val pairedDevices by syncVm.pairedDevices.collectAsState()754 val pendingVouched by syncVm.pendingVouched.collectAsState()755 val syncStatus by syncVm.status.collectAsState()756 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()757 val syncMode by syncVm.syncMode.collectAsState()758 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()759 var notificationsAvailable by remember {760 mutableStateOf(canShowBackgroundSyncNotification(context))761 }762 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }763 val scope = rememberCoroutineScope()764 var confirmUnpair by remember { mutableStateOf<String?>(null) }765 var rotateConfirmOpen by remember { mutableStateOf(false) }766 var rotating by remember { mutableStateOf(false) }767 var rotateError by remember { mutableStateOf<String?>(null) }768 var relayUrlDraft by remember(configuredRelayUrl) {769 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))770 }771 var relayUrlStatus by remember { mutableStateOf<String?>(null) }772 var showConnection by remember { mutableStateOf(false) }773 var showRecovery by remember { mutableStateOf(false) }774775 SubLabel("ADD A DEVICE", fg, top = 0.dp)776 RowLine(fg) {777 Column(Modifier.weight(1f)) {778 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)779 Text(780 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",781 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,782 modifier = Modifier.padding(top = 2.dp),783 )784 }785 786 787 788 789 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }790 Spacer(Modifier.width(PeachSpacing.xs))791 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }792 }793794 SubLabel("SYNC STATUS", fg)795 val indicator = syncIndicator(syncStatus, pairedDevices.size)796 RowLine(fg) {797 SyncIndicatorDot(798 indicator = indicator,799 label = syncIndicatorLabel(indicator, syncStatus),800 modifier = Modifier.padding(end = PeachSpacing.sm),801 size = 8.dp,802 )803 Text(804 describeSyncOutcome(syncStatus),805 color = fg.copy(alpha = 0.7f),806 fontFamily = PeachType.Body,807 fontSize = 11.sp,808 lineHeight = 16.sp,809 modifier = Modifier.weight(1f),810 )811 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {812 if (!syncStatus.syncing) syncVm.syncNow()813 }814 }815816 SubLabel("PAIRED DEVICES", fg)817 if (pairedDevices.isEmpty()) {818 PeachEmptyOrEndState(819 message = "No paired devices yet.",820 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",821 modifier = Modifier.padding(top = PeachSpacing.xs),822 )823 } else {824 pairedDevices.forEach { d ->825 RowLine(fg) {826 Column(Modifier.weight(1f)) {827 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)828 Text(829 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",830 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,831 modifier = Modifier.padding(top = 2.dp),832 )833 }834 if (confirmUnpair != d.deviceId) {835 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }836 }837 }838 839 840 841 842 843 if (confirmUnpair == d.deviceId) {844 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {845 Text(846 "Stops this device receiving future changes. It doesn't lock it " +847 "out of what it already has - if it's lost or stolen, also " +848 "change your master password and generate new recovery words.",849 color = fg.copy(alpha = 0.7f),850 fontFamily = PeachType.Body,851 fontSize = 11.sp,852 lineHeight = 16.sp,853 )854 Row(855 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),856 horizontalArrangement = Arrangement.End,857 ) {858 SubtleButton("cancel", fg) { confirmUnpair = null }859 Spacer(Modifier.width(PeachSpacing.sm))860 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }861 }862 }863 }864 }865 }866867 SyncDisclosure(868 title = "Connection",869 summary = "Background sync, relay, and privacy choices",870 expanded = showConnection,871 fg = fg,872 onToggle = { showConnection = !showConnection },873 )874 if (showConnection) {875 RowLine(fg) {876 Column(Modifier.weight(1f)) {877 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)878 Text(879 when {880 backgroundSyncEnabled && !notificationsAvailable ->881 "Action needed — Android notifications are off, so background sync is not visible."882 backgroundSyncEnabled ->883 "On — keeps this phone reachable for paired devices."884 else ->885 "Off — Peach syncs only while you are using the app."886 },887 color = fg.copy(alpha = 0.7f),888 fontFamily = PeachType.Body,889 fontSize = 11.sp,890 lineHeight = 16.sp,891 modifier = Modifier.padding(top = 2.dp),892 )893 }894 SubtleButton(895 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",896 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,897 ) { showBackgroundSyncPrompt = true }898 }899 SyncModeRow(900 title = "Public relay",901 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",902 selected = syncMode == SyncMode.PUBLIC,903 fg = fg,904 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }905 SyncModeRow(906 title = "Private relay",907 detail = "Use your own peach-relay on your own domain.",908 selected = syncMode == SyncMode.PRIVATE,909 fg = fg,910 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }911912 if (syncMode == SyncMode.PRIVATE) {913 Text(914 "RELAY URL",915 color = fg.copy(alpha = 0.6f),916 fontFamily = PeachType.Mono,917 fontSize = 8.sp,918 letterSpacing = 0.22.em,919 modifier = Modifier.padding(top = 8.dp),920 )921 Box(922 Modifier.fillMaxWidth().padding(top = 6.dp)923 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))924 .padding(horizontal = 12.dp, vertical = 10.dp),925 ) {926 BasicTextField(927 value = relayUrlDraft,928 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },929 singleLine = true,930 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),931 cursorBrush = SolidColor(fg),932 modifier = Modifier.fillMaxWidth(),933 )934 }935 val needsRelayUrl = configuredRelayUrl.isEmpty()936 RowLine(fg) {937 val message = relayUrlStatus938 ?: if (needsRelayUrl) {939 "Sync is paused until you enter your relay URL."940 } else {941 "Only secure wss:// relays are accepted."942 }943 val warn = relayUrlStatus?.startsWith("Use") == true ||944 (relayUrlStatus == null && needsRelayUrl)945 ValueText(message, if (warn) peachColors.Breach else fg)946 SubtleButton("save", fg) {947 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {948 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)949 "Saved"950 } else {951 "Use a valid wss:// relay URL"952 }953 }954 }955 }956957 SyncModeRow(958 title = "No sync",959 detail = "Keep this device separate and move backups by hand.",960 selected = syncMode == SyncMode.OFF,961 fg = fg,962 ) { syncVm.setSyncMode(SyncMode.OFF) }963 Hint("Pairing authorizes a device, not a cloud account.", fg)964 }965966 if (pendingVouched.isNotEmpty()) {967 SubLabel("PENDING DEVICES", fg)968 pendingVouched.forEach { dev ->969 RowLine(fg) {970 Column(Modifier.weight(1f)) {971 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)972 Text(973 "Was added by another device. Allow it to sync?",974 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,975 modifier = Modifier.padding(top = 2.dp),976 )977 }978 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {979 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }980 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }981 }982 }983 }984 }985986 SyncDisclosure(987 title = "Recovery & security",988 summary = "For a lost or stolen device",989 expanded = showRecovery,990 fg = fg,991 onToggle = { showRecovery = !showRecovery },992 )993 if (showRecovery) {994 RowLine(fg) {995 Column(Modifier.weight(1f)) {996 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)997 Text(998 "Scan only when this populated vault previously trusted the source device.",999 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1000 modifier = Modifier.padding(top = 2.dp),1001 )1002 }1003 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1004 }1005 if (rotateConfirmOpen) {1006 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1007 rotateError?.let {1008 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1009 }1010 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1011 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1012 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1013 if (rotating) return@SubtleButton1014 rotating = true1015 rotateError = null1016 scope.launch {1017 val ok = syncVm.rotateSyncIdentity()1018 rotating = false1019 if (ok) {1020 rotateConfirmOpen = false1021 onNavigate(PeachNav.RecoverySetup)1022 } else {1023 rotateError = "Rotation failed. Unlock your real vault and try again."1024 }1025 }1026 }1027 }1028 } else {1029 RowLine(fg) {1030 Column(Modifier.weight(1f)) {1031 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1032 Text(1033 "Disconnects every device and replaces the keys used for sync.",1034 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1035 modifier = Modifier.padding(top = 2.dp),1036 )1037 }1038 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1039 }1040 }1041 }10421043 if (showBackgroundSyncPrompt) {1044 BackgroundSyncDialog { enabled ->1045 showBackgroundSyncPrompt = false1046 notificationsAvailable = canShowBackgroundSyncNotification(context)1047 syncVm.setBackgroundSyncEnabled(enabled)1048 }1049 }1050}10511052private fun deviceTypeLabel(type: String?): String = when (type) {1053 "android" -> "Android"1054 "phone" -> "Phone"1055 "tablet" -> "Tablet"1056 "browser" -> "Browser"1057 else -> "Device"1058}10591060private fun syncTimeLabel(lastSyncedAt: Long?): String {1061 if (lastSyncedAt == null) return "never synced"1062 val diff = System.currentTimeMillis() - lastSyncedAt1063 if (diff < 60_000) return "just now"1064 if (diff < 3_600_000) return "${diff / 60_000}m ago"1065 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1066 return "${diff / 86_400_000}d ago"1067}10681069private fun isSyncStale(lastSyncedAt: Long?): Boolean =1070 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1071107210731074@Composable1075private fun ProBody(fg: Color, vm: SettingsViewModel) {1076 val ctx = LocalContext.current1077 val isPro by vm.isPro.collectAsState()1078 if (isPro) {1079 ProActiveBody(fg, vm)1080 return1081 }1082 val features = listOf(1083 "Peach Sync" to "End-to-end encrypted",1084 "Peach Codex" to "Restore from paper",1085 "Passkeys" to "Zero-knowledge WebAuthn",1086 "Advanced Vault Health" to "Reuse · age · exposure",1087 "Canary entries" to "Detects vault compromise",1088 "Duress vault" to "Decoy under coercion",1089 )1090 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1091 Text(1092 "Everything, everywhere.",1093 modifier = Modifier.padding(top = 8.dp),1094 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1095 )1096 Text(1097 "One key unlocks every Pro feature — for the lifetime of the app.",1098 modifier = Modifier.padding(top = 6.dp),1099 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1100 )1101 Spacer(Modifier.height(10.dp))1102 features.forEachIndexed { i, (name, desc) ->1103 1104 1105 1106 1107 RowLine(fg, verticalPadding = 6.dp) {1108 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1109 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1110 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1111 }1112 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1113 }1114 }1115 Spacer(Modifier.height(12.dp))1116 Text(1117 "$39",1118 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1119 )1120 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1121 Spacer(Modifier.height(10.dp))1122 Box(1123 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1124 .background(fg.copy(alpha = 0.16f))1125 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1126 .clickable {1127 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1128 }1129 .padding(vertical = 12.dp),1130 contentAlignment = Alignment.Center,1131 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }11321133 1134 1135 1136 1137 Spacer(Modifier.height(44.dp))11381139 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1140 var key by remember { mutableStateOf("") }1141 val activating by vm.activating.collectAsState()1142 val activationError by vm.activationError.collectAsState()1143 Box(1144 Modifier.fillMaxWidth().padding(top = 6.dp)1145 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1146 .padding(horizontal = 12.dp, vertical = 10.dp),1147 ) {1148 BasicTextField(1149 value = key,1150 1151 1152 1153 1154 onValueChange = { key = it; vm.clearActivationError() },1155 singleLine = true,1156 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1157 cursorBrush = SolidColor(fg),1158 modifier = Modifier.fillMaxWidth(),1159 decorationBox = { inner ->1160 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1161 inner()1162 },1163 )1164 }1165 if (activationError != null) {1166 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1167 }1168 Spacer(Modifier.height(8.dp))1169 Box(1170 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1171 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1172 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1173 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1174 .padding(vertical = 12.dp),1175 contentAlignment = Alignment.Center,1176 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1177}11781179@Composable1180private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1181 val ctx = LocalContext.current1182 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1183 val licenseKey by vm.licenseKey.collectAsState()1184 val issuedAt by vm.licenseIssuedAt.collectAsState()1185 var showKey by remember { mutableStateOf(false) }1186 var copied by remember { mutableStateOf(false) }1187 val scope = rememberCoroutineScope()1188 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1189 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1190 }1191 Text(1192 "Peach Pro",1193 modifier = Modifier.fillMaxWidth(),1194 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1195 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1196 )1197 Text(1198 formatLicenseActiveSince(issuedAt),1199 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1200 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1201 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1202 )1203 licenseKey?.let { key ->1204 Spacer(Modifier.height(12.dp))1205 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1206 RowLine(fg) {1207 Text(1208 if (showKey) key else "************************",1209 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1210 fontFamily = PeachType.Mono,1211 fontSize = 10.sp,1212 modifier = Modifier.weight(1f),1213 )1214 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1215 SubtleButton(if (copied) "copied" else "copy", fg) {1216 PeachClipboard.copy(ctx, "peach license", key)1217 copied = true1218 scope.launch {1219 delay(1_500)1220 copied = false1221 }1222 }1223 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1224 }1225 }1226 }1227 Spacer(Modifier.height(12.dp))1228 features.forEachIndexed { i, name ->1229 RowLine(fg) {1230 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1231 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1232 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1233 }1234 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1235 }1236 }1237 Spacer(Modifier.height(12.dp))1238 Box(1239 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1240 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1241 .clickable { vm.deactivate() }1242 .padding(vertical = 12.dp),1243 contentAlignment = Alignment.Center,1244 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1245}12461247private fun formatLicenseActiveSince(issuedAt: Long?): String {1248 if (issuedAt == null) return "Active"1249 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1250 return "Active since $monthYear"1251}1252125312541255@OptIn(ExperimentalLayoutApi::class)1256@Composable1257private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1258 val categories = listOf("Bug report", "Feature request", "Question", "Other")1259 var category by remember { mutableStateOf(categories[0]) }1260 var message by remember { mutableStateOf("") }1261 val state by vm.feedbackState.collectAsState()12621263 1264 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }12651266 if (state is FeedbackState.Sent) {1267 Box(1268 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1269 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1270 ) {1271 Column {1272 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1273 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))1274 }1275 }1276 return1277 }12781279 val sending = state is FeedbackState.Sending12801281 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)1282 SubLabel("TYPE", fg)1283 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1284 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1285 }1286 SubLabel("MESSAGE", fg)1287 Box(1288 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1289 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1290 .padding(10.dp),1291 ) {1292 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1293 BasicTextField(1294 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1295 enabled = !sending,1296 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1297 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1298 )1299 }1300 Text(1301 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1302 modifier = Modifier.padding(top = 6.dp),1303 color = fg.copy(alpha = 0.52f),1304 fontFamily = PeachType.Mono,1305 fontSize = 10.sp,1306 lineHeight = 14.sp,1307 )1308 Spacer(Modifier.height(8.dp))13091310 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1311 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1312 Box(1313 Modifier.clip(RoundedCornerShape(8.dp))1314 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1315 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1316 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1317 .padding(horizontal = 12.dp, vertical = 6.dp),1318 ) {1319 Text(1320 feedbackSubmitLabel(state),1321 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1322 fontFamily = PeachType.Mono, fontSize = 11.sp,1323 )1324 }1325 if (sending) {1326 CircularProgressIndicator(1327 color = LocalPeachAccent.current,1328 strokeWidth = 2.dp,1329 modifier = Modifier.size(16.dp),1330 )1331 }1332 }13331334 (state as? FeedbackState.Error)?.let { err ->1335 Text(1336 err.message,1337 modifier = Modifier.padding(top = 8.dp),1338 color = peachColors.Breach,1339 fontFamily = PeachType.Mono,1340 fontSize = 11.sp,1341 lineHeight = 15.sp,1342 )1343 }13441345 Hint("Sent anonymously · no account required", fg)1346}1347134813491350@Composable1351internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1352 val configured by vm.duressConfigured.collectAsState()1353 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1354}13551356@Composable1357private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1358 Text(1359 "Duress password is active",1360 color = peachColors.HealthOk,1361 fontFamily = PeachType.Mono,1362 fontSize = 11.sp,1363 modifier = Modifier.padding(top = 6.dp),1364 )1365 Spacer(Modifier.height(8.dp))1366 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1367 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1368}13691370@Composable1371private fun DuressForm(fg: Color, vm: SettingsViewModel) {1372 var enabled by remember { mutableStateOf(false) }1373 var password by remember { mutableStateOf("") }1374 var confirm by remember { mutableStateOf("") }1375 var action by remember { mutableStateOf(DuressAction.DECOY) }1376 var error by remember { mutableStateOf<String?>(null) }1377 val scope = rememberCoroutineScope()13781379 RowLine(fg) {1380 Name("Duress password", fg)1381 Toggle(enabled, fg) { on ->1382 enabled = on1383 if (!on) { password = ""; confirm = ""; error = null }1384 }1385 }1386 Text(1387 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1388 color = fg.copy(alpha = 0.7f),1389 fontFamily = PeachType.Mono,1390 fontSize = 10.sp,1391 modifier = Modifier.padding(top = 6.dp),1392 )13931394 AnimatedVisibility(enabled) {1395 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1396 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1397 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13981399 Text(1400 "When your duress password is entered at the lock screen, Peach will:",1401 color = fg.copy(alpha = 0.7f),1402 fontFamily = PeachType.Mono,1403 fontSize = 10.sp,1404 modifier = Modifier.padding(top = 4.dp),1405 )14061407 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1408 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }14091410 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }14111412 val canSave = password.isNotBlank() && confirm.isNotBlank()1413 DuressSaveButton("Save duress password", fg, canSave) {1414 error = when {1415 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1416 password != confirm -> "Passwords do not match"1417 else -> null1418 }1419 if (error == null) scope.launch {1420 if (vm.saveDuress(password, action)) {1421 password = ""; confirm = ""; enabled = false1422 } else {1423 error = "Couldn't save — the duress passphrase must differ from your real password"1424 }1425 }1426 }1427 }1428 }1429}14301431@Composable1432private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1433 val accent = LocalPeachAccent.current1434 Row(1435 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1436 verticalAlignment = Alignment.CenterVertically,1437 horizontalArrangement = Arrangement.spacedBy(10.dp),1438 ) {1439 Box(1440 Modifier.size(16.dp).clip(CircleShape)1441 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1442 contentAlignment = Alignment.Center,1443 ) {1444 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1445 }1446 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1447 }1448}14491450@Composable1451private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1452 val accent = LocalPeachAccent.current1453 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1454 Box(1455 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1456 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1457 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1458 .let { if (enabled) it.clickable(onClick = onClick) else it }1459 .padding(vertical = 11.dp),1460 contentAlignment = Alignment.Center,1461 ) {1462 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1463 }1464}14651466@Composable1467private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1468 Box(1469 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1470 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1471 .clickable(onClick = onClick)1472 .padding(vertical = 11.dp),1473 contentAlignment = Alignment.Center,1474 ) {1475 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1476 }1477}147814791480@Composable1481private fun SyncModeRow(1482 title: String,1483 detail: String,1484 selected: Boolean,1485 fg: Color,1486 onSelect: () -> Unit,1487) {1488 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1489 Column(Modifier.weight(1f)) {1490 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1491 Text(1492 detail,1493 color = fg.copy(alpha = 0.7f),1494 fontFamily = PeachType.Body,1495 fontSize = 11.sp,1496 lineHeight = 16.sp,1497 modifier = Modifier.padding(top = 2.dp),1498 )1499 }1500 Text(1501 if (selected) "●" else "○",1502 color = if (selected) fg else fg.copy(alpha = 0.45f),1503 fontFamily = PeachType.Mono,1504 fontSize = 14.sp,1505 modifier = Modifier.padding(start = 10.dp),1506 )1507 }1508}