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}124125@Composable126private fun RowLine(127 fg: Color,128 modifier: Modifier = Modifier,129 verticalPadding: Dp = 9.dp,130 content: @Composable RowScope.() -> Unit,131) {132 Row(133 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),134 verticalAlignment = Alignment.CenterVertically,135 horizontalArrangement = Arrangement.SpaceBetween,136 content = content,137 )138 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))139}140141@Composable142private fun RowScope.Name(text: String, fg: Color) {143 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))144}145146@Composable147private fun ValueText(text: String, fg: Color) {148 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)149}150151@Composable152private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {153 Box(154 Modifier.clip(RoundedCornerShape(8.dp))155 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))156 .background(fg.copy(alpha = 0.08f))157 .clickable(onClick = onClick)158 .padding(horizontal = 12.dp, vertical = 6.dp),159 ) {160 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)161 }162}163164@Composable165private fun SyncDisclosure(166 title: String,167 summary: String,168 expanded: Boolean,169 fg: Color,170 onToggle: () -> Unit,171) {172 Row(173 modifier = Modifier174 .fillMaxWidth()175 .heightIn(min = 48.dp)176 .clip(RoundedCornerShape(8.dp))177 .clickable(onClick = onToggle)178 .padding(vertical = 8.dp),179 verticalAlignment = Alignment.CenterVertically,180 ) {181 Column(Modifier.weight(1f)) {182 Text(183 title.uppercase(),184 color = fg.copy(alpha = 0.58f),185 fontFamily = PeachType.Mono,186 fontSize = 8.sp,187 letterSpacing = 0.22.em,188 )189 Text(190 summary,191 color = fg.copy(alpha = 0.7f),192 fontFamily = PeachType.Body,193 fontSize = 11.sp,194 modifier = Modifier.padding(top = 3.dp),195 )196 }197 Text(198 if (expanded) "hide" else "show",199 color = fg,200 fontFamily = PeachType.Mono,201 fontSize = 10.sp,202 )203 }204}205206@Composable207private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {208 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")209 Box(210 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))211 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))212 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))213 .clickable { onToggle(!on) },214 ) {215 Box(216 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)217 .padding(start = knob - 2.dp)218 .size(14.dp).clip(CircleShape)219 .background(if (on) fg else fg.copy(alpha = 0.58f)),220 )221 }222}223224@Composable225private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {226 Box(227 Modifier.clip(RoundedCornerShape(999.dp))228 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))229 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)230 .clickable(onClick = onClick)231 .padding(horizontal = 12.dp, vertical = 5.dp),232 ) {233 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)234 }235}236237@Composable238private fun ProBadge(fg: Color) {239 Box(240 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))241 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),242 ) {243 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)244 }245}246247@Composable248private fun Hint(text: String, fg: Color) {249 Text(250 text,251 modifier = Modifier.padding(top = 6.dp),252 color = fg.copy(alpha = 0.6f),253 fontFamily = PeachType.Display,254 fontStyle = FontStyle.Italic,255 fontSize = 11.5.sp,256 lineHeight = 16.sp,257 )258}259260261262@OptIn(ExperimentalLayoutApi::class)263@Composable264private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {265 SubLabel("ACCENT", fg, top = 0.dp)266 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {267 ACCENT_COLORS.forEach { (name, color) ->268 Box(269 Modifier.size(22.dp).clip(CircleShape).background(color)270 .border(271 if (s.accentColor == name) 2.dp else 1.dp,272 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),273 CircleShape,274 )275 .clickable { vm.setAccent(name) },276 )277 }278 }279 SubLabel("TEXT SIZE", fg)280 RowLine(fg) {281 Name("Text size", fg)282 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {283 Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }284 Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }285 Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }286 }287 }288 SubLabel("MOTION", fg)289 RowLine(fg) {290 Name("Reduce motion", fg)291 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }292 }293}294295296297@Composable298private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {299 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")300 val current = s.autoLockMinutes ?: 0301302 SubLabel("LOCKING", fg, top = 0.dp)303 RowLine(fg) {304 Name("Allow screenshots", fg)305 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }306 }307 RowLine(fg) {308 Name("Auto-lock after", fg)309 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)310 SubtleButton(lockOptions[idx].second, fg) {311 val next = lockOptions[(idx + 1) % lockOptions.size].first312 vm.setAutoLockMinutes(if (next == 0) 0 else next)313 }314 }315 SubLabel("BIOMETRICS", fg)316 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity317 RowLine(fg) {318 Name("Unlock with biometrics", fg)319 Toggle(s.biometricsEnabled, fg) { on ->320 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()321 }322 }323 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)324 if (bioError != null) {325 Text(326 bioError,327 color = com.peachpasswords.ui.theme.peachColors.Breach,328 fontFamily = PeachType.Mono, fontSize = 10.sp,329 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),330 )331 }332 SubLabel("AUTOFILL", fg)333 run {334 335 336 val ctx = LocalContext.current337 val enabled = AutofillEnablement.isEnabled(ctx)338 RowLine(fg) {339 Column(Modifier.weight(1f)) {340 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)341 Text(342 if (enabled) {343 "Peach is your autofill service."344 } else {345 "Android decides which app fills passwords. Turn Peach on in system settings."346 },347 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,348 modifier = Modifier.padding(top = 2.dp),349 )350 }351 if (!enabled) {352 SubtleButton("enable", fg) {353 354 355 356 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }357 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }358 }359 } else {360 ValueText("on", fg)361 }362 }363 }364365 SubLabel("PASSKEYS", fg)366 RowLine(fg) {367 Column(Modifier.weight(1f)) {368 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)369 Text(370 "Peach can handle passkey sign-in and creation through Android Credential Manager.",371 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,372 modifier = Modifier.padding(top = 2.dp),373 )374 }375 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }376 }377 RowLine(fg) {378 Name("Status", fg)379 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)380 }381 SubLabel("RECOVERY", fg)382 RowLine(fg) {383 Column(Modifier.weight(1f)) {384 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)385 Text(386 "Keep the paper copy current before rotating sync identity or replacing devices.",387 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,388 modifier = Modifier.padding(top = 2.dp),389 )390 }391 SubtleButton("view", fg) {392 if (bioActivity != null) {393 com.peachpasswords.ui.lock.Biometrics.authenticate(394 activity = bioActivity,395 title = "View recovery words",396 subtitle = "Confirm your identity to view your recovery words",397 onSuccess = { onNavigate(PeachNav.RecoveryView) },398 )399 } else onNavigate(PeachNav.RecoveryView)400 }401 }402 SubLabel("BREACH PROTECTION", fg)403 val checkingBreaches by vm.checkingBreaches.collectAsState()404 val breachResult by vm.breachResult.collectAsState()405 RowLine(fg) {406 Name("Breach monitoring", fg)407 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }408 }409 RowLine(fg) {410 Name("Check all passwords", fg)411 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }412 }413 breachResult?.let { result ->414 RowLine(fg) {415 Name("Last check", fg)416 ValueText(result, fg)417 }418 }419420 SubLabel("MASTER PASSWORD", fg)421 ChangePasswordRow(fg, vm)422423 SubLabel("DURESS PASSWORD", fg)424 val duressIsPro by vm.isPro.collectAsState()425 if (!duressIsPro) {426 RowLine(fg) {427 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {428 Name("Duress password", fg); ProBadge(fg)429 }430 ValueText("off", fg)431 }432 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)433 } else {434 DuressSection(fg, vm)435 }436}437438@Composable439private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {440 var open by remember { mutableStateOf(false) }441 var current by remember { mutableStateOf("") }442 var next by remember { mutableStateOf("") }443 var confirm by remember { mutableStateOf("") }444 var error by remember { mutableStateOf<String?>(null) }445 var done by remember { mutableStateOf(false) }446 val scope = rememberCoroutineScope()447448 RowLine(fg) {449 Name("Master password", fg)450 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }451 }452 AnimatedVisibility(open) {453 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {454 PwField("current password", current, fg) { current = it }455 PwField("new password (min 12 chars)", next, fg) { next = it }456 PwField("confirm new password", confirm, fg) { confirm = it }457 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }458 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)459 SubtleButton("save new password", fg) {460 error = when {461 current.isBlank() -> "enter your current password"462 next.length < 12 -> "new password must be at least 12 characters"463 next != confirm -> "new passwords do not match"464 next == current -> "new password must differ from current"465 else -> null466 }467 if (error == null) scope.launch {468 if (vm.changePassword(current, next)) {469 current = ""; next = ""; confirm = ""; open = false; done = true470 } else error = "incorrect current password"471 }472 }473 }474 }475}476477@Composable478private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {479 PeachField(480 label = placeholder,481 value = value,482 onValueChange = onChange,483 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,484 visualTransformation = PasswordVisualTransformation(),485 )486}487488489490@Composable491private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {492 val vm: UpdateViewModel = hiltViewModel()493 val available by vm.available.collectAsState()494 val state by vm.state.collectAsState()495496 RowLine(fg) {497 Name("Version", fg)498 ValueText(BuildConfig.VERSION_NAME, fg)499 }500 RowLine(fg) {501 Column(Modifier.weight(1f)) {502 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)503 Text(504 when {505 available != null -> "v${available?.version} is available"506 state.phase == UpdatePhase.Checking -> "Checking…"507 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"508 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"509 else -> "Peach checks once a day and tells you when there's a new version."510 },511 color = fg.copy(alpha = 0.7f),512 fontFamily = PeachType.Body,513 fontSize = 11.sp,514 lineHeight = 16.sp,515 modifier = Modifier.padding(top = 2.dp),516 )517 }518 if (available != null) {519 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }520 } else {521 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {522 vm.check()523 }524 }525 }526}527528@Composable529private fun VaultBody(530 fg: Color,531 vm: SettingsViewModel,532 s: VaultSettings,533 entriesCount: Int,534 onNavigate: (PeachNav) -> Unit,535) {536 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")537 val current = s.trashRetentionDays ?: 0538 val isPro by vm.isPro.collectAsState()539 val dedupCount by vm.dedupCount.collectAsState()540 val dedupRunning by vm.dedupRunning.collectAsState()541 val dedupDone by vm.dedupDone.collectAsState()542 val history by vm.vaultHistory.collectAsState()543 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()544545 SubLabel("ENTRIES", fg, top = 0.dp)546 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }547 RowLine(fg) {548 Name("Duplicates", fg)549 val done = dedupDone550 if (done != null) {551 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)552 } else {553 val count = dedupCount554 val label = when {555 dedupRunning -> "scanning..."556 count == null -> "scan"557 count == 0 -> "none found"558 else -> "$count found"559 }560 SubtleButton(label, fg) {561 if (count == null || count == 0) vm.scanDuplicates()562 }563 }564 }565 if ((dedupCount ?: 0) > 0 && dedupDone == null) {566 Column(567 Modifier.fillMaxWidth()568 .padding(top = 2.dp, bottom = 8.dp)569 .clip(RoundedCornerShape(8.dp))570 .background(fg.copy(alpha = 0.08f))571 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))572 .padding(10.dp),573 verticalArrangement = Arrangement.spacedBy(8.dp),574 ) {575 val count = dedupCount ?: 0576 Text(577 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",578 color = fg.copy(alpha = 0.82f),579 fontFamily = PeachType.Body,580 fontSize = 11.sp,581 lineHeight = 16.sp,582 )583 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {584 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }585 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }586 }587 }588 }589590 SubLabel("MAINTENANCE", fg)591 RowLine(fg) {592 Name("Trash auto-delete", fg)593 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)594 SubtleButton(trashOptions[idx].second, fg) {595 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)596 }597 }598 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }599 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }600601 SubLabel("DATA", fg)602 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }603 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }604 RowLine(fg) {605 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }606 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }607 }608 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }609610 var historyShowAll by remember { mutableStateOf(false) }611 SubLabel("UPDATES", fg)612 UpdatesRows(fg, onNavigate)613614 SubLabel("VAULT HISTORY", fg)615 if (history.isEmpty()) {616 Text(617 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",618 color = fg.copy(alpha = 0.68f),619 fontFamily = PeachType.Body,620 fontSize = 11.sp,621 lineHeight = 16.sp,622 modifier = Modifier.padding(bottom = 6.dp),623 )624 } else {625 Text(626 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",627 color = fg.copy(alpha = 0.68f),628 fontFamily = PeachType.Body,629 fontSize = 11.sp,630 lineHeight = 16.sp,631 modifier = Modifier.padding(bottom = 4.dp),632 )633 history.take(if (historyShowAll) history.size else 3).forEach { snap ->634 RowLine(fg) {635 Column(Modifier.weight(1f)) {636 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)637 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)638 }639 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {640 vm.restoreVaultSnapshot(snap.id)641 }642 }643 }644 if (history.size > 3) {645 RowLine(fg) {646 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)647 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }648 }649 }650 Text(651 "${history.size} of 5 snapshots",652 color = fg.copy(alpha = 0.58f),653 fontFamily = PeachType.Mono,654 fontSize = 10.sp,655 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),656 )657 }658659 var advancedOpen by remember { mutableStateOf(false) }660 SubLabel("ADVANCED", fg)661 RowLine(fg) {662 Name("Advanced", fg)663 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }664 }665 if (advancedOpen) {666 RowLine(fg) {667 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)668 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }669 }670 RowLine(fg) {671 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)672 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }673 }674 }675}676677private fun snapshotSubLabel(snapshot: VaultSnapshot): String {678 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }679 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")680}681682private fun snapshotReasonLabel(reason: String): String = when (reason) {683 "local_save" -> "saved"684 "sync_received" -> "synced"685 "import" -> "imported"686 "restore" -> "restored"687 else -> reason688}689690private fun snapshotTimeLabel(savedAt: Long): String {691 val diff = System.currentTimeMillis() - savedAt692 if (diff < 60_000) return "Just now"693 if (diff < 3_600_000) return "${diff / 60_000}m ago"694 val date = Date(savedAt)695 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())696 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)697 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)698 if (today == thenDay) return "Today $time"699 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))700 if (yesterday == thenDay) return "Yesterday $time"701 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)702}703704705706@Composable707private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {708 val context = LocalContext.current709 val syncVm: SyncViewModel = hiltViewModel()710 val pairedDevices by syncVm.pairedDevices.collectAsState()711 val pendingVouched by syncVm.pendingVouched.collectAsState()712 val syncStatus by syncVm.status.collectAsState()713 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()714 val syncMode by syncVm.syncMode.collectAsState()715 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()716 var notificationsAvailable by remember {717 mutableStateOf(canShowBackgroundSyncNotification(context))718 }719 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }720 val scope = rememberCoroutineScope()721 var confirmUnpair by remember { mutableStateOf<String?>(null) }722 var rotateConfirmOpen by remember { mutableStateOf(false) }723 var rotating by remember { mutableStateOf(false) }724 var rotateError by remember { mutableStateOf<String?>(null) }725 var relayUrlDraft by remember(configuredRelayUrl) {726 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))727 }728 var relayUrlStatus by remember { mutableStateOf<String?>(null) }729 var showConnection by remember { mutableStateOf(false) }730 var showRecovery by remember { mutableStateOf(false) }731732 SubLabel("ADD A DEVICE", fg, top = 0.dp)733 RowLine(fg) {734 Column(Modifier.weight(1f)) {735 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)736 Text(737 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",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 743 744 745 746 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }747 Spacer(Modifier.width(PeachSpacing.xs))748 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }749 }750751 SubLabel("SYNC STATUS", fg)752 val indicator = syncIndicator(syncStatus, pairedDevices.size)753 RowLine(fg) {754 SyncIndicatorDot(755 indicator = indicator,756 label = syncIndicatorLabel(indicator, syncStatus),757 modifier = Modifier.padding(end = PeachSpacing.sm),758 size = 8.dp,759 )760 Text(761 describeSyncOutcome(syncStatus),762 color = fg.copy(alpha = 0.7f),763 fontFamily = PeachType.Body,764 fontSize = 11.sp,765 lineHeight = 16.sp,766 modifier = Modifier.weight(1f),767 )768 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {769 if (!syncStatus.syncing) syncVm.syncNow()770 }771 }772773 SubLabel("PAIRED DEVICES", fg)774 if (pairedDevices.isEmpty()) {775 PeachEmptyOrEndState(776 message = "No paired devices yet.",777 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",778 modifier = Modifier.padding(top = PeachSpacing.xs),779 )780 } else {781 pairedDevices.forEach { d ->782 RowLine(fg) {783 Column(Modifier.weight(1f)) {784 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)785 Text(786 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",787 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,788 modifier = Modifier.padding(top = 2.dp),789 )790 }791 if (confirmUnpair != d.deviceId) {792 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }793 }794 }795 796 797 798 799 800 if (confirmUnpair == d.deviceId) {801 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {802 Text(803 "Stops this device receiving future changes. It doesn't lock it " +804 "out of what it already has - if it's lost or stolen, also " +805 "change your master password and generate new recovery words.",806 color = fg.copy(alpha = 0.7f),807 fontFamily = PeachType.Body,808 fontSize = 11.sp,809 lineHeight = 16.sp,810 )811 Row(812 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),813 horizontalArrangement = Arrangement.End,814 ) {815 SubtleButton("cancel", fg) { confirmUnpair = null }816 Spacer(Modifier.width(PeachSpacing.sm))817 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }818 }819 }820 }821 }822 }823824 SyncDisclosure(825 title = "Connection",826 summary = "Background sync, relay, and privacy choices",827 expanded = showConnection,828 fg = fg,829 onToggle = { showConnection = !showConnection },830 )831 if (showConnection) {832 RowLine(fg) {833 Column(Modifier.weight(1f)) {834 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)835 Text(836 when {837 backgroundSyncEnabled && !notificationsAvailable ->838 "Action needed — Android notifications are off, so background sync is not visible."839 backgroundSyncEnabled ->840 "On — keeps this phone reachable for paired devices."841 else ->842 "Off — Peach syncs only while you are using the app."843 },844 color = fg.copy(alpha = 0.7f),845 fontFamily = PeachType.Body,846 fontSize = 11.sp,847 lineHeight = 16.sp,848 modifier = Modifier.padding(top = 2.dp),849 )850 }851 SubtleButton(852 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",853 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,854 ) { showBackgroundSyncPrompt = true }855 }856 SyncModeRow(857 title = "Public relay",858 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",859 selected = syncMode == SyncMode.PUBLIC,860 fg = fg,861 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }862 SyncModeRow(863 title = "Private relay",864 detail = "Use your own peach-relay on your own domain.",865 selected = syncMode == SyncMode.PRIVATE,866 fg = fg,867 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }868869 if (syncMode == SyncMode.PRIVATE) {870 Text(871 "RELAY URL",872 color = fg.copy(alpha = 0.6f),873 fontFamily = PeachType.Mono,874 fontSize = 8.sp,875 letterSpacing = 0.22.em,876 modifier = Modifier.padding(top = 8.dp),877 )878 Box(879 Modifier.fillMaxWidth().padding(top = 6.dp)880 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))881 .padding(horizontal = 12.dp, vertical = 10.dp),882 ) {883 BasicTextField(884 value = relayUrlDraft,885 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },886 singleLine = true,887 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),888 cursorBrush = SolidColor(fg),889 modifier = Modifier.fillMaxWidth(),890 )891 }892 val needsRelayUrl = configuredRelayUrl.isEmpty()893 RowLine(fg) {894 val message = relayUrlStatus895 ?: if (needsRelayUrl) {896 "Sync is paused until you enter your relay URL."897 } else {898 "Only secure wss:// relays are accepted."899 }900 val warn = relayUrlStatus?.startsWith("Use") == true ||901 (relayUrlStatus == null && needsRelayUrl)902 ValueText(message, if (warn) peachColors.Breach else fg)903 SubtleButton("save", fg) {904 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {905 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)906 "Saved"907 } else {908 "Use a valid wss:// relay URL"909 }910 }911 }912 }913914 SyncModeRow(915 title = "No sync",916 detail = "Keep this device separate and move backups by hand.",917 selected = syncMode == SyncMode.OFF,918 fg = fg,919 ) { syncVm.setSyncMode(SyncMode.OFF) }920 Hint("Pairing authorizes a device, not a cloud account.", fg)921 }922923 if (pendingVouched.isNotEmpty()) {924 SubLabel("PENDING DEVICES", fg)925 pendingVouched.forEach { dev ->926 RowLine(fg) {927 Column(Modifier.weight(1f)) {928 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)929 Text(930 "Was added by another device. Allow it to sync?",931 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,932 modifier = Modifier.padding(top = 2.dp),933 )934 }935 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {936 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }937 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }938 }939 }940 }941 }942943 SyncDisclosure(944 title = "Recovery & security",945 summary = "For a lost or stolen device",946 expanded = showRecovery,947 fg = fg,948 onToggle = { showRecovery = !showRecovery },949 )950 if (showRecovery) {951 RowLine(fg) {952 Column(Modifier.weight(1f)) {953 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)954 Text(955 "Scan only when this populated vault previously trusted the source device.",956 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,957 modifier = Modifier.padding(top = 2.dp),958 )959 }960 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }961 }962 if (rotateConfirmOpen) {963 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)964 rotateError?.let {965 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))966 }967 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {968 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }969 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {970 if (rotating) return@SubtleButton971 rotating = true972 rotateError = null973 scope.launch {974 val ok = syncVm.rotateSyncIdentity()975 rotating = false976 if (ok) {977 rotateConfirmOpen = false978 onNavigate(PeachNav.RecoverySetup)979 } else {980 rotateError = "Rotation failed. Unlock your real vault and try again."981 }982 }983 }984 }985 } else {986 RowLine(fg) {987 Column(Modifier.weight(1f)) {988 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)989 Text(990 "Disconnects every device and replaces the keys used for sync.",991 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,992 modifier = Modifier.padding(top = 2.dp),993 )994 }995 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }996 }997 }998 }9991000 if (showBackgroundSyncPrompt) {1001 BackgroundSyncDialog { enabled ->1002 showBackgroundSyncPrompt = false1003 notificationsAvailable = canShowBackgroundSyncNotification(context)1004 syncVm.setBackgroundSyncEnabled(enabled)1005 }1006 }1007}10081009private fun deviceTypeLabel(type: String?): String = when (type) {1010 "android" -> "Android"1011 "phone" -> "Phone"1012 "tablet" -> "Tablet"1013 "browser" -> "Browser"1014 else -> "Device"1015}10161017private fun syncTimeLabel(lastSyncedAt: Long?): String {1018 if (lastSyncedAt == null) return "never synced"1019 val diff = System.currentTimeMillis() - lastSyncedAt1020 if (diff < 60_000) return "just now"1021 if (diff < 3_600_000) return "${diff / 60_000}m ago"1022 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1023 return "${diff / 86_400_000}d ago"1024}10251026private fun isSyncStale(lastSyncedAt: Long?): Boolean =1027 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1028102910301031@Composable1032private fun ProBody(fg: Color, vm: SettingsViewModel) {1033 val ctx = LocalContext.current1034 val isPro by vm.isPro.collectAsState()1035 if (isPro) {1036 ProActiveBody(fg, vm)1037 return1038 }1039 val features = listOf(1040 "Peach Sync" to "End-to-end encrypted",1041 "Peach Codex" to "Restore from paper",1042 "Passkeys" to "Zero-knowledge WebAuthn",1043 "Advanced Vault Health" to "Reuse · age · exposure",1044 "Canary entries" to "Detects vault compromise",1045 "Duress vault" to "Decoy under coercion",1046 )1047 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1048 Text(1049 "Everything, everywhere.",1050 modifier = Modifier.padding(top = 8.dp),1051 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1052 )1053 Text(1054 "One key unlocks every Pro feature — for the lifetime of the app.",1055 modifier = Modifier.padding(top = 6.dp),1056 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1057 )1058 Spacer(Modifier.height(10.dp))1059 features.forEachIndexed { i, (name, desc) ->1060 1061 1062 1063 1064 RowLine(fg, verticalPadding = 6.dp) {1065 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1066 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1067 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1068 }1069 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1070 }1071 }1072 Spacer(Modifier.height(12.dp))1073 Text(1074 "$39",1075 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1076 )1077 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1078 Spacer(Modifier.height(10.dp))1079 Box(1080 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1081 .background(fg.copy(alpha = 0.16f))1082 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1083 .clickable {1084 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1085 }1086 .padding(vertical = 12.dp),1087 contentAlignment = Alignment.Center,1088 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10891090 1091 1092 1093 1094 Spacer(Modifier.height(44.dp))10951096 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1097 var key by remember { mutableStateOf("") }1098 val activating by vm.activating.collectAsState()1099 val activationError by vm.activationError.collectAsState()1100 Box(1101 Modifier.fillMaxWidth().padding(top = 6.dp)1102 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1103 .padding(horizontal = 12.dp, vertical = 10.dp),1104 ) {1105 BasicTextField(1106 value = key,1107 1108 1109 1110 1111 onValueChange = { key = it; vm.clearActivationError() },1112 singleLine = true,1113 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1114 cursorBrush = SolidColor(fg),1115 modifier = Modifier.fillMaxWidth(),1116 decorationBox = { inner ->1117 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1118 inner()1119 },1120 )1121 }1122 if (activationError != null) {1123 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1124 }1125 Spacer(Modifier.height(8.dp))1126 Box(1127 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1128 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1129 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1130 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1131 .padding(vertical = 12.dp),1132 contentAlignment = Alignment.Center,1133 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1134}11351136@Composable1137private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1138 val ctx = LocalContext.current1139 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1140 val licenseKey by vm.licenseKey.collectAsState()1141 val issuedAt by vm.licenseIssuedAt.collectAsState()1142 var showKey by remember { mutableStateOf(false) }1143 var copied by remember { mutableStateOf(false) }1144 val scope = rememberCoroutineScope()1145 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1146 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1147 }1148 Text(1149 "Peach Pro",1150 modifier = Modifier.fillMaxWidth(),1151 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1152 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1153 )1154 Text(1155 formatLicenseActiveSince(issuedAt),1156 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1157 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1158 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1159 )1160 licenseKey?.let { key ->1161 Spacer(Modifier.height(12.dp))1162 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1163 RowLine(fg) {1164 Text(1165 if (showKey) key else "************************",1166 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1167 fontFamily = PeachType.Mono,1168 fontSize = 10.sp,1169 modifier = Modifier.weight(1f),1170 )1171 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1172 SubtleButton(if (copied) "copied" else "copy", fg) {1173 PeachClipboard.copy(ctx, "peach license", key)1174 copied = true1175 scope.launch {1176 delay(1_500)1177 copied = false1178 }1179 }1180 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1181 }1182 }1183 }1184 Spacer(Modifier.height(12.dp))1185 features.forEachIndexed { i, name ->1186 RowLine(fg) {1187 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1188 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1189 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1190 }1191 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1192 }1193 }1194 Spacer(Modifier.height(12.dp))1195 Box(1196 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1197 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1198 .clickable { vm.deactivate() }1199 .padding(vertical = 12.dp),1200 contentAlignment = Alignment.Center,1201 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1202}12031204private fun formatLicenseActiveSince(issuedAt: Long?): String {1205 if (issuedAt == null) return "Active"1206 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1207 return "Active since $monthYear"1208}1209121012111212@OptIn(ExperimentalLayoutApi::class)1213@Composable1214private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1215 val categories = listOf("Bug report", "Feature request", "Question", "Other")1216 var category by remember { mutableStateOf(categories[0]) }1217 var message by remember { mutableStateOf("") }1218 val state by vm.feedbackState.collectAsState()12191220 1221 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }12221223 if (state is FeedbackState.Sent) {1224 Box(1225 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1226 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1227 ) {1228 Column {1229 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1230 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))1231 }1232 }1233 return1234 }12351236 val sending = state is FeedbackState.Sending12371238 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)1239 SubLabel("TYPE", fg)1240 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1241 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1242 }1243 SubLabel("MESSAGE", fg)1244 Box(1245 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1246 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1247 .padding(10.dp),1248 ) {1249 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1250 BasicTextField(1251 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1252 enabled = !sending,1253 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1254 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1255 )1256 }1257 Text(1258 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1259 modifier = Modifier.padding(top = 6.dp),1260 color = fg.copy(alpha = 0.52f),1261 fontFamily = PeachType.Mono,1262 fontSize = 10.sp,1263 lineHeight = 14.sp,1264 )1265 Spacer(Modifier.height(8.dp))12661267 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1268 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1269 Box(1270 Modifier.clip(RoundedCornerShape(8.dp))1271 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1272 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1273 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1274 .padding(horizontal = 12.dp, vertical = 6.dp),1275 ) {1276 Text(1277 feedbackSubmitLabel(state),1278 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1279 fontFamily = PeachType.Mono, fontSize = 11.sp,1280 )1281 }1282 if (sending) {1283 CircularProgressIndicator(1284 color = LocalPeachAccent.current,1285 strokeWidth = 2.dp,1286 modifier = Modifier.size(16.dp),1287 )1288 }1289 }12901291 (state as? FeedbackState.Error)?.let { err ->1292 Text(1293 err.message,1294 modifier = Modifier.padding(top = 8.dp),1295 color = peachColors.Breach,1296 fontFamily = PeachType.Mono,1297 fontSize = 11.sp,1298 lineHeight = 15.sp,1299 )1300 }13011302 Hint("Sent anonymously · no account required", fg)1303}1304130513061307@Composable1308internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1309 val configured by vm.duressConfigured.collectAsState()1310 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1311}13121313@Composable1314private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1315 Text(1316 "Duress password is active",1317 color = peachColors.HealthOk,1318 fontFamily = PeachType.Mono,1319 fontSize = 11.sp,1320 modifier = Modifier.padding(top = 6.dp),1321 )1322 Spacer(Modifier.height(8.dp))1323 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1324 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1325}13261327@Composable1328private fun DuressForm(fg: Color, vm: SettingsViewModel) {1329 var enabled by remember { mutableStateOf(false) }1330 var password by remember { mutableStateOf("") }1331 var confirm by remember { mutableStateOf("") }1332 var action by remember { mutableStateOf(DuressAction.DECOY) }1333 var error by remember { mutableStateOf<String?>(null) }1334 val scope = rememberCoroutineScope()13351336 RowLine(fg) {1337 Name("Duress password", fg)1338 Toggle(enabled, fg) { on ->1339 enabled = on1340 if (!on) { password = ""; confirm = ""; error = null }1341 }1342 }1343 Text(1344 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1345 color = fg.copy(alpha = 0.7f),1346 fontFamily = PeachType.Mono,1347 fontSize = 10.sp,1348 modifier = Modifier.padding(top = 6.dp),1349 )13501351 AnimatedVisibility(enabled) {1352 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1353 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1354 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13551356 Text(1357 "When your duress password is entered at the lock screen, Peach will:",1358 color = fg.copy(alpha = 0.7f),1359 fontFamily = PeachType.Mono,1360 fontSize = 10.sp,1361 modifier = Modifier.padding(top = 4.dp),1362 )13631364 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1365 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13661367 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13681369 val canSave = password.isNotBlank() && confirm.isNotBlank()1370 DuressSaveButton("Save duress password", fg, canSave) {1371 error = when {1372 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1373 password != confirm -> "Passwords do not match"1374 else -> null1375 }1376 if (error == null) scope.launch {1377 if (vm.saveDuress(password, action)) {1378 password = ""; confirm = ""; enabled = false1379 } else {1380 error = "Couldn't save — the duress passphrase must differ from your real password"1381 }1382 }1383 }1384 }1385 }1386}13871388@Composable1389private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1390 val accent = LocalPeachAccent.current1391 Row(1392 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1393 verticalAlignment = Alignment.CenterVertically,1394 horizontalArrangement = Arrangement.spacedBy(10.dp),1395 ) {1396 Box(1397 Modifier.size(16.dp).clip(CircleShape)1398 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1399 contentAlignment = Alignment.Center,1400 ) {1401 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1402 }1403 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1404 }1405}14061407@Composable1408private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1409 val accent = LocalPeachAccent.current1410 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1411 Box(1412 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1413 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1414 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1415 .let { if (enabled) it.clickable(onClick = onClick) else it }1416 .padding(vertical = 11.dp),1417 contentAlignment = Alignment.Center,1418 ) {1419 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1420 }1421}14221423@Composable1424private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1425 Box(1426 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1427 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1428 .clickable(onClick = onClick)1429 .padding(vertical = 11.dp),1430 contentAlignment = Alignment.Center,1431 ) {1432 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1433 }1434}143514361437@Composable1438private fun SyncModeRow(1439 title: String,1440 detail: String,1441 selected: Boolean,1442 fg: Color,1443 onSelect: () -> Unit,1444) {1445 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1446 Column(Modifier.weight(1f)) {1447 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1448 Text(1449 detail,1450 color = fg.copy(alpha = 0.7f),1451 fontFamily = PeachType.Body,1452 fontSize = 11.sp,1453 lineHeight = 16.sp,1454 modifier = Modifier.padding(top = 2.dp),1455 )1456 }1457 Text(1458 if (selected) "●" else "○",1459 color = if (selected) fg else fg.copy(alpha = 0.45f),1460 fontFamily = PeachType.Mono,1461 fontSize = 14.sp,1462 modifier = Modifier.padding(start = 10.dp),1463 )1464 }1465}