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("Auto-lock after", fg)296 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)297 SubtleButton(lockOptions[idx].second, fg) {298 val next = lockOptions[(idx + 1) % lockOptions.size].first299 vm.setAutoLockMinutes(if (next == 0) 0 else next)300 }301 }302 SubLabel("BIOMETRICS", fg)303 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity304 RowLine(fg) {305 Name("Unlock with biometrics", fg)306 Toggle(s.biometricsEnabled, fg) { on ->307 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()308 }309 }310 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)311 if (bioError != null) {312 Text(313 bioError,314 color = com.peachpasswords.ui.theme.peachColors.Breach,315 fontFamily = PeachType.Mono, fontSize = 10.sp,316 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),317 )318 }319 SubLabel("AUTOFILL", fg)320 run {321 322 323 val ctx = LocalContext.current324 val enabled = AutofillEnablement.isEnabled(ctx)325 RowLine(fg) {326 Column(Modifier.weight(1f)) {327 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)328 Text(329 if (enabled) {330 "Peach is your autofill service."331 } else {332 "Android decides which app fills passwords. Turn Peach on in system settings."333 },334 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,335 modifier = Modifier.padding(top = 2.dp),336 )337 }338 if (!enabled) {339 SubtleButton("enable", fg) {340 341 342 343 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }344 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }345 }346 } else {347 ValueText("on", fg)348 }349 }350 }351352 SubLabel("PASSKEYS", fg)353 RowLine(fg) {354 Column(Modifier.weight(1f)) {355 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)356 Text(357 "Peach can handle passkey sign-in and creation through Android Credential Manager.",358 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,359 modifier = Modifier.padding(top = 2.dp),360 )361 }362 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }363 }364 RowLine(fg) {365 Name("Status", fg)366 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)367 }368 SubLabel("RECOVERY", fg)369 RowLine(fg) {370 Column(Modifier.weight(1f)) {371 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)372 Text(373 "Keep the paper copy current before rotating sync identity or replacing devices.",374 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,375 modifier = Modifier.padding(top = 2.dp),376 )377 }378 SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }379 }380 SubLabel("BREACH PROTECTION", fg)381 val checkingBreaches by vm.checkingBreaches.collectAsState()382 val breachResult by vm.breachResult.collectAsState()383 RowLine(fg) {384 Name("Breach monitoring", fg)385 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }386 }387 RowLine(fg) {388 Name("Check all passwords", fg)389 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }390 }391 breachResult?.let { result ->392 RowLine(fg) {393 Name("Last check", fg)394 ValueText(result, fg)395 }396 }397398 SubLabel("MASTER PASSWORD", fg)399 ChangePasswordRow(fg, vm)400401 SubLabel("DURESS PASSWORD", fg)402 val duressIsPro by vm.isPro.collectAsState()403 if (!duressIsPro) {404 RowLine(fg) {405 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {406 Name("Duress password", fg); ProBadge(fg)407 }408 ValueText("off", fg)409 }410 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)411 } else {412 DuressSection(fg, vm)413 }414}415416@Composable417private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {418 var open by remember { mutableStateOf(false) }419 var current by remember { mutableStateOf("") }420 var next by remember { mutableStateOf("") }421 var confirm by remember { mutableStateOf("") }422 var error by remember { mutableStateOf<String?>(null) }423 var done by remember { mutableStateOf(false) }424 val scope = rememberCoroutineScope()425426 RowLine(fg) {427 Name("Master password", fg)428 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }429 }430 AnimatedVisibility(open) {431 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {432 PwField("current password", current, fg) { current = it }433 PwField("new password (min 12 chars)", next, fg) { next = it }434 PwField("confirm new password", confirm, fg) { confirm = it }435 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }436 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)437 SubtleButton("save new password", fg) {438 error = when {439 current.isBlank() -> "enter your current password"440 next.length < 12 -> "new password must be at least 12 characters"441 next != confirm -> "new passwords do not match"442 next == current -> "new password must differ from current"443 else -> null444 }445 if (error == null) scope.launch {446 if (vm.changePassword(current, next)) {447 current = ""; next = ""; confirm = ""; open = false; done = true448 } else error = "incorrect current password"449 }450 }451 }452 }453}454455@Composable456private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {457 PeachField(458 label = placeholder,459 value = value,460 onValueChange = onChange,461 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,462 visualTransformation = PasswordVisualTransformation(),463 )464}465466467468@Composable469private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {470 val vm: UpdateViewModel = hiltViewModel()471 val available by vm.available.collectAsState()472 val state by vm.state.collectAsState()473474 RowLine(fg) {475 Name("Version", fg)476 ValueText(BuildConfig.VERSION_NAME, fg)477 }478 RowLine(fg) {479 Column(Modifier.weight(1f)) {480 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)481 Text(482 when {483 available != null -> "v${'$'}{available?.version} is available"484 state.phase == UpdatePhase.Checking -> "Checking…"485 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"486 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"487 else -> "Peach checks once a day and tells you when there's a new version."488 },489 color = fg.copy(alpha = 0.7f),490 fontFamily = PeachType.Body,491 fontSize = 11.sp,492 lineHeight = 16.sp,493 modifier = Modifier.padding(top = 2.dp),494 )495 }496 if (available != null) {497 SubtleButton("update", fg) { onNavigate(PeachNav.Update) }498 } else {499 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {500 vm.check()501 }502 }503 }504}505506@Composable507private fun VaultBody(508 fg: Color,509 vm: SettingsViewModel,510 s: VaultSettings,511 entriesCount: Int,512 onNavigate: (PeachNav) -> Unit,513) {514 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")515 val current = s.trashRetentionDays ?: 0516 val isPro by vm.isPro.collectAsState()517 val dedupCount by vm.dedupCount.collectAsState()518 val dedupRunning by vm.dedupRunning.collectAsState()519 val dedupDone by vm.dedupDone.collectAsState()520 val history by vm.vaultHistory.collectAsState()521 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()522523 SubLabel("ENTRIES", fg, top = 0.dp)524 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }525 RowLine(fg) {526 Name("Duplicates", fg)527 val done = dedupDone528 if (done != null) {529 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)530 } else {531 val count = dedupCount532 val label = when {533 dedupRunning -> "scanning..."534 count == null -> "scan"535 count == 0 -> "none found"536 else -> "$count found"537 }538 SubtleButton(label, fg) {539 if (count == null || count == 0) vm.scanDuplicates()540 }541 }542 }543 if ((dedupCount ?: 0) > 0 && dedupDone == null) {544 Column(545 Modifier.fillMaxWidth()546 .padding(top = 2.dp, bottom = 8.dp)547 .clip(RoundedCornerShape(8.dp))548 .background(fg.copy(alpha = 0.08f))549 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))550 .padding(10.dp),551 verticalArrangement = Arrangement.spacedBy(8.dp),552 ) {553 val count = dedupCount ?: 0554 Text(555 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",556 color = fg.copy(alpha = 0.82f),557 fontFamily = PeachType.Body,558 fontSize = 11.sp,559 lineHeight = 16.sp,560 )561 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {562 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }563 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }564 }565 }566 }567568 SubLabel("MAINTENANCE", fg)569 RowLine(fg) {570 Name("Trash auto-delete", fg)571 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)572 SubtleButton(trashOptions[idx].second, fg) {573 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)574 }575 }576 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }577 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }578579 SubLabel("DATA", fg)580 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }581 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }582 RowLine(fg) {583 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }584 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }585 }586 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }587588 var historyShowAll by remember { mutableStateOf(false) }589 SubLabel("UPDATES", fg)590 UpdatesRows(fg, onNavigate)591592 SubLabel("VAULT HISTORY", fg)593 if (history.isEmpty()) {594 Text(595 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",596 color = fg.copy(alpha = 0.68f),597 fontFamily = PeachType.Body,598 fontSize = 11.sp,599 lineHeight = 16.sp,600 modifier = Modifier.padding(bottom = 6.dp),601 )602 } else {603 Text(604 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",605 color = fg.copy(alpha = 0.68f),606 fontFamily = PeachType.Body,607 fontSize = 11.sp,608 lineHeight = 16.sp,609 modifier = Modifier.padding(bottom = 4.dp),610 )611 history.take(if (historyShowAll) history.size else 3).forEach { snap ->612 RowLine(fg) {613 Column(Modifier.weight(1f)) {614 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)615 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)616 }617 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {618 vm.restoreVaultSnapshot(snap.id)619 }620 }621 }622 if (history.size > 3) {623 RowLine(fg) {624 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)625 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }626 }627 }628 Text(629 "${history.size} of 5 snapshots",630 color = fg.copy(alpha = 0.58f),631 fontFamily = PeachType.Mono,632 fontSize = 10.sp,633 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),634 )635 }636637 var advancedOpen by remember { mutableStateOf(false) }638 SubLabel("ADVANCED", fg)639 RowLine(fg) {640 Name("Advanced", fg)641 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }642 }643 if (advancedOpen) {644 RowLine(fg) {645 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)646 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }647 }648 RowLine(fg) {649 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)650 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }651 }652 }653}654655private fun snapshotSubLabel(snapshot: VaultSnapshot): String {656 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }657 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")658}659660private fun snapshotReasonLabel(reason: String): String = when (reason) {661 "local_save" -> "saved"662 "sync_received" -> "synced"663 "import" -> "imported"664 "restore" -> "restored"665 else -> reason666}667668private fun snapshotTimeLabel(savedAt: Long): String {669 val diff = System.currentTimeMillis() - savedAt670 if (diff < 60_000) return "Just now"671 if (diff < 3_600_000) return "${diff / 60_000}m ago"672 val date = Date(savedAt)673 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())674 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)675 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)676 if (today == thenDay) return "Today $time"677 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))678 if (yesterday == thenDay) return "Yesterday $time"679 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)680}681682683684@Composable685private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {686 val context = LocalContext.current687 val syncVm: SyncViewModel = hiltViewModel()688 val pairedDevices by syncVm.pairedDevices.collectAsState()689 val pendingVouched by syncVm.pendingVouched.collectAsState()690 val syncStatus by syncVm.status.collectAsState()691 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()692 val syncMode by syncVm.syncMode.collectAsState()693 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()694 var notificationsAvailable by remember {695 mutableStateOf(canShowBackgroundSyncNotification(context))696 }697 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }698 val scope = rememberCoroutineScope()699 var confirmUnpair by remember { mutableStateOf<String?>(null) }700 var rotateConfirmOpen by remember { mutableStateOf(false) }701 var rotating by remember { mutableStateOf(false) }702 var rotateError by remember { mutableStateOf<String?>(null) }703 var relayUrlDraft by remember(configuredRelayUrl) {704 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))705 }706 var relayUrlStatus by remember { mutableStateOf<String?>(null) }707 var showConnection by remember { mutableStateOf(false) }708 var showRecovery by remember { mutableStateOf(false) }709710 SubLabel("ADD A DEVICE", fg, top = 0.dp)711 RowLine(fg) {712 Column(Modifier.weight(1f)) {713 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)714 Text(715 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",716 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,717 modifier = Modifier.padding(top = 2.dp),718 )719 }720 721 722 723 724 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }725 Spacer(Modifier.width(PeachSpacing.xs))726 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }727 }728729 SubLabel("SYNC STATUS", fg)730 val indicator = syncIndicator(syncStatus, pairedDevices.size)731 RowLine(fg) {732 SyncIndicatorDot(733 indicator = indicator,734 label = syncIndicatorLabel(indicator, syncStatus),735 modifier = Modifier.padding(end = PeachSpacing.sm),736 size = 8.dp,737 )738 Text(739 describeSyncOutcome(syncStatus),740 color = fg.copy(alpha = 0.7f),741 fontFamily = PeachType.Body,742 fontSize = 11.sp,743 lineHeight = 16.sp,744 modifier = Modifier.weight(1f),745 )746 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {747 if (!syncStatus.syncing) syncVm.syncNow()748 }749 }750751 SubLabel("PAIRED DEVICES", fg)752 if (pairedDevices.isEmpty()) {753 PeachEmptyOrEndState(754 message = "No paired devices yet.",755 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",756 modifier = Modifier.padding(top = PeachSpacing.xs),757 )758 } else {759 pairedDevices.forEach { d ->760 RowLine(fg) {761 Column(Modifier.weight(1f)) {762 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)763 Text(764 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",765 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,766 modifier = Modifier.padding(top = 2.dp),767 )768 }769 if (confirmUnpair != d.deviceId) {770 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }771 }772 }773 774 775 776 777 778 if (confirmUnpair == d.deviceId) {779 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {780 Text(781 "Stops this device receiving future changes. It doesn't lock it " +782 "out of what it already has - if it's lost or stolen, also " +783 "change your master password and generate new recovery words.",784 color = fg.copy(alpha = 0.7f),785 fontFamily = PeachType.Body,786 fontSize = 11.sp,787 lineHeight = 16.sp,788 )789 Row(790 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),791 horizontalArrangement = Arrangement.End,792 ) {793 SubtleButton("cancel", fg) { confirmUnpair = null }794 Spacer(Modifier.width(PeachSpacing.sm))795 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }796 }797 }798 }799 }800 }801802 SyncDisclosure(803 title = "Connection",804 summary = "Background sync, relay, and privacy choices",805 expanded = showConnection,806 fg = fg,807 onToggle = { showConnection = !showConnection },808 )809 if (showConnection) {810 RowLine(fg) {811 Column(Modifier.weight(1f)) {812 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)813 Text(814 when {815 backgroundSyncEnabled && !notificationsAvailable ->816 "Action needed — Android notifications are off, so background sync is not visible."817 backgroundSyncEnabled ->818 "On — keeps this phone reachable for paired devices."819 else ->820 "Off — Peach syncs only while you are using the app."821 },822 color = fg.copy(alpha = 0.7f),823 fontFamily = PeachType.Body,824 fontSize = 11.sp,825 lineHeight = 16.sp,826 modifier = Modifier.padding(top = 2.dp),827 )828 }829 SubtleButton(830 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",831 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,832 ) { showBackgroundSyncPrompt = true }833 }834 SyncModeRow(835 title = "Public relay",836 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",837 selected = syncMode == SyncMode.PUBLIC,838 fg = fg,839 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }840 SyncModeRow(841 title = "Private relay",842 detail = "Use your own peach-relay on your own domain.",843 selected = syncMode == SyncMode.PRIVATE,844 fg = fg,845 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }846847 if (syncMode == SyncMode.PRIVATE) {848 Text(849 "RELAY URL",850 color = fg.copy(alpha = 0.6f),851 fontFamily = PeachType.Mono,852 fontSize = 8.sp,853 letterSpacing = 0.22.em,854 modifier = Modifier.padding(top = 8.dp),855 )856 Box(857 Modifier.fillMaxWidth().padding(top = 6.dp)858 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))859 .padding(horizontal = 12.dp, vertical = 10.dp),860 ) {861 BasicTextField(862 value = relayUrlDraft,863 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },864 singleLine = true,865 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),866 cursorBrush = SolidColor(fg),867 modifier = Modifier.fillMaxWidth(),868 )869 }870 val needsRelayUrl = configuredRelayUrl.isEmpty()871 RowLine(fg) {872 val message = relayUrlStatus873 ?: if (needsRelayUrl) {874 "Sync is paused until you enter your relay URL."875 } else {876 "Only secure wss:// relays are accepted."877 }878 val warn = relayUrlStatus?.startsWith("Use") == true ||879 (relayUrlStatus == null && needsRelayUrl)880 ValueText(message, if (warn) peachColors.Breach else fg)881 SubtleButton("save", fg) {882 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {883 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)884 "Saved"885 } else {886 "Use a valid wss:// relay URL"887 }888 }889 }890 }891892 SyncModeRow(893 title = "No sync",894 detail = "Keep this device separate and move backups by hand.",895 selected = syncMode == SyncMode.OFF,896 fg = fg,897 ) { syncVm.setSyncMode(SyncMode.OFF) }898 Hint("Pairing authorizes a device, not a cloud account.", fg)899 }900901 if (pendingVouched.isNotEmpty()) {902 SubLabel("PENDING DEVICES", fg)903 pendingVouched.forEach { dev ->904 RowLine(fg) {905 Column(Modifier.weight(1f)) {906 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)907 Text(908 "Was added by another device. Allow it to sync?",909 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,910 modifier = Modifier.padding(top = 2.dp),911 )912 }913 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {914 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }915 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }916 }917 }918 }919 }920921 SyncDisclosure(922 title = "Recovery & security",923 summary = "For a lost or stolen device",924 expanded = showRecovery,925 fg = fg,926 onToggle = { showRecovery = !showRecovery },927 )928 if (showRecovery) {929 RowLine(fg) {930 Column(Modifier.weight(1f)) {931 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)932 Text(933 "Scan only when this populated vault previously trusted the source device.",934 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,935 modifier = Modifier.padding(top = 2.dp),936 )937 }938 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }939 }940 if (rotateConfirmOpen) {941 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)942 rotateError?.let {943 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))944 }945 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {946 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }947 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {948 if (rotating) return@SubtleButton949 rotating = true950 rotateError = null951 scope.launch {952 val ok = syncVm.rotateSyncIdentity()953 rotating = false954 if (ok) {955 rotateConfirmOpen = false956 onNavigate(PeachNav.RecoverySetup)957 } else {958 rotateError = "Rotation failed. Unlock your real vault and try again."959 }960 }961 }962 }963 } else {964 RowLine(fg) {965 Column(Modifier.weight(1f)) {966 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)967 Text(968 "Disconnects every device and replaces the keys used for sync.",969 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,970 modifier = Modifier.padding(top = 2.dp),971 )972 }973 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }974 }975 }976 }977978 if (showBackgroundSyncPrompt) {979 BackgroundSyncDialog { enabled ->980 showBackgroundSyncPrompt = false981 notificationsAvailable = canShowBackgroundSyncNotification(context)982 syncVm.setBackgroundSyncEnabled(enabled)983 }984 }985}986987private fun deviceTypeLabel(type: String?): String = when (type) {988 "android" -> "Android"989 "phone" -> "Phone"990 "tablet" -> "Tablet"991 "browser" -> "Browser"992 else -> "Device"993}994995private fun syncTimeLabel(lastSyncedAt: Long?): String {996 if (lastSyncedAt == null) return "never synced"997 val diff = System.currentTimeMillis() - lastSyncedAt998 if (diff < 60_000) return "just now"999 if (diff < 3_600_000) return "${diff / 60_000}m ago"1000 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1001 return "${diff / 86_400_000}d ago"1002}10031004private fun isSyncStale(lastSyncedAt: Long?): Boolean =1005 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1006100710081009@Composable1010private fun ProBody(fg: Color, vm: SettingsViewModel) {1011 val ctx = LocalContext.current1012 val isPro by vm.isPro.collectAsState()1013 if (isPro) {1014 ProActiveBody(fg, vm)1015 return1016 }1017 val features = listOf(1018 "Peach Sync" to "End-to-end encrypted",1019 "Peach Codex" to "Restore from paper",1020 "Passkeys" to "Zero-knowledge WebAuthn",1021 "Advanced Vault Health" to "Reuse · age · exposure",1022 "Canary entries" to "Detects vault compromise",1023 "Duress vault" to "Decoy under coercion",1024 )1025 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1026 Text(1027 "Everything, everywhere.",1028 modifier = Modifier.padding(top = 8.dp),1029 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1030 )1031 Text(1032 "One key unlocks every Pro feature — for the lifetime of the app.",1033 modifier = Modifier.padding(top = 6.dp),1034 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1035 )1036 Spacer(Modifier.height(10.dp))1037 features.forEachIndexed { i, (name, desc) ->1038 1039 1040 1041 1042 RowLine(fg, verticalPadding = 6.dp) {1043 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1044 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1045 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1046 }1047 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1048 }1049 }1050 Spacer(Modifier.height(12.dp))1051 Text(1052 "$39",1053 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1054 )1055 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1056 Spacer(Modifier.height(10.dp))1057 Box(1058 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1059 .background(fg.copy(alpha = 0.16f))1060 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1061 .clickable {1062 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1063 }1064 .padding(vertical = 12.dp),1065 contentAlignment = Alignment.Center,1066 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10671068 1069 1070 1071 1072 Spacer(Modifier.height(44.dp))10731074 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1075 var key by remember { mutableStateOf("") }1076 val activating by vm.activating.collectAsState()1077 val activationError by vm.activationError.collectAsState()1078 Box(1079 Modifier.fillMaxWidth().padding(top = 6.dp)1080 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1081 .padding(horizontal = 12.dp, vertical = 10.dp),1082 ) {1083 BasicTextField(1084 value = key,1085 1086 1087 1088 1089 onValueChange = { key = it; vm.clearActivationError() },1090 singleLine = true,1091 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1092 cursorBrush = SolidColor(fg),1093 modifier = Modifier.fillMaxWidth(),1094 decorationBox = { inner ->1095 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1096 inner()1097 },1098 )1099 }1100 if (activationError != null) {1101 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1102 }1103 Spacer(Modifier.height(8.dp))1104 Box(1105 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1106 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1107 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1108 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1109 .padding(vertical = 12.dp),1110 contentAlignment = Alignment.Center,1111 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1112}11131114@Composable1115private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1116 val ctx = LocalContext.current1117 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1118 val licenseKey by vm.licenseKey.collectAsState()1119 val issuedAt by vm.licenseIssuedAt.collectAsState()1120 var showKey by remember { mutableStateOf(false) }1121 var copied by remember { mutableStateOf(false) }1122 val scope = rememberCoroutineScope()1123 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1124 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1125 }1126 Text(1127 "Peach Pro",1128 modifier = Modifier.fillMaxWidth(),1129 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1130 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1131 )1132 Text(1133 formatLicenseActiveSince(issuedAt),1134 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1135 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1136 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1137 )1138 licenseKey?.let { key ->1139 Spacer(Modifier.height(12.dp))1140 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1141 RowLine(fg) {1142 Text(1143 if (showKey) key else "************************",1144 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1145 fontFamily = PeachType.Mono,1146 fontSize = 10.sp,1147 modifier = Modifier.weight(1f),1148 )1149 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1150 SubtleButton(if (copied) "copied" else "copy", fg) {1151 PeachClipboard.copy(ctx, "peach license", key)1152 copied = true1153 scope.launch {1154 delay(1_500)1155 copied = false1156 }1157 }1158 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1159 }1160 }1161 }1162 Spacer(Modifier.height(12.dp))1163 features.forEachIndexed { i, name ->1164 RowLine(fg) {1165 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1166 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1167 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1168 }1169 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1170 }1171 }1172 Spacer(Modifier.height(12.dp))1173 Box(1174 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1175 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1176 .clickable { vm.deactivate() }1177 .padding(vertical = 12.dp),1178 contentAlignment = Alignment.Center,1179 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1180}11811182private fun formatLicenseActiveSince(issuedAt: Long?): String {1183 if (issuedAt == null) return "Active"1184 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1185 return "Active since $monthYear"1186}1187118811891190@OptIn(ExperimentalLayoutApi::class)1191@Composable1192private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1193 val categories = listOf("Bug report", "Feature request", "Question", "Other")1194 var category by remember { mutableStateOf(categories[0]) }1195 var message by remember { mutableStateOf("") }1196 val state by vm.feedbackState.collectAsState()11971198 1199 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }12001201 if (state is FeedbackState.Sent) {1202 Box(1203 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1204 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1205 ) {1206 Column {1207 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1208 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))1209 }1210 }1211 return1212 }12131214 val sending = state is FeedbackState.Sending12151216 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)1217 SubLabel("TYPE", fg)1218 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1219 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1220 }1221 SubLabel("MESSAGE", fg)1222 Box(1223 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1224 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1225 .padding(10.dp),1226 ) {1227 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1228 BasicTextField(1229 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1230 enabled = !sending,1231 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1232 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1233 )1234 }1235 Text(1236 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1237 modifier = Modifier.padding(top = 6.dp),1238 color = fg.copy(alpha = 0.52f),1239 fontFamily = PeachType.Mono,1240 fontSize = 10.sp,1241 lineHeight = 14.sp,1242 )1243 Spacer(Modifier.height(8.dp))12441245 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1246 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1247 Box(1248 Modifier.clip(RoundedCornerShape(8.dp))1249 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1250 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1251 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1252 .padding(horizontal = 12.dp, vertical = 6.dp),1253 ) {1254 Text(1255 feedbackSubmitLabel(state),1256 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1257 fontFamily = PeachType.Mono, fontSize = 11.sp,1258 )1259 }1260 if (sending) {1261 CircularProgressIndicator(1262 color = LocalPeachAccent.current,1263 strokeWidth = 2.dp,1264 modifier = Modifier.size(16.dp),1265 )1266 }1267 }12681269 (state as? FeedbackState.Error)?.let { err ->1270 Text(1271 err.message,1272 modifier = Modifier.padding(top = 8.dp),1273 color = peachColors.Breach,1274 fontFamily = PeachType.Mono,1275 fontSize = 11.sp,1276 lineHeight = 15.sp,1277 )1278 }12791280 Hint("Sent anonymously · no account required", fg)1281}1282128312841285@Composable1286internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1287 val configured by vm.duressConfigured.collectAsState()1288 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1289}12901291@Composable1292private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1293 Text(1294 "Duress password is active",1295 color = peachColors.HealthOk,1296 fontFamily = PeachType.Mono,1297 fontSize = 11.sp,1298 modifier = Modifier.padding(top = 6.dp),1299 )1300 Spacer(Modifier.height(8.dp))1301 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1302 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1303}13041305@Composable1306private fun DuressForm(fg: Color, vm: SettingsViewModel) {1307 var enabled by remember { mutableStateOf(false) }1308 var password by remember { mutableStateOf("") }1309 var confirm by remember { mutableStateOf("") }1310 var action by remember { mutableStateOf(DuressAction.DECOY) }1311 var error by remember { mutableStateOf<String?>(null) }1312 val scope = rememberCoroutineScope()13131314 RowLine(fg) {1315 Name("Duress password", fg)1316 Toggle(enabled, fg) { on ->1317 enabled = on1318 if (!on) { password = ""; confirm = ""; error = null }1319 }1320 }1321 Text(1322 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1323 color = fg.copy(alpha = 0.7f),1324 fontFamily = PeachType.Mono,1325 fontSize = 10.sp,1326 modifier = Modifier.padding(top = 6.dp),1327 )13281329 AnimatedVisibility(enabled) {1330 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1331 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1332 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13331334 Text(1335 "When your duress password is entered at the lock screen, Peach will:",1336 color = fg.copy(alpha = 0.7f),1337 fontFamily = PeachType.Mono,1338 fontSize = 10.sp,1339 modifier = Modifier.padding(top = 4.dp),1340 )13411342 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1343 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13441345 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13461347 val canSave = password.isNotBlank() && confirm.isNotBlank()1348 DuressSaveButton("Save duress password", fg, canSave) {1349 error = when {1350 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1351 password != confirm -> "Passwords do not match"1352 else -> null1353 }1354 if (error == null) scope.launch {1355 if (vm.saveDuress(password, action)) {1356 password = ""; confirm = ""; enabled = false1357 } else {1358 error = "Couldn't save — the duress passphrase must differ from your real password"1359 }1360 }1361 }1362 }1363 }1364}13651366@Composable1367private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1368 val accent = LocalPeachAccent.current1369 Row(1370 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1371 verticalAlignment = Alignment.CenterVertically,1372 horizontalArrangement = Arrangement.spacedBy(10.dp),1373 ) {1374 Box(1375 Modifier.size(16.dp).clip(CircleShape)1376 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1377 contentAlignment = Alignment.Center,1378 ) {1379 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1380 }1381 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1382 }1383}13841385@Composable1386private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1387 val accent = LocalPeachAccent.current1388 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1389 Box(1390 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1391 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1392 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1393 .let { if (enabled) it.clickable(onClick = onClick) else it }1394 .padding(vertical = 11.dp),1395 contentAlignment = Alignment.Center,1396 ) {1397 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1398 }1399}14001401@Composable1402private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1403 Box(1404 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1405 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1406 .clickable(onClick = onClick)1407 .padding(vertical = 11.dp),1408 contentAlignment = Alignment.Center,1409 ) {1410 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1411 }1412}141314141415@Composable1416private fun SyncModeRow(1417 title: String,1418 detail: String,1419 selected: Boolean,1420 fg: Color,1421 onSelect: () -> Unit,1422) {1423 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1424 Column(Modifier.weight(1f)) {1425 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1426 Text(1427 detail,1428 color = fg.copy(alpha = 0.7f),1429 fontFamily = PeachType.Body,1430 fontSize = 11.sp,1431 lineHeight = 16.sp,1432 modifier = Modifier.padding(top = 2.dp),1433 )1434 }1435 Text(1436 if (selected) "●" else "○",1437 color = if (selected) fg else fg.copy(alpha = 0.45f),1438 fontFamily = PeachType.Mono,1439 fontSize = 14.sp,1440 modifier = Modifier.padding(start = 10.dp),1441 )1442 }1443}