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("MOTION", fg)280 RowLine(fg) {281 Name("Reduce motion", fg)282 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }283 }284}285286287288@Composable289private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {290 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")291 val current = s.autoLockMinutes ?: 0292293 SubLabel("LOCKING", fg, top = 0.dp)294 RowLine(fg) {295 Name("Allow screenshots", fg)296 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }297 }298 RowLine(fg) {299 Name("Auto-lock after", fg)300 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)301 SubtleButton(lockOptions[idx].second, fg) {302 val next = lockOptions[(idx + 1) % lockOptions.size].first303 vm.setAutoLockMinutes(if (next == 0) 0 else next)304 }305 }306 SubLabel("BIOMETRICS", fg)307 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity308 RowLine(fg) {309 Name("Unlock with biometrics", fg)310 Toggle(s.biometricsEnabled, fg) { on ->311 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()312 }313 }314 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)315 if (bioError != null) {316 Text(317 bioError,318 color = com.peachpasswords.ui.theme.peachColors.Breach,319 fontFamily = PeachType.Mono, fontSize = 10.sp,320 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),321 )322 }323 SubLabel("AUTOFILL", fg)324 run {325 326 327 val ctx = LocalContext.current328 val enabled = AutofillEnablement.isEnabled(ctx)329 RowLine(fg) {330 Column(Modifier.weight(1f)) {331 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)332 Text(333 if (enabled) {334 "Peach is your autofill service."335 } else {336 "Android decides which app fills passwords. Turn Peach on in system settings."337 },338 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,339 modifier = Modifier.padding(top = 2.dp),340 )341 }342 if (!enabled) {343 SubtleButton("enable", fg) {344 345 346 347 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }348 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }349 }350 } else {351 ValueText("on", fg)352 }353 }354 }355356 SubLabel("PASSKEYS", fg)357 RowLine(fg) {358 Column(Modifier.weight(1f)) {359 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)360 Text(361 "Peach can handle passkey sign-in and creation through Android Credential Manager.",362 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,363 modifier = Modifier.padding(top = 2.dp),364 )365 }366 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }367 }368 RowLine(fg) {369 Name("Status", fg)370 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)371 }372 SubLabel("RECOVERY", fg)373 RowLine(fg) {374 Column(Modifier.weight(1f)) {375 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)376 Text(377 "Keep the paper copy current before rotating sync identity or replacing devices.",378 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,379 modifier = Modifier.padding(top = 2.dp),380 )381 }382 SubtleButton("view", fg) {383 if (bioActivity != null) {384 com.peachpasswords.ui.lock.Biometrics.authenticate(385 activity = bioActivity,386 title = "View recovery words",387 subtitle = "Confirm your identity to view your recovery words",388 onSuccess = { onNavigate(PeachNav.RecoveryView) },389 )390 } else onNavigate(PeachNav.RecoveryView)391 }392 }393 SubLabel("BREACH PROTECTION", fg)394 val checkingBreaches by vm.checkingBreaches.collectAsState()395 val breachResult by vm.breachResult.collectAsState()396 RowLine(fg) {397 Name("Breach monitoring", fg)398 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }399 }400 RowLine(fg) {401 Name("Check all passwords", fg)402 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }403 }404 breachResult?.let { result ->405 RowLine(fg) {406 Name("Last check", fg)407 ValueText(result, fg)408 }409 }410411 SubLabel("MASTER PASSWORD", fg)412 ChangePasswordRow(fg, vm)413414 SubLabel("DURESS PASSWORD", fg)415 val duressIsPro by vm.isPro.collectAsState()416 if (!duressIsPro) {417 RowLine(fg) {418 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {419 Name("Duress password", fg); ProBadge(fg)420 }421 ValueText("off", fg)422 }423 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)424 } else {425 DuressSection(fg, vm)426 }427}428429@Composable430private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {431 var open by remember { mutableStateOf(false) }432 var current by remember { mutableStateOf("") }433 var next by remember { mutableStateOf("") }434 var confirm by remember { mutableStateOf("") }435 var error by remember { mutableStateOf<String?>(null) }436 var done by remember { mutableStateOf(false) }437 val scope = rememberCoroutineScope()438439 RowLine(fg) {440 Name("Master password", fg)441 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }442 }443 AnimatedVisibility(open) {444 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {445 PwField("current password", current, fg) { current = it }446 PwField("new password (min 12 chars)", next, fg) { next = it }447 PwField("confirm new password", confirm, fg) { confirm = it }448 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }449 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)450 SubtleButton("save new password", fg) {451 error = when {452 current.isBlank() -> "enter your current password"453 next.length < 12 -> "new password must be at least 12 characters"454 next != confirm -> "new passwords do not match"455 next == current -> "new password must differ from current"456 else -> null457 }458 if (error == null) scope.launch {459 if (vm.changePassword(current, next)) {460 current = ""; next = ""; confirm = ""; open = false; done = true461 } else error = "incorrect current password"462 }463 }464 }465 }466}467468@Composable469private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {470 PeachField(471 label = placeholder,472 value = value,473 onValueChange = onChange,474 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,475 visualTransformation = PasswordVisualTransformation(),476 )477}478479480481@Composable482private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {483 val vm: UpdateViewModel = hiltViewModel()484 val available by vm.available.collectAsState()485 val state by vm.state.collectAsState()486487 RowLine(fg) {488 Name("Version", fg)489 ValueText(BuildConfig.VERSION_NAME, fg)490 }491 RowLine(fg) {492 Column(Modifier.weight(1f)) {493 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)494 Text(495 when {496 available != null -> "v${available?.version} is available"497 state.phase == UpdatePhase.Checking -> "Checking…"498 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"499 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"500 else -> "Peach checks once a day and tells you when there's a new version."501 },502 color = fg.copy(alpha = 0.7f),503 fontFamily = PeachType.Body,504 fontSize = 11.sp,505 lineHeight = 16.sp,506 modifier = Modifier.padding(top = 2.dp),507 )508 }509 if (available != null) {510 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }511 } else {512 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {513 vm.check()514 }515 }516 }517}518519@Composable520private fun VaultBody(521 fg: Color,522 vm: SettingsViewModel,523 s: VaultSettings,524 entriesCount: Int,525 onNavigate: (PeachNav) -> Unit,526) {527 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")528 val current = s.trashRetentionDays ?: 0529 val isPro by vm.isPro.collectAsState()530 val dedupCount by vm.dedupCount.collectAsState()531 val dedupRunning by vm.dedupRunning.collectAsState()532 val dedupDone by vm.dedupDone.collectAsState()533 val history by vm.vaultHistory.collectAsState()534 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()535536 SubLabel("ENTRIES", fg, top = 0.dp)537 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }538 RowLine(fg) {539 Name("Duplicates", fg)540 val done = dedupDone541 if (done != null) {542 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)543 } else {544 val count = dedupCount545 val label = when {546 dedupRunning -> "scanning..."547 count == null -> "scan"548 count == 0 -> "none found"549 else -> "$count found"550 }551 SubtleButton(label, fg) {552 if (count == null || count == 0) vm.scanDuplicates()553 }554 }555 }556 if ((dedupCount ?: 0) > 0 && dedupDone == null) {557 Column(558 Modifier.fillMaxWidth()559 .padding(top = 2.dp, bottom = 8.dp)560 .clip(RoundedCornerShape(8.dp))561 .background(fg.copy(alpha = 0.08f))562 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))563 .padding(10.dp),564 verticalArrangement = Arrangement.spacedBy(8.dp),565 ) {566 val count = dedupCount ?: 0567 Text(568 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",569 color = fg.copy(alpha = 0.82f),570 fontFamily = PeachType.Body,571 fontSize = 11.sp,572 lineHeight = 16.sp,573 )574 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {575 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }576 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }577 }578 }579 }580581 SubLabel("MAINTENANCE", fg)582 RowLine(fg) {583 Name("Trash auto-delete", fg)584 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)585 SubtleButton(trashOptions[idx].second, fg) {586 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)587 }588 }589 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }590 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }591592 SubLabel("DATA", fg)593 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }594 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }595 RowLine(fg) {596 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }597 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }598 }599 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }600601 var historyShowAll by remember { mutableStateOf(false) }602 SubLabel("UPDATES", fg)603 UpdatesRows(fg, onNavigate)604605 SubLabel("VAULT HISTORY", fg)606 if (history.isEmpty()) {607 Text(608 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",609 color = fg.copy(alpha = 0.68f),610 fontFamily = PeachType.Body,611 fontSize = 11.sp,612 lineHeight = 16.sp,613 modifier = Modifier.padding(bottom = 6.dp),614 )615 } else {616 Text(617 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",618 color = fg.copy(alpha = 0.68f),619 fontFamily = PeachType.Body,620 fontSize = 11.sp,621 lineHeight = 16.sp,622 modifier = Modifier.padding(bottom = 4.dp),623 )624 history.take(if (historyShowAll) history.size else 3).forEach { snap ->625 RowLine(fg) {626 Column(Modifier.weight(1f)) {627 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)628 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)629 }630 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {631 vm.restoreVaultSnapshot(snap.id)632 }633 }634 }635 if (history.size > 3) {636 RowLine(fg) {637 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)638 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }639 }640 }641 Text(642 "${history.size} of 5 snapshots",643 color = fg.copy(alpha = 0.58f),644 fontFamily = PeachType.Mono,645 fontSize = 10.sp,646 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),647 )648 }649650 var advancedOpen by remember { mutableStateOf(false) }651 SubLabel("ADVANCED", fg)652 RowLine(fg) {653 Name("Advanced", fg)654 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }655 }656 if (advancedOpen) {657 RowLine(fg) {658 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)659 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }660 }661 RowLine(fg) {662 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)663 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }664 }665 }666}667668private fun snapshotSubLabel(snapshot: VaultSnapshot): String {669 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }670 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")671}672673private fun snapshotReasonLabel(reason: String): String = when (reason) {674 "local_save" -> "saved"675 "sync_received" -> "synced"676 "import" -> "imported"677 "restore" -> "restored"678 else -> reason679}680681private fun snapshotTimeLabel(savedAt: Long): String {682 val diff = System.currentTimeMillis() - savedAt683 if (diff < 60_000) return "Just now"684 if (diff < 3_600_000) return "${diff / 60_000}m ago"685 val date = Date(savedAt)686 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())687 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)688 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)689 if (today == thenDay) return "Today $time"690 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))691 if (yesterday == thenDay) return "Yesterday $time"692 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)693}694695696697@Composable698private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {699 val context = LocalContext.current700 val syncVm: SyncViewModel = hiltViewModel()701 val pairedDevices by syncVm.pairedDevices.collectAsState()702 val pendingVouched by syncVm.pendingVouched.collectAsState()703 val syncStatus by syncVm.status.collectAsState()704 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()705 val syncMode by syncVm.syncMode.collectAsState()706 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()707 var notificationsAvailable by remember {708 mutableStateOf(canShowBackgroundSyncNotification(context))709 }710 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }711 val scope = rememberCoroutineScope()712 var confirmUnpair by remember { mutableStateOf<String?>(null) }713 var rotateConfirmOpen by remember { mutableStateOf(false) }714 var rotating by remember { mutableStateOf(false) }715 var rotateError by remember { mutableStateOf<String?>(null) }716 var relayUrlDraft by remember(configuredRelayUrl) {717 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))718 }719 var relayUrlStatus by remember { mutableStateOf<String?>(null) }720 var showConnection by remember { mutableStateOf(false) }721 var showRecovery by remember { mutableStateOf(false) }722723 SubLabel("ADD A DEVICE", fg, top = 0.dp)724 RowLine(fg) {725 Column(Modifier.weight(1f)) {726 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)727 Text(728 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",729 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,730 modifier = Modifier.padding(top = 2.dp),731 )732 }733 734 735 736 737 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }738 Spacer(Modifier.width(PeachSpacing.xs))739 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }740 }741742 SubLabel("SYNC STATUS", fg)743 val indicator = syncIndicator(syncStatus, pairedDevices.size)744 RowLine(fg) {745 SyncIndicatorDot(746 indicator = indicator,747 label = syncIndicatorLabel(indicator, syncStatus),748 modifier = Modifier.padding(end = PeachSpacing.sm),749 size = 8.dp,750 )751 Text(752 describeSyncOutcome(syncStatus),753 color = fg.copy(alpha = 0.7f),754 fontFamily = PeachType.Body,755 fontSize = 11.sp,756 lineHeight = 16.sp,757 modifier = Modifier.weight(1f),758 )759 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {760 if (!syncStatus.syncing) syncVm.syncNow()761 }762 }763764 SubLabel("PAIRED DEVICES", fg)765 if (pairedDevices.isEmpty()) {766 PeachEmptyOrEndState(767 message = "No paired devices yet.",768 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",769 modifier = Modifier.padding(top = PeachSpacing.xs),770 )771 } else {772 pairedDevices.forEach { d ->773 RowLine(fg) {774 Column(Modifier.weight(1f)) {775 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)776 Text(777 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",778 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,779 modifier = Modifier.padding(top = 2.dp),780 )781 }782 if (confirmUnpair != d.deviceId) {783 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }784 }785 }786 787 788 789 790 791 if (confirmUnpair == d.deviceId) {792 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {793 Text(794 "Stops this device receiving future changes. It doesn't lock it " +795 "out of what it already has - if it's lost or stolen, also " +796 "change your master password and generate new recovery words.",797 color = fg.copy(alpha = 0.7f),798 fontFamily = PeachType.Body,799 fontSize = 11.sp,800 lineHeight = 16.sp,801 )802 Row(803 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),804 horizontalArrangement = Arrangement.End,805 ) {806 SubtleButton("cancel", fg) { confirmUnpair = null }807 Spacer(Modifier.width(PeachSpacing.sm))808 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }809 }810 }811 }812 }813 }814815 SyncDisclosure(816 title = "Connection",817 summary = "Background sync, relay, and privacy choices",818 expanded = showConnection,819 fg = fg,820 onToggle = { showConnection = !showConnection },821 )822 if (showConnection) {823 RowLine(fg) {824 Column(Modifier.weight(1f)) {825 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)826 Text(827 when {828 backgroundSyncEnabled && !notificationsAvailable ->829 "Action needed — Android notifications are off, so background sync is not visible."830 backgroundSyncEnabled ->831 "On — keeps this phone reachable for paired devices."832 else ->833 "Off — Peach syncs only while you are using the app."834 },835 color = fg.copy(alpha = 0.7f),836 fontFamily = PeachType.Body,837 fontSize = 11.sp,838 lineHeight = 16.sp,839 modifier = Modifier.padding(top = 2.dp),840 )841 }842 SubtleButton(843 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",844 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,845 ) { showBackgroundSyncPrompt = true }846 }847 SyncModeRow(848 title = "Public relay",849 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",850 selected = syncMode == SyncMode.PUBLIC,851 fg = fg,852 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }853 SyncModeRow(854 title = "Private relay",855 detail = "Use your own peach-relay on your own domain.",856 selected = syncMode == SyncMode.PRIVATE,857 fg = fg,858 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }859860 if (syncMode == SyncMode.PRIVATE) {861 Text(862 "RELAY URL",863 color = fg.copy(alpha = 0.6f),864 fontFamily = PeachType.Mono,865 fontSize = 8.sp,866 letterSpacing = 0.22.em,867 modifier = Modifier.padding(top = 8.dp),868 )869 Box(870 Modifier.fillMaxWidth().padding(top = 6.dp)871 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))872 .padding(horizontal = 12.dp, vertical = 10.dp),873 ) {874 BasicTextField(875 value = relayUrlDraft,876 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },877 singleLine = true,878 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),879 cursorBrush = SolidColor(fg),880 modifier = Modifier.fillMaxWidth(),881 )882 }883 val needsRelayUrl = configuredRelayUrl.isEmpty()884 RowLine(fg) {885 val message = relayUrlStatus886 ?: if (needsRelayUrl) {887 "Sync is paused until you enter your relay URL."888 } else {889 "Only secure wss:// relays are accepted."890 }891 val warn = relayUrlStatus?.startsWith("Use") == true ||892 (relayUrlStatus == null && needsRelayUrl)893 ValueText(message, if (warn) peachColors.Breach else fg)894 SubtleButton("save", fg) {895 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {896 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)897 "Saved"898 } else {899 "Use a valid wss:// relay URL"900 }901 }902 }903 }904905 SyncModeRow(906 title = "No sync",907 detail = "Keep this device separate and move backups by hand.",908 selected = syncMode == SyncMode.OFF,909 fg = fg,910 ) { syncVm.setSyncMode(SyncMode.OFF) }911 Hint("Pairing authorizes a device, not a cloud account.", fg)912 }913914 if (pendingVouched.isNotEmpty()) {915 SubLabel("PENDING DEVICES", fg)916 pendingVouched.forEach { dev ->917 RowLine(fg) {918 Column(Modifier.weight(1f)) {919 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)920 Text(921 "Was added by another device. Allow it to sync?",922 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,923 modifier = Modifier.padding(top = 2.dp),924 )925 }926 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {927 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }928 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }929 }930 }931 }932 }933934 SyncDisclosure(935 title = "Recovery & security",936 summary = "For a lost or stolen device",937 expanded = showRecovery,938 fg = fg,939 onToggle = { showRecovery = !showRecovery },940 )941 if (showRecovery) {942 RowLine(fg) {943 Column(Modifier.weight(1f)) {944 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)945 Text(946 "Scan only when this populated vault previously trusted the source device.",947 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,948 modifier = Modifier.padding(top = 2.dp),949 )950 }951 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }952 }953 if (rotateConfirmOpen) {954 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)955 rotateError?.let {956 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))957 }958 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {959 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }960 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {961 if (rotating) return@SubtleButton962 rotating = true963 rotateError = null964 scope.launch {965 val ok = syncVm.rotateSyncIdentity()966 rotating = false967 if (ok) {968 rotateConfirmOpen = false969 onNavigate(PeachNav.RecoverySetup)970 } else {971 rotateError = "Rotation failed. Unlock your real vault and try again."972 }973 }974 }975 }976 } else {977 RowLine(fg) {978 Column(Modifier.weight(1f)) {979 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)980 Text(981 "Disconnects every device and replaces the keys used for sync.",982 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,983 modifier = Modifier.padding(top = 2.dp),984 )985 }986 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }987 }988 }989 }990991 if (showBackgroundSyncPrompt) {992 BackgroundSyncDialog { enabled ->993 showBackgroundSyncPrompt = false994 notificationsAvailable = canShowBackgroundSyncNotification(context)995 syncVm.setBackgroundSyncEnabled(enabled)996 }997 }998}9991000private fun deviceTypeLabel(type: String?): String = when (type) {1001 "android" -> "Android"1002 "phone" -> "Phone"1003 "tablet" -> "Tablet"1004 "browser" -> "Browser"1005 else -> "Device"1006}10071008private fun syncTimeLabel(lastSyncedAt: Long?): String {1009 if (lastSyncedAt == null) return "never synced"1010 val diff = System.currentTimeMillis() - lastSyncedAt1011 if (diff < 60_000) return "just now"1012 if (diff < 3_600_000) return "${diff / 60_000}m ago"1013 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1014 return "${diff / 86_400_000}d ago"1015}10161017private fun isSyncStale(lastSyncedAt: Long?): Boolean =1018 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1019102010211022@Composable1023private fun ProBody(fg: Color, vm: SettingsViewModel) {1024 val ctx = LocalContext.current1025 val isPro by vm.isPro.collectAsState()1026 if (isPro) {1027 ProActiveBody(fg, vm)1028 return1029 }1030 val features = listOf(1031 "Peach Sync" to "End-to-end encrypted",1032 "Peach Codex" to "Restore from paper",1033 "Passkeys" to "Zero-knowledge WebAuthn",1034 "Advanced Vault Health" to "Reuse · age · exposure",1035 "Canary entries" to "Detects vault compromise",1036 "Duress vault" to "Decoy under coercion",1037 )1038 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1039 Text(1040 "Everything, everywhere.",1041 modifier = Modifier.padding(top = 8.dp),1042 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1043 )1044 Text(1045 "One key unlocks every Pro feature — for the lifetime of the app.",1046 modifier = Modifier.padding(top = 6.dp),1047 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1048 )1049 Spacer(Modifier.height(10.dp))1050 features.forEachIndexed { i, (name, desc) ->1051 1052 1053 1054 1055 RowLine(fg, verticalPadding = 6.dp) {1056 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1057 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1058 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1059 }1060 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1061 }1062 }1063 Spacer(Modifier.height(12.dp))1064 Text(1065 "$39",1066 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1067 )1068 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1069 Spacer(Modifier.height(10.dp))1070 Box(1071 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1072 .background(fg.copy(alpha = 0.16f))1073 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1074 .clickable {1075 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1076 }1077 .padding(vertical = 12.dp),1078 contentAlignment = Alignment.Center,1079 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10801081 1082 1083 1084 1085 Spacer(Modifier.height(44.dp))10861087 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1088 var key by remember { mutableStateOf("") }1089 val activating by vm.activating.collectAsState()1090 val activationError by vm.activationError.collectAsState()1091 Box(1092 Modifier.fillMaxWidth().padding(top = 6.dp)1093 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1094 .padding(horizontal = 12.dp, vertical = 10.dp),1095 ) {1096 BasicTextField(1097 value = key,1098 1099 1100 1101 1102 onValueChange = { key = it; vm.clearActivationError() },1103 singleLine = true,1104 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1105 cursorBrush = SolidColor(fg),1106 modifier = Modifier.fillMaxWidth(),1107 decorationBox = { inner ->1108 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1109 inner()1110 },1111 )1112 }1113 if (activationError != null) {1114 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1115 }1116 Spacer(Modifier.height(8.dp))1117 Box(1118 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1119 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1120 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1121 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1122 .padding(vertical = 12.dp),1123 contentAlignment = Alignment.Center,1124 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1125}11261127@Composable1128private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1129 val ctx = LocalContext.current1130 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1131 val licenseKey by vm.licenseKey.collectAsState()1132 val issuedAt by vm.licenseIssuedAt.collectAsState()1133 var showKey by remember { mutableStateOf(false) }1134 var copied by remember { mutableStateOf(false) }1135 val scope = rememberCoroutineScope()1136 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1137 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1138 }1139 Text(1140 "Peach Pro",1141 modifier = Modifier.fillMaxWidth(),1142 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1143 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1144 )1145 Text(1146 formatLicenseActiveSince(issuedAt),1147 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1148 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1149 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1150 )1151 licenseKey?.let { key ->1152 Spacer(Modifier.height(12.dp))1153 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1154 RowLine(fg) {1155 Text(1156 if (showKey) key else "************************",1157 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1158 fontFamily = PeachType.Mono,1159 fontSize = 10.sp,1160 modifier = Modifier.weight(1f),1161 )1162 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1163 SubtleButton(if (copied) "copied" else "copy", fg) {1164 PeachClipboard.copy(ctx, "peach license", key)1165 copied = true1166 scope.launch {1167 delay(1_500)1168 copied = false1169 }1170 }1171 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1172 }1173 }1174 }1175 Spacer(Modifier.height(12.dp))1176 features.forEachIndexed { i, name ->1177 RowLine(fg) {1178 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1179 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1180 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1181 }1182 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1183 }1184 }1185 Spacer(Modifier.height(12.dp))1186 Box(1187 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1188 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1189 .clickable { vm.deactivate() }1190 .padding(vertical = 12.dp),1191 contentAlignment = Alignment.Center,1192 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1193}11941195private fun formatLicenseActiveSince(issuedAt: Long?): String {1196 if (issuedAt == null) return "Active"1197 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1198 return "Active since $monthYear"1199}1200120112021203@OptIn(ExperimentalLayoutApi::class)1204@Composable1205private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1206 val categories = listOf("Bug report", "Feature request", "Question", "Other")1207 var category by remember { mutableStateOf(categories[0]) }1208 var message by remember { mutableStateOf("") }1209 val state by vm.feedbackState.collectAsState()12101211 1212 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }12131214 if (state is FeedbackState.Sent) {1215 Box(1216 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1217 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1218 ) {1219 Column {1220 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1221 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))1222 }1223 }1224 return1225 }12261227 val sending = state is FeedbackState.Sending12281229 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)1230 SubLabel("TYPE", fg)1231 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1232 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1233 }1234 SubLabel("MESSAGE", fg)1235 Box(1236 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1237 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1238 .padding(10.dp),1239 ) {1240 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1241 BasicTextField(1242 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1243 enabled = !sending,1244 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1245 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1246 )1247 }1248 Text(1249 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1250 modifier = Modifier.padding(top = 6.dp),1251 color = fg.copy(alpha = 0.52f),1252 fontFamily = PeachType.Mono,1253 fontSize = 10.sp,1254 lineHeight = 14.sp,1255 )1256 Spacer(Modifier.height(8.dp))12571258 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1259 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1260 Box(1261 Modifier.clip(RoundedCornerShape(8.dp))1262 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1263 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1264 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1265 .padding(horizontal = 12.dp, vertical = 6.dp),1266 ) {1267 Text(1268 feedbackSubmitLabel(state),1269 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1270 fontFamily = PeachType.Mono, fontSize = 11.sp,1271 )1272 }1273 if (sending) {1274 CircularProgressIndicator(1275 color = LocalPeachAccent.current,1276 strokeWidth = 2.dp,1277 modifier = Modifier.size(16.dp),1278 )1279 }1280 }12811282 (state as? FeedbackState.Error)?.let { err ->1283 Text(1284 err.message,1285 modifier = Modifier.padding(top = 8.dp),1286 color = peachColors.Breach,1287 fontFamily = PeachType.Mono,1288 fontSize = 11.sp,1289 lineHeight = 15.sp,1290 )1291 }12921293 Hint("Sent anonymously · no account required", fg)1294}1295129612971298@Composable1299internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1300 val configured by vm.duressConfigured.collectAsState()1301 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1302}13031304@Composable1305private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1306 Text(1307 "Duress password is active",1308 color = peachColors.HealthOk,1309 fontFamily = PeachType.Mono,1310 fontSize = 11.sp,1311 modifier = Modifier.padding(top = 6.dp),1312 )1313 Spacer(Modifier.height(8.dp))1314 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1315 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1316}13171318@Composable1319private fun DuressForm(fg: Color, vm: SettingsViewModel) {1320 var enabled by remember { mutableStateOf(false) }1321 var password by remember { mutableStateOf("") }1322 var confirm by remember { mutableStateOf("") }1323 var action by remember { mutableStateOf(DuressAction.DECOY) }1324 var error by remember { mutableStateOf<String?>(null) }1325 val scope = rememberCoroutineScope()13261327 RowLine(fg) {1328 Name("Duress password", fg)1329 Toggle(enabled, fg) { on ->1330 enabled = on1331 if (!on) { password = ""; confirm = ""; error = null }1332 }1333 }1334 Text(1335 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1336 color = fg.copy(alpha = 0.7f),1337 fontFamily = PeachType.Mono,1338 fontSize = 10.sp,1339 modifier = Modifier.padding(top = 6.dp),1340 )13411342 AnimatedVisibility(enabled) {1343 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1344 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1345 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13461347 Text(1348 "When your duress password is entered at the lock screen, Peach will:",1349 color = fg.copy(alpha = 0.7f),1350 fontFamily = PeachType.Mono,1351 fontSize = 10.sp,1352 modifier = Modifier.padding(top = 4.dp),1353 )13541355 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1356 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13571358 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13591360 val canSave = password.isNotBlank() && confirm.isNotBlank()1361 DuressSaveButton("Save duress password", fg, canSave) {1362 error = when {1363 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1364 password != confirm -> "Passwords do not match"1365 else -> null1366 }1367 if (error == null) scope.launch {1368 if (vm.saveDuress(password, action)) {1369 password = ""; confirm = ""; enabled = false1370 } else {1371 error = "Couldn't save — the duress passphrase must differ from your real password"1372 }1373 }1374 }1375 }1376 }1377}13781379@Composable1380private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1381 val accent = LocalPeachAccent.current1382 Row(1383 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1384 verticalAlignment = Alignment.CenterVertically,1385 horizontalArrangement = Arrangement.spacedBy(10.dp),1386 ) {1387 Box(1388 Modifier.size(16.dp).clip(CircleShape)1389 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1390 contentAlignment = Alignment.Center,1391 ) {1392 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1393 }1394 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1395 }1396}13971398@Composable1399private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1400 val accent = LocalPeachAccent.current1401 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1402 Box(1403 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1404 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1405 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1406 .let { if (enabled) it.clickable(onClick = onClick) else it }1407 .padding(vertical = 11.dp),1408 contentAlignment = Alignment.Center,1409 ) {1410 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1411 }1412}14131414@Composable1415private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1416 Box(1417 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1418 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1419 .clickable(onClick = onClick)1420 .padding(vertical = 11.dp),1421 contentAlignment = Alignment.Center,1422 ) {1423 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1424 }1425}142614271428@Composable1429private fun SyncModeRow(1430 title: String,1431 detail: String,1432 selected: Boolean,1433 fg: Color,1434 onSelect: () -> Unit,1435) {1436 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1437 Column(Modifier.weight(1f)) {1438 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1439 Text(1440 detail,1441 color = fg.copy(alpha = 0.7f),1442 fontFamily = PeachType.Body,1443 fontSize = 11.sp,1444 lineHeight = 16.sp,1445 modifier = Modifier.padding(top = 2.dp),1446 )1447 }1448 Text(1449 if (selected) "●" else "○",1450 color = if (selected) fg else fg.copy(alpha = 0.45f),1451 fontFamily = PeachType.Mono,1452 fontSize = 14.sp,1453 modifier = Modifier.padding(start = 10.dp),1454 )1455 }1456}