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 -> {108 109 110 AboutUpdatesBody(fg, onNavigate)111 FeedbackBody(fg, vm)112 }113 }114}115116117118@Composable119private fun AboutUpdatesBody(fg: Color, onNavigate: (PeachNav) -> Unit) {120 val vm: UpdateViewModel = hiltViewModel()121 val available by vm.available.collectAsState()122 val state by vm.state.collectAsState()123124 SubLabel("THIS BUILD", fg, top = 0.dp)125 RowLine(fg) {126 Name("Version", fg)127 ValueText(BuildConfig.VERSION_NAME, fg)128 }129 RowLine(fg) {130 Column(Modifier.weight(1f)) {131 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)132 Text(133 when {134 available != null -> "v${available?.version} is available"135 state.phase == UpdatePhase.Checking -> "Checking…"136 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"137 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"138 else -> "Peach checks once a day and tells you when there's a new version."139 },140 color = fg.copy(alpha = 0.7f),141 fontFamily = PeachType.Body,142 fontSize = 11.sp,143 lineHeight = 16.sp,144 modifier = Modifier.padding(top = 2.dp),145 )146 }147 if (available != null) {148 SubtleButton("update", fg) { onNavigate(PeachNav.Update) }149 } else {150 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {151 vm.check()152 }153 }154 }155}156157158159@Composable160private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {161 Text(162 text,163 modifier = Modifier.padding(top = top, bottom = 4.dp),164 color = fg.copy(alpha = 0.58f),165 fontFamily = PeachType.Mono,166 fontSize = 8.sp,167 letterSpacing = 0.22.em,168 )169}170171@Composable172private fun RowLine(173 fg: Color,174 modifier: Modifier = Modifier,175 verticalPadding: Dp = 9.dp,176 content: @Composable RowScope.() -> Unit,177) {178 Row(179 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),180 verticalAlignment = Alignment.CenterVertically,181 horizontalArrangement = Arrangement.SpaceBetween,182 content = content,183 )184 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))185}186187@Composable188private fun RowScope.Name(text: String, fg: Color) {189 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))190}191192@Composable193private fun ValueText(text: String, fg: Color) {194 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)195}196197@Composable198private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {199 Box(200 Modifier.clip(RoundedCornerShape(8.dp))201 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))202 .background(fg.copy(alpha = 0.08f))203 .clickable(onClick = onClick)204 .padding(horizontal = 12.dp, vertical = 6.dp),205 ) {206 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)207 }208}209210@Composable211private fun SyncDisclosure(212 title: String,213 summary: String,214 expanded: Boolean,215 fg: Color,216 onToggle: () -> Unit,217) {218 Row(219 modifier = Modifier220 .fillMaxWidth()221 .heightIn(min = 48.dp)222 .clip(RoundedCornerShape(8.dp))223 .clickable(onClick = onToggle)224 .padding(vertical = 8.dp),225 verticalAlignment = Alignment.CenterVertically,226 ) {227 Column(Modifier.weight(1f)) {228 Text(229 title.uppercase(),230 color = fg.copy(alpha = 0.58f),231 fontFamily = PeachType.Mono,232 fontSize = 8.sp,233 letterSpacing = 0.22.em,234 )235 Text(236 summary,237 color = fg.copy(alpha = 0.7f),238 fontFamily = PeachType.Body,239 fontSize = 11.sp,240 modifier = Modifier.padding(top = 3.dp),241 )242 }243 Text(244 if (expanded) "hide" else "show",245 color = fg,246 fontFamily = PeachType.Mono,247 fontSize = 10.sp,248 )249 }250}251252@Composable253private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {254 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")255 Box(256 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))257 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))258 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))259 .clickable { onToggle(!on) },260 ) {261 Box(262 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)263 .padding(start = knob - 2.dp)264 .size(14.dp).clip(CircleShape)265 .background(if (on) fg else fg.copy(alpha = 0.58f)),266 )267 }268}269270@Composable271private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {272 Box(273 Modifier.clip(RoundedCornerShape(999.dp))274 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))275 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)276 .clickable(onClick = onClick)277 .padding(horizontal = 12.dp, vertical = 5.dp),278 ) {279 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)280 }281}282283@Composable284private fun ProBadge(fg: Color) {285 Box(286 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))287 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),288 ) {289 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)290 }291}292293@Composable294private fun Hint(text: String, fg: Color) {295 Text(296 text,297 modifier = Modifier.padding(top = 6.dp),298 color = fg.copy(alpha = 0.6f),299 fontFamily = PeachType.Display,300 fontStyle = FontStyle.Italic,301 fontSize = 11.5.sp,302 lineHeight = 16.sp,303 )304}305306307308@OptIn(ExperimentalLayoutApi::class)309@Composable310private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {311 SubLabel("ACCENT", fg, top = 0.dp)312 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {313 ACCENT_COLORS.forEach { (name, color) ->314 Box(315 Modifier.size(22.dp).clip(CircleShape).background(color)316 .border(317 if (s.accentColor == name) 2.dp else 1.dp,318 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),319 CircleShape,320 )321 .clickable { vm.setAccent(name) },322 )323 }324 }325 SubLabel("MOTION", fg)326 RowLine(fg) {327 Name("Reduce motion", fg)328 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }329 }330}331332333334@Composable335private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {336 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")337 val current = s.autoLockMinutes ?: 0338339 SubLabel("LOCKING", fg, top = 0.dp)340 RowLine(fg) {341 Name("Auto-lock after", fg)342 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)343 SubtleButton(lockOptions[idx].second, fg) {344 val next = lockOptions[(idx + 1) % lockOptions.size].first345 vm.setAutoLockMinutes(if (next == 0) 0 else next)346 }347 }348 SubLabel("BIOMETRICS", fg)349 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity350 RowLine(fg) {351 Name("Unlock with biometrics", fg)352 Toggle(s.biometricsEnabled, fg) { on ->353 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()354 }355 }356 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)357 if (bioError != null) {358 Text(359 bioError,360 color = com.peachpasswords.ui.theme.peachColors.Breach,361 fontFamily = PeachType.Mono, fontSize = 10.sp,362 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),363 )364 }365 SubLabel("AUTOFILL", fg)366 run {367 368 369 val ctx = LocalContext.current370 val enabled = AutofillEnablement.isEnabled(ctx)371 RowLine(fg) {372 Column(Modifier.weight(1f)) {373 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)374 Text(375 if (enabled) {376 "Peach is your autofill service."377 } else {378 "Android decides which app fills passwords. Turn Peach on in system settings."379 },380 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,381 modifier = Modifier.padding(top = 2.dp),382 )383 }384 if (!enabled) {385 SubtleButton("enable", fg) {386 387 388 389 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }390 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }391 }392 } else {393 ValueText("on", fg)394 }395 }396 }397398 SubLabel("PASSKEYS", fg)399 RowLine(fg) {400 Column(Modifier.weight(1f)) {401 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)402 Text(403 "Peach can handle passkey sign-in and creation through Android Credential Manager.",404 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,405 modifier = Modifier.padding(top = 2.dp),406 )407 }408 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }409 }410 RowLine(fg) {411 Name("Status", fg)412 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)413 }414 SubLabel("RECOVERY", fg)415 RowLine(fg) {416 Column(Modifier.weight(1f)) {417 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)418 Text(419 "Keep the paper copy current before rotating sync identity or replacing devices.",420 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,421 modifier = Modifier.padding(top = 2.dp),422 )423 }424 SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }425 }426 SubLabel("BREACH PROTECTION", fg)427 val checkingBreaches by vm.checkingBreaches.collectAsState()428 val breachResult by vm.breachResult.collectAsState()429 RowLine(fg) {430 Name("Breach monitoring", fg)431 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }432 }433 RowLine(fg) {434 Name("Check all passwords", fg)435 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }436 }437 breachResult?.let { result ->438 RowLine(fg) {439 Name("Last check", fg)440 ValueText(result, fg)441 }442 }443444 SubLabel("MASTER PASSWORD", fg)445 ChangePasswordRow(fg, vm)446447 SubLabel("DURESS PASSWORD", fg)448 val duressIsPro by vm.isPro.collectAsState()449 if (!duressIsPro) {450 RowLine(fg) {451 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {452 Name("Duress password", fg); ProBadge(fg)453 }454 ValueText("off", fg)455 }456 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)457 } else {458 DuressSection(fg, vm)459 }460}461462@Composable463private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {464 var open by remember { mutableStateOf(false) }465 var current by remember { mutableStateOf("") }466 var next by remember { mutableStateOf("") }467 var confirm by remember { mutableStateOf("") }468 var error by remember { mutableStateOf<String?>(null) }469 var done by remember { mutableStateOf(false) }470 val scope = rememberCoroutineScope()471472 RowLine(fg) {473 Name("Master password", fg)474 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }475 }476 AnimatedVisibility(open) {477 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {478 PwField("current password", current, fg) { current = it }479 PwField("new password (min 12 chars)", next, fg) { next = it }480 PwField("confirm new password", confirm, fg) { confirm = it }481 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }482 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)483 SubtleButton("save new password", fg) {484 error = when {485 current.isBlank() -> "enter your current password"486 next.length < 12 -> "new password must be at least 12 characters"487 next != confirm -> "new passwords do not match"488 next == current -> "new password must differ from current"489 else -> null490 }491 if (error == null) scope.launch {492 if (vm.changePassword(current, next)) {493 current = ""; next = ""; confirm = ""; open = false; done = true494 } else error = "incorrect current password"495 }496 }497 }498 }499}500501@Composable502private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {503 PeachField(504 label = placeholder,505 value = value,506 onValueChange = onChange,507 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,508 visualTransformation = PasswordVisualTransformation(),509 )510}511512513514@Composable515private fun VaultBody(516 fg: Color,517 vm: SettingsViewModel,518 s: VaultSettings,519 entriesCount: Int,520 onNavigate: (PeachNav) -> Unit,521) {522 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")523 val current = s.trashRetentionDays ?: 0524 val isPro by vm.isPro.collectAsState()525 val dedupCount by vm.dedupCount.collectAsState()526 val dedupRunning by vm.dedupRunning.collectAsState()527 val dedupDone by vm.dedupDone.collectAsState()528 val history by vm.vaultHistory.collectAsState()529 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()530531 SubLabel("ENTRIES", fg, top = 0.dp)532 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }533 RowLine(fg) {534 Name("Duplicates", fg)535 val done = dedupDone536 if (done != null) {537 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)538 } else {539 val count = dedupCount540 val label = when {541 dedupRunning -> "scanning..."542 count == null -> "scan"543 count == 0 -> "none found"544 else -> "$count found"545 }546 SubtleButton(label, fg) {547 if (count == null || count == 0) vm.scanDuplicates()548 }549 }550 }551 if ((dedupCount ?: 0) > 0 && dedupDone == null) {552 Column(553 Modifier.fillMaxWidth()554 .padding(top = 2.dp, bottom = 8.dp)555 .clip(RoundedCornerShape(8.dp))556 .background(fg.copy(alpha = 0.08f))557 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))558 .padding(10.dp),559 verticalArrangement = Arrangement.spacedBy(8.dp),560 ) {561 val count = dedupCount ?: 0562 Text(563 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",564 color = fg.copy(alpha = 0.82f),565 fontFamily = PeachType.Body,566 fontSize = 11.sp,567 lineHeight = 16.sp,568 )569 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {570 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }571 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }572 }573 }574 }575576 SubLabel("MAINTENANCE", fg)577 RowLine(fg) {578 Name("Trash auto-delete", fg)579 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)580 SubtleButton(trashOptions[idx].second, fg) {581 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)582 }583 }584 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }585 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }586587 SubLabel("DATA", fg)588 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }589 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }590 RowLine(fg) {591 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }592 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }593 }594 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }595596 var historyShowAll by remember { mutableStateOf(false) }597 SubLabel("VAULT HISTORY", fg)598 if (history.isEmpty()) {599 Text(600 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",601 color = fg.copy(alpha = 0.68f),602 fontFamily = PeachType.Body,603 fontSize = 11.sp,604 lineHeight = 16.sp,605 modifier = Modifier.padding(bottom = 6.dp),606 )607 } else {608 Text(609 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",610 color = fg.copy(alpha = 0.68f),611 fontFamily = PeachType.Body,612 fontSize = 11.sp,613 lineHeight = 16.sp,614 modifier = Modifier.padding(bottom = 4.dp),615 )616 history.take(if (historyShowAll) history.size else 3).forEach { snap ->617 RowLine(fg) {618 Column(Modifier.weight(1f)) {619 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)620 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)621 }622 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {623 vm.restoreVaultSnapshot(snap.id)624 }625 }626 }627 if (history.size > 3) {628 RowLine(fg) {629 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)630 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }631 }632 }633 Text(634 "${history.size} of 5 snapshots",635 color = fg.copy(alpha = 0.58f),636 fontFamily = PeachType.Mono,637 fontSize = 10.sp,638 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),639 )640 }641642 var advancedOpen by remember { mutableStateOf(false) }643 SubLabel("ADVANCED", fg)644 RowLine(fg) {645 Name("Advanced", fg)646 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }647 }648 if (advancedOpen) {649 RowLine(fg) {650 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)651 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }652 }653 RowLine(fg) {654 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)655 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }656 }657 }658}659660private fun snapshotSubLabel(snapshot: VaultSnapshot): String {661 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }662 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")663}664665private fun snapshotReasonLabel(reason: String): String = when (reason) {666 "local_save" -> "saved"667 "sync_received" -> "synced"668 "import" -> "imported"669 "restore" -> "restored"670 else -> reason671}672673private fun snapshotTimeLabel(savedAt: Long): String {674 val diff = System.currentTimeMillis() - savedAt675 if (diff < 60_000) return "Just now"676 if (diff < 3_600_000) return "${diff / 60_000}m ago"677 val date = Date(savedAt)678 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())679 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)680 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)681 if (today == thenDay) return "Today $time"682 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))683 if (yesterday == thenDay) return "Yesterday $time"684 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)685}686687688689@Composable690private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {691 val context = LocalContext.current692 val syncVm: SyncViewModel = hiltViewModel()693 val pairedDevices by syncVm.pairedDevices.collectAsState()694 val pendingVouched by syncVm.pendingVouched.collectAsState()695 val syncStatus by syncVm.status.collectAsState()696 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()697 val syncMode by syncVm.syncMode.collectAsState()698 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()699 var notificationsAvailable by remember {700 mutableStateOf(canShowBackgroundSyncNotification(context))701 }702 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }703 val scope = rememberCoroutineScope()704 var confirmUnpair by remember { mutableStateOf<String?>(null) }705 var rotateConfirmOpen by remember { mutableStateOf(false) }706 var rotating by remember { mutableStateOf(false) }707 var rotateError by remember { mutableStateOf<String?>(null) }708 var relayUrlDraft by remember(configuredRelayUrl) {709 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))710 }711 var relayUrlStatus by remember { mutableStateOf<String?>(null) }712 var showConnection by remember { mutableStateOf(false) }713 var showRecovery by remember { mutableStateOf(false) }714715 SubLabel("ADD A DEVICE", fg, top = 0.dp)716 RowLine(fg) {717 Column(Modifier.weight(1f)) {718 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)719 Text(720 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",721 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,722 modifier = Modifier.padding(top = 2.dp),723 )724 }725 726 727 728 729 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }730 Spacer(Modifier.width(PeachSpacing.xs))731 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }732 }733734 SubLabel("SYNC STATUS", fg)735 val indicator = syncIndicator(syncStatus, pairedDevices.size)736 RowLine(fg) {737 SyncIndicatorDot(738 indicator = indicator,739 label = syncIndicatorLabel(indicator, syncStatus),740 modifier = Modifier.padding(end = PeachSpacing.sm),741 size = 8.dp,742 )743 Text(744 describeSyncOutcome(syncStatus),745 color = fg.copy(alpha = 0.7f),746 fontFamily = PeachType.Body,747 fontSize = 11.sp,748 lineHeight = 16.sp,749 modifier = Modifier.weight(1f),750 )751 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {752 if (!syncStatus.syncing) syncVm.syncNow()753 }754 }755756 SubLabel("PAIRED DEVICES", fg)757 if (pairedDevices.isEmpty()) {758 PeachEmptyOrEndState(759 message = "No paired devices yet.",760 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",761 modifier = Modifier.padding(top = PeachSpacing.xs),762 )763 } else {764 pairedDevices.forEach { d ->765 RowLine(fg) {766 Column(Modifier.weight(1f)) {767 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)768 Text(769 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",770 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,771 modifier = Modifier.padding(top = 2.dp),772 )773 }774 if (confirmUnpair != d.deviceId) {775 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }776 }777 }778 779 780 781 782 783 if (confirmUnpair == d.deviceId) {784 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {785 Text(786 "Stops this device receiving future changes. It doesn't lock it " +787 "out of what it already has - if it's lost or stolen, also " +788 "change your master password and generate new recovery words.",789 color = fg.copy(alpha = 0.7f),790 fontFamily = PeachType.Body,791 fontSize = 11.sp,792 lineHeight = 16.sp,793 )794 Row(795 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),796 horizontalArrangement = Arrangement.End,797 ) {798 SubtleButton("cancel", fg) { confirmUnpair = null }799 Spacer(Modifier.width(PeachSpacing.sm))800 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }801 }802 }803 }804 }805 }806807 SyncDisclosure(808 title = "Connection",809 summary = "Background sync, relay, and privacy choices",810 expanded = showConnection,811 fg = fg,812 onToggle = { showConnection = !showConnection },813 )814 if (showConnection) {815 RowLine(fg) {816 Column(Modifier.weight(1f)) {817 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)818 Text(819 when {820 backgroundSyncEnabled && !notificationsAvailable ->821 "Action needed — Android notifications are off, so background sync is not visible."822 backgroundSyncEnabled ->823 "On — keeps this phone reachable for paired devices."824 else ->825 "Off — Peach syncs only while you are using the app."826 },827 color = fg.copy(alpha = 0.7f),828 fontFamily = PeachType.Body,829 fontSize = 11.sp,830 lineHeight = 16.sp,831 modifier = Modifier.padding(top = 2.dp),832 )833 }834 SubtleButton(835 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",836 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,837 ) { showBackgroundSyncPrompt = true }838 }839 SyncModeRow(840 title = "Public relay",841 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",842 selected = syncMode == SyncMode.PUBLIC,843 fg = fg,844 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }845 SyncModeRow(846 title = "Private relay",847 detail = "Use your own peach-relay on your own domain.",848 selected = syncMode == SyncMode.PRIVATE,849 fg = fg,850 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }851852 if (syncMode == SyncMode.PRIVATE) {853 Text(854 "RELAY URL",855 color = fg.copy(alpha = 0.6f),856 fontFamily = PeachType.Mono,857 fontSize = 8.sp,858 letterSpacing = 0.22.em,859 modifier = Modifier.padding(top = 8.dp),860 )861 Box(862 Modifier.fillMaxWidth().padding(top = 6.dp)863 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))864 .padding(horizontal = 12.dp, vertical = 10.dp),865 ) {866 BasicTextField(867 value = relayUrlDraft,868 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },869 singleLine = true,870 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),871 cursorBrush = SolidColor(fg),872 modifier = Modifier.fillMaxWidth(),873 )874 }875 val needsRelayUrl = configuredRelayUrl.isEmpty()876 RowLine(fg) {877 val message = relayUrlStatus878 ?: if (needsRelayUrl) {879 "Sync is paused until you enter your relay URL."880 } else {881 "Only secure wss:// relays are accepted."882 }883 val warn = relayUrlStatus?.startsWith("Use") == true ||884 (relayUrlStatus == null && needsRelayUrl)885 ValueText(message, if (warn) peachColors.Breach else fg)886 SubtleButton("save", fg) {887 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {888 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)889 "Saved"890 } else {891 "Use a valid wss:// relay URL"892 }893 }894 }895 }896897 SyncModeRow(898 title = "No sync",899 detail = "Keep this device separate and move backups by hand.",900 selected = syncMode == SyncMode.OFF,901 fg = fg,902 ) { syncVm.setSyncMode(SyncMode.OFF) }903 Hint("Pairing authorizes a device, not a cloud account.", fg)904 }905906 if (pendingVouched.isNotEmpty()) {907 SubLabel("PENDING DEVICES", fg)908 pendingVouched.forEach { dev ->909 RowLine(fg) {910 Column(Modifier.weight(1f)) {911 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)912 Text(913 "Was added by another device. Allow it to sync?",914 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,915 modifier = Modifier.padding(top = 2.dp),916 )917 }918 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {919 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }920 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }921 }922 }923 }924 }925926 SyncDisclosure(927 title = "Recovery & security",928 summary = "For a lost or stolen device",929 expanded = showRecovery,930 fg = fg,931 onToggle = { showRecovery = !showRecovery },932 )933 if (showRecovery) {934 RowLine(fg) {935 Column(Modifier.weight(1f)) {936 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)937 Text(938 "Scan only when this populated vault previously trusted the source device.",939 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,940 modifier = Modifier.padding(top = 2.dp),941 )942 }943 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }944 }945 if (rotateConfirmOpen) {946 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)947 rotateError?.let {948 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))949 }950 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {951 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }952 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {953 if (rotating) return@SubtleButton954 rotating = true955 rotateError = null956 scope.launch {957 val ok = syncVm.rotateSyncIdentity()958 rotating = false959 if (ok) {960 rotateConfirmOpen = false961 onNavigate(PeachNav.RecoverySetup)962 } else {963 rotateError = "Rotation failed. Unlock your real vault and try again."964 }965 }966 }967 }968 } else {969 RowLine(fg) {970 Column(Modifier.weight(1f)) {971 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)972 Text(973 "Disconnects every device and replaces the keys used for sync.",974 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,975 modifier = Modifier.padding(top = 2.dp),976 )977 }978 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }979 }980 }981 }982983 if (showBackgroundSyncPrompt) {984 BackgroundSyncDialog { enabled ->985 showBackgroundSyncPrompt = false986 notificationsAvailable = canShowBackgroundSyncNotification(context)987 syncVm.setBackgroundSyncEnabled(enabled)988 }989 }990}991992private fun deviceTypeLabel(type: String?): String = when (type) {993 "android" -> "Android"994 "phone" -> "Phone"995 "tablet" -> "Tablet"996 "browser" -> "Browser"997 else -> "Device"998}9991000private fun syncTimeLabel(lastSyncedAt: Long?): String {1001 if (lastSyncedAt == null) return "never synced"1002 val diff = System.currentTimeMillis() - lastSyncedAt1003 if (diff < 60_000) return "just now"1004 if (diff < 3_600_000) return "${diff / 60_000}m ago"1005 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1006 return "${diff / 86_400_000}d ago"1007}10081009private fun isSyncStale(lastSyncedAt: Long?): Boolean =1010 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1011101210131014@Composable1015private fun ProBody(fg: Color, vm: SettingsViewModel) {1016 val ctx = LocalContext.current1017 val isPro by vm.isPro.collectAsState()1018 if (isPro) {1019 ProActiveBody(fg, vm)1020 return1021 }1022 val features = listOf(1023 "Peach Sync" to "End-to-end encrypted",1024 "Peach Codex" to "Restore from paper",1025 "Passkeys" to "Zero-knowledge WebAuthn",1026 "Advanced Vault Health" to "Reuse · age · exposure",1027 "Canary entries" to "Detects vault compromise",1028 "Duress vault" to "Decoy under coercion",1029 )1030 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1031 Text(1032 "Everything, everywhere.",1033 modifier = Modifier.padding(top = 8.dp),1034 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1035 )1036 Text(1037 "One key unlocks every Pro feature — for the lifetime of the app.",1038 modifier = Modifier.padding(top = 6.dp),1039 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1040 )1041 Spacer(Modifier.height(10.dp))1042 features.forEachIndexed { i, (name, desc) ->1043 1044 1045 1046 1047 RowLine(fg, verticalPadding = 6.dp) {1048 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1049 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1050 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1051 }1052 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1053 }1054 }1055 Spacer(Modifier.height(12.dp))1056 Text(1057 "$39",1058 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1059 )1060 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1061 Spacer(Modifier.height(10.dp))1062 Box(1063 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1064 .background(fg.copy(alpha = 0.16f))1065 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1066 .clickable {1067 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1068 }1069 .padding(vertical = 12.dp),1070 contentAlignment = Alignment.Center,1071 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10721073 1074 1075 1076 1077 Spacer(Modifier.height(44.dp))10781079 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1080 var key by remember { mutableStateOf("") }1081 val activating by vm.activating.collectAsState()1082 val activationError by vm.activationError.collectAsState()1083 Box(1084 Modifier.fillMaxWidth().padding(top = 6.dp)1085 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1086 .padding(horizontal = 12.dp, vertical = 10.dp),1087 ) {1088 BasicTextField(1089 value = key,1090 1091 1092 1093 1094 onValueChange = { key = it; vm.clearActivationError() },1095 singleLine = true,1096 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1097 cursorBrush = SolidColor(fg),1098 modifier = Modifier.fillMaxWidth(),1099 decorationBox = { inner ->1100 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1101 inner()1102 },1103 )1104 }1105 if (activationError != null) {1106 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1107 }1108 Spacer(Modifier.height(8.dp))1109 Box(1110 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1111 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1112 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1113 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1114 .padding(vertical = 12.dp),1115 contentAlignment = Alignment.Center,1116 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1117}11181119@Composable1120private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1121 val ctx = LocalContext.current1122 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1123 val licenseKey by vm.licenseKey.collectAsState()1124 val issuedAt by vm.licenseIssuedAt.collectAsState()1125 var showKey by remember { mutableStateOf(false) }1126 var copied by remember { mutableStateOf(false) }1127 val scope = rememberCoroutineScope()1128 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1129 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1130 }1131 Text(1132 "Peach Pro",1133 modifier = Modifier.fillMaxWidth(),1134 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1135 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1136 )1137 Text(1138 formatLicenseActiveSince(issuedAt),1139 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1140 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1141 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1142 )1143 licenseKey?.let { key ->1144 Spacer(Modifier.height(12.dp))1145 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1146 RowLine(fg) {1147 Text(1148 if (showKey) key else "************************",1149 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1150 fontFamily = PeachType.Mono,1151 fontSize = 10.sp,1152 modifier = Modifier.weight(1f),1153 )1154 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1155 SubtleButton(if (copied) "copied" else "copy", fg) {1156 PeachClipboard.copy(ctx, "peach license", key)1157 copied = true1158 scope.launch {1159 delay(1_500)1160 copied = false1161 }1162 }1163 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1164 }1165 }1166 }1167 Spacer(Modifier.height(12.dp))1168 features.forEachIndexed { i, name ->1169 RowLine(fg) {1170 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1171 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1172 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1173 }1174 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1175 }1176 }1177 Spacer(Modifier.height(12.dp))1178 Box(1179 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1180 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1181 .clickable { vm.deactivate() }1182 .padding(vertical = 12.dp),1183 contentAlignment = Alignment.Center,1184 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1185}11861187private fun formatLicenseActiveSince(issuedAt: Long?): String {1188 if (issuedAt == null) return "Active"1189 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1190 return "Active since $monthYear"1191}1192119311941195@OptIn(ExperimentalLayoutApi::class)1196@Composable1197private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1198 val categories = listOf("Bug report", "Feature request", "Question", "Other")1199 var category by remember { mutableStateOf(categories[0]) }1200 var message by remember { mutableStateOf("") }1201 val state by vm.feedbackState.collectAsState()12021203 1204 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }12051206 if (state is FeedbackState.Sent) {1207 Box(1208 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1209 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1210 ) {1211 Column {1212 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1213 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))1214 }1215 }1216 return1217 }12181219 val sending = state is FeedbackState.Sending12201221 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)1222 SubLabel("TYPE", fg)1223 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1224 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1225 }1226 SubLabel("MESSAGE", fg)1227 Box(1228 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1229 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1230 .padding(10.dp),1231 ) {1232 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1233 BasicTextField(1234 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1235 enabled = !sending,1236 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1237 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1238 )1239 }1240 Text(1241 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1242 modifier = Modifier.padding(top = 6.dp),1243 color = fg.copy(alpha = 0.52f),1244 fontFamily = PeachType.Mono,1245 fontSize = 10.sp,1246 lineHeight = 14.sp,1247 )1248 Spacer(Modifier.height(8.dp))12491250 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1251 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1252 Box(1253 Modifier.clip(RoundedCornerShape(8.dp))1254 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1255 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1256 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1257 .padding(horizontal = 12.dp, vertical = 6.dp),1258 ) {1259 Text(1260 feedbackSubmitLabel(state),1261 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1262 fontFamily = PeachType.Mono, fontSize = 11.sp,1263 )1264 }1265 if (sending) {1266 CircularProgressIndicator(1267 color = LocalPeachAccent.current,1268 strokeWidth = 2.dp,1269 modifier = Modifier.size(16.dp),1270 )1271 }1272 }12731274 (state as? FeedbackState.Error)?.let { err ->1275 Text(1276 err.message,1277 modifier = Modifier.padding(top = 8.dp),1278 color = peachColors.Breach,1279 fontFamily = PeachType.Mono,1280 fontSize = 11.sp,1281 lineHeight = 15.sp,1282 )1283 }12841285 Hint("Sent anonymously · no account required", fg)1286}1287128812891290@Composable1291internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1292 val configured by vm.duressConfigured.collectAsState()1293 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1294}12951296@Composable1297private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1298 Text(1299 "Duress password is active",1300 color = peachColors.HealthOk,1301 fontFamily = PeachType.Mono,1302 fontSize = 11.sp,1303 modifier = Modifier.padding(top = 6.dp),1304 )1305 Spacer(Modifier.height(8.dp))1306 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1307 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1308}13091310@Composable1311private fun DuressForm(fg: Color, vm: SettingsViewModel) {1312 var enabled by remember { mutableStateOf(false) }1313 var password by remember { mutableStateOf("") }1314 var confirm by remember { mutableStateOf("") }1315 var action by remember { mutableStateOf(DuressAction.DECOY) }1316 var error by remember { mutableStateOf<String?>(null) }1317 val scope = rememberCoroutineScope()13181319 RowLine(fg) {1320 Name("Duress password", fg)1321 Toggle(enabled, fg) { on ->1322 enabled = on1323 if (!on) { password = ""; confirm = ""; error = null }1324 }1325 }1326 Text(1327 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1328 color = fg.copy(alpha = 0.7f),1329 fontFamily = PeachType.Mono,1330 fontSize = 10.sp,1331 modifier = Modifier.padding(top = 6.dp),1332 )13331334 AnimatedVisibility(enabled) {1335 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1336 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1337 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13381339 Text(1340 "When your duress password is entered at the lock screen, Peach will:",1341 color = fg.copy(alpha = 0.7f),1342 fontFamily = PeachType.Mono,1343 fontSize = 10.sp,1344 modifier = Modifier.padding(top = 4.dp),1345 )13461347 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1348 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13491350 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13511352 val canSave = password.isNotBlank() && confirm.isNotBlank()1353 DuressSaveButton("Save duress password", fg, canSave) {1354 error = when {1355 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1356 password != confirm -> "Passwords do not match"1357 else -> null1358 }1359 if (error == null) scope.launch {1360 if (vm.saveDuress(password, action)) {1361 password = ""; confirm = ""; enabled = false1362 } else {1363 error = "Couldn't save — the duress passphrase must differ from your real password"1364 }1365 }1366 }1367 }1368 }1369}13701371@Composable1372private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1373 val accent = LocalPeachAccent.current1374 Row(1375 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1376 verticalAlignment = Alignment.CenterVertically,1377 horizontalArrangement = Arrangement.spacedBy(10.dp),1378 ) {1379 Box(1380 Modifier.size(16.dp).clip(CircleShape)1381 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1382 contentAlignment = Alignment.Center,1383 ) {1384 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1385 }1386 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1387 }1388}13891390@Composable1391private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1392 val accent = LocalPeachAccent.current1393 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1394 Box(1395 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1396 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1397 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1398 .let { if (enabled) it.clickable(onClick = onClick) else it }1399 .padding(vertical = 11.dp),1400 contentAlignment = Alignment.Center,1401 ) {1402 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1403 }1404}14051406@Composable1407private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1408 Box(1409 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1410 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1411 .clickable(onClick = onClick)1412 .padding(vertical = 11.dp),1413 contentAlignment = Alignment.Center,1414 ) {1415 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1416 }1417}141814191420@Composable1421private fun SyncModeRow(1422 title: String,1423 detail: String,1424 selected: Boolean,1425 fg: Color,1426 onSelect: () -> Unit,1427) {1428 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1429 Column(Modifier.weight(1f)) {1430 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1431 Text(1432 detail,1433 color = fg.copy(alpha = 0.7f),1434 fontFamily = PeachType.Body,1435 fontSize = 11.sp,1436 lineHeight = 16.sp,1437 modifier = Modifier.padding(top = 2.dp),1438 )1439 }1440 Text(1441 if (selected) "●" else "○",1442 color = if (selected) fg else fg.copy(alpha = 0.45f),1443 fontFamily = PeachType.Mono,1444 fontSize = 14.sp,1445 modifier = Modifier.padding(start = 10.dp),1446 )1447 }1448}