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 "This vault shows a QR. An empty device scans it and receives the vault after the codes match.",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 SubtleButton("pair", fg) { onNavigate(PeachNav.CodePair) }726 }727728 SubLabel("SYNC STATUS", fg)729 val indicator = syncIndicator(syncStatus, pairedDevices.size)730 RowLine(fg) {731 SyncIndicatorDot(732 indicator = indicator,733 label = syncIndicatorLabel(indicator, syncStatus),734 modifier = Modifier.padding(end = PeachSpacing.sm),735 size = 8.dp,736 )737 Text(738 describeSyncOutcome(syncStatus),739 color = fg.copy(alpha = 0.7f),740 fontFamily = PeachType.Body,741 fontSize = 11.sp,742 lineHeight = 16.sp,743 modifier = Modifier.weight(1f),744 )745 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {746 if (!syncStatus.syncing) syncVm.syncNow()747 }748 }749750 SubLabel("PAIRED DEVICES", fg)751 if (pairedDevices.isEmpty()) {752 PeachEmptyOrEndState(753 message = "No paired devices yet.",754 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",755 modifier = Modifier.padding(top = PeachSpacing.xs),756 )757 } else {758 pairedDevices.forEach { d ->759 RowLine(fg) {760 Column(Modifier.weight(1f)) {761 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)762 Text(763 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",764 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,765 modifier = Modifier.padding(top = 2.dp),766 )767 }768 if (confirmUnpair != d.deviceId) {769 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }770 }771 }772 773 774 775 776 777 if (confirmUnpair == d.deviceId) {778 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {779 Text(780 "Stops this device receiving future changes. It doesn't lock it " +781 "out of what it already has - if it's lost or stolen, also " +782 "change your master password and generate new recovery words.",783 color = fg.copy(alpha = 0.7f),784 fontFamily = PeachType.Body,785 fontSize = 11.sp,786 lineHeight = 16.sp,787 )788 Row(789 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),790 horizontalArrangement = Arrangement.End,791 ) {792 SubtleButton("cancel", fg) { confirmUnpair = null }793 Spacer(Modifier.width(PeachSpacing.sm))794 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }795 }796 }797 }798 }799 }800801 SyncDisclosure(802 title = "Connection",803 summary = "Background sync, relay, and privacy choices",804 expanded = showConnection,805 fg = fg,806 onToggle = { showConnection = !showConnection },807 )808 if (showConnection) {809 RowLine(fg) {810 Column(Modifier.weight(1f)) {811 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)812 Text(813 when {814 backgroundSyncEnabled && !notificationsAvailable ->815 "Action needed — Android notifications are off, so background sync is not visible."816 backgroundSyncEnabled ->817 "On — keeps this phone reachable for paired devices."818 else ->819 "Off — Peach syncs only while you are using the app."820 },821 color = fg.copy(alpha = 0.7f),822 fontFamily = PeachType.Body,823 fontSize = 11.sp,824 lineHeight = 16.sp,825 modifier = Modifier.padding(top = 2.dp),826 )827 }828 SubtleButton(829 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",830 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,831 ) { showBackgroundSyncPrompt = true }832 }833 SyncModeRow(834 title = "Public relay",835 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",836 selected = syncMode == SyncMode.PUBLIC,837 fg = fg,838 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }839 SyncModeRow(840 title = "Private relay",841 detail = "Use your own peach-relay on your own domain.",842 selected = syncMode == SyncMode.PRIVATE,843 fg = fg,844 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }845846 if (syncMode == SyncMode.PRIVATE) {847 Text(848 "RELAY URL",849 color = fg.copy(alpha = 0.6f),850 fontFamily = PeachType.Mono,851 fontSize = 8.sp,852 letterSpacing = 0.22.em,853 modifier = Modifier.padding(top = 8.dp),854 )855 Box(856 Modifier.fillMaxWidth().padding(top = 6.dp)857 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))858 .padding(horizontal = 12.dp, vertical = 10.dp),859 ) {860 BasicTextField(861 value = relayUrlDraft,862 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },863 singleLine = true,864 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),865 cursorBrush = SolidColor(fg),866 modifier = Modifier.fillMaxWidth(),867 )868 }869 val needsRelayUrl = configuredRelayUrl.isEmpty()870 RowLine(fg) {871 val message = relayUrlStatus872 ?: if (needsRelayUrl) {873 "Sync is paused until you enter your relay URL."874 } else {875 "Only secure wss:// relays are accepted."876 }877 val warn = relayUrlStatus?.startsWith("Use") == true ||878 (relayUrlStatus == null && needsRelayUrl)879 ValueText(message, if (warn) peachColors.Breach else fg)880 SubtleButton("save", fg) {881 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {882 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)883 "Saved"884 } else {885 "Use a valid wss:// relay URL"886 }887 }888 }889 }890891 SyncModeRow(892 title = "No sync",893 detail = "Keep this device separate and move backups by hand.",894 selected = syncMode == SyncMode.OFF,895 fg = fg,896 ) { syncVm.setSyncMode(SyncMode.OFF) }897 Hint("Pairing authorizes a device, not a cloud account.", fg)898 }899900 if (pendingVouched.isNotEmpty()) {901 SubLabel("PENDING DEVICES", fg)902 pendingVouched.forEach { dev ->903 RowLine(fg) {904 Column(Modifier.weight(1f)) {905 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)906 Text(907 "Was added by another device. Allow it to sync?",908 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,909 modifier = Modifier.padding(top = 2.dp),910 )911 }912 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {913 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }914 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }915 }916 }917 }918 }919920 SyncDisclosure(921 title = "Recovery & security",922 summary = "For a lost or stolen device",923 expanded = showRecovery,924 fg = fg,925 onToggle = { showRecovery = !showRecovery },926 )927 if (showRecovery) {928 RowLine(fg) {929 Column(Modifier.weight(1f)) {930 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)931 Text(932 "Scan only when this populated vault previously trusted the source device.",933 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,934 modifier = Modifier.padding(top = 2.dp),935 )936 }937 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }938 }939 if (rotateConfirmOpen) {940 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)941 rotateError?.let {942 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))943 }944 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {945 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }946 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {947 if (rotating) return@SubtleButton948 rotating = true949 rotateError = null950 scope.launch {951 val ok = syncVm.rotateSyncIdentity()952 rotating = false953 if (ok) {954 rotateConfirmOpen = false955 onNavigate(PeachNav.RecoverySetup)956 } else {957 rotateError = "Rotation failed. Unlock your real vault and try again."958 }959 }960 }961 }962 } else {963 RowLine(fg) {964 Column(Modifier.weight(1f)) {965 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)966 Text(967 "Disconnects every device and replaces the keys used for sync.",968 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,969 modifier = Modifier.padding(top = 2.dp),970 )971 }972 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }973 }974 }975 }976977 if (showBackgroundSyncPrompt) {978 BackgroundSyncDialog { enabled ->979 showBackgroundSyncPrompt = false980 notificationsAvailable = canShowBackgroundSyncNotification(context)981 syncVm.setBackgroundSyncEnabled(enabled)982 }983 }984}985986private fun deviceTypeLabel(type: String?): String = when (type) {987 "android" -> "Android"988 "phone" -> "Phone"989 "tablet" -> "Tablet"990 "browser" -> "Browser"991 else -> "Device"992}993994private fun syncTimeLabel(lastSyncedAt: Long?): String {995 if (lastSyncedAt == null) return "never synced"996 val diff = System.currentTimeMillis() - lastSyncedAt997 if (diff < 60_000) return "just now"998 if (diff < 3_600_000) return "${diff / 60_000}m ago"999 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1000 return "${diff / 86_400_000}d ago"1001}10021003private fun isSyncStale(lastSyncedAt: Long?): Boolean =1004 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1005100610071008@Composable1009private fun ProBody(fg: Color, vm: SettingsViewModel) {1010 val ctx = LocalContext.current1011 val isPro by vm.isPro.collectAsState()1012 if (isPro) {1013 ProActiveBody(fg, vm)1014 return1015 }1016 val features = listOf(1017 "Peach Sync" to "End-to-end encrypted",1018 "Peach Codex" to "Restore from paper",1019 "Passkeys" to "Zero-knowledge WebAuthn",1020 "Advanced Vault Health" to "Reuse · age · exposure",1021 "Canary entries" to "Detects vault compromise",1022 "Duress vault" to "Decoy under coercion",1023 )1024 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1025 Text(1026 "Everything, everywhere.",1027 modifier = Modifier.padding(top = 8.dp),1028 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1029 )1030 Text(1031 "One key unlocks every Pro feature — for the lifetime of the app.",1032 modifier = Modifier.padding(top = 6.dp),1033 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1034 )1035 Spacer(Modifier.height(10.dp))1036 features.forEachIndexed { i, (name, desc) ->1037 1038 1039 1040 1041 RowLine(fg, verticalPadding = 6.dp) {1042 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1043 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1044 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1045 }1046 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1047 }1048 }1049 Spacer(Modifier.height(12.dp))1050 Text(1051 "$39",1052 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1053 )1054 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1055 Spacer(Modifier.height(10.dp))1056 Box(1057 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1058 .background(fg.copy(alpha = 0.16f))1059 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1060 .clickable {1061 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1062 }1063 .padding(vertical = 12.dp),1064 contentAlignment = Alignment.Center,1065 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }10661067 1068 1069 1070 1071 Spacer(Modifier.height(44.dp))10721073 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1074 var key by remember { mutableStateOf("") }1075 val activating by vm.activating.collectAsState()1076 val activationError by vm.activationError.collectAsState()1077 Box(1078 Modifier.fillMaxWidth().padding(top = 6.dp)1079 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1080 .padding(horizontal = 12.dp, vertical = 10.dp),1081 ) {1082 BasicTextField(1083 value = key,1084 1085 1086 1087 1088 onValueChange = { key = it; vm.clearActivationError() },1089 singleLine = true,1090 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1091 cursorBrush = SolidColor(fg),1092 modifier = Modifier.fillMaxWidth(),1093 decorationBox = { inner ->1094 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1095 inner()1096 },1097 )1098 }1099 if (activationError != null) {1100 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1101 }1102 Spacer(Modifier.height(8.dp))1103 Box(1104 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1105 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1106 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1107 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1108 .padding(vertical = 12.dp),1109 contentAlignment = Alignment.Center,1110 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1111}11121113@Composable1114private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1115 val ctx = LocalContext.current1116 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1117 val licenseKey by vm.licenseKey.collectAsState()1118 val issuedAt by vm.licenseIssuedAt.collectAsState()1119 var showKey by remember { mutableStateOf(false) }1120 var copied by remember { mutableStateOf(false) }1121 val scope = rememberCoroutineScope()1122 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1123 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1124 }1125 Text(1126 "Peach Pro",1127 modifier = Modifier.fillMaxWidth(),1128 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1129 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1130 )1131 Text(1132 formatLicenseActiveSince(issuedAt),1133 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1134 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1135 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1136 )1137 licenseKey?.let { key ->1138 Spacer(Modifier.height(12.dp))1139 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1140 RowLine(fg) {1141 Text(1142 if (showKey) key else "************************",1143 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1144 fontFamily = PeachType.Mono,1145 fontSize = 10.sp,1146 modifier = Modifier.weight(1f),1147 )1148 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1149 SubtleButton(if (copied) "copied" else "copy", fg) {1150 PeachClipboard.copy(ctx, "peach license", key)1151 copied = true1152 scope.launch {1153 delay(1_500)1154 copied = false1155 }1156 }1157 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1158 }1159 }1160 }1161 Spacer(Modifier.height(12.dp))1162 features.forEachIndexed { i, name ->1163 RowLine(fg) {1164 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1165 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1166 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1167 }1168 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1169 }1170 }1171 Spacer(Modifier.height(12.dp))1172 Box(1173 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1174 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1175 .clickable { vm.deactivate() }1176 .padding(vertical = 12.dp),1177 contentAlignment = Alignment.Center,1178 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1179}11801181private fun formatLicenseActiveSince(issuedAt: Long?): String {1182 if (issuedAt == null) return "Active"1183 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1184 return "Active since $monthYear"1185}1186118711881189@OptIn(ExperimentalLayoutApi::class)1190@Composable1191private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1192 val categories = listOf("Bug report", "Feature request", "Question", "Other")1193 var category by remember { mutableStateOf(categories[0]) }1194 var message by remember { mutableStateOf("") }1195 val state by vm.feedbackState.collectAsState()11961197 1198 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }11991200 if (state is FeedbackState.Sent) {1201 Box(1202 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1203 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1204 ) {1205 Column {1206 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1207 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))1208 }1209 }1210 return1211 }12121213 val sending = state is FeedbackState.Sending12141215 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)1216 SubLabel("TYPE", fg)1217 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1218 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1219 }1220 SubLabel("MESSAGE", fg)1221 Box(1222 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1223 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1224 .padding(10.dp),1225 ) {1226 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1227 BasicTextField(1228 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1229 enabled = !sending,1230 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1231 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1232 )1233 }1234 Text(1235 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1236 modifier = Modifier.padding(top = 6.dp),1237 color = fg.copy(alpha = 0.52f),1238 fontFamily = PeachType.Mono,1239 fontSize = 10.sp,1240 lineHeight = 14.sp,1241 )1242 Spacer(Modifier.height(8.dp))12431244 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1245 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1246 Box(1247 Modifier.clip(RoundedCornerShape(8.dp))1248 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1249 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1250 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1251 .padding(horizontal = 12.dp, vertical = 6.dp),1252 ) {1253 Text(1254 feedbackSubmitLabel(state),1255 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1256 fontFamily = PeachType.Mono, fontSize = 11.sp,1257 )1258 }1259 if (sending) {1260 CircularProgressIndicator(1261 color = LocalPeachAccent.current,1262 strokeWidth = 2.dp,1263 modifier = Modifier.size(16.dp),1264 )1265 }1266 }12671268 (state as? FeedbackState.Error)?.let { err ->1269 Text(1270 err.message,1271 modifier = Modifier.padding(top = 8.dp),1272 color = peachColors.Breach,1273 fontFamily = PeachType.Mono,1274 fontSize = 11.sp,1275 lineHeight = 15.sp,1276 )1277 }12781279 Hint("Sent anonymously · no account required", fg)1280}1281128212831284@Composable1285internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1286 val configured by vm.duressConfigured.collectAsState()1287 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1288}12891290@Composable1291private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1292 Text(1293 "Duress password is active",1294 color = peachColors.HealthOk,1295 fontFamily = PeachType.Mono,1296 fontSize = 11.sp,1297 modifier = Modifier.padding(top = 6.dp),1298 )1299 Spacer(Modifier.height(8.dp))1300 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1301 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1302}13031304@Composable1305private fun DuressForm(fg: Color, vm: SettingsViewModel) {1306 var enabled by remember { mutableStateOf(false) }1307 var password by remember { mutableStateOf("") }1308 var confirm by remember { mutableStateOf("") }1309 var action by remember { mutableStateOf(DuressAction.DECOY) }1310 var error by remember { mutableStateOf<String?>(null) }1311 val scope = rememberCoroutineScope()13121313 RowLine(fg) {1314 Name("Duress password", fg)1315 Toggle(enabled, fg) { on ->1316 enabled = on1317 if (!on) { password = ""; confirm = ""; error = null }1318 }1319 }1320 Text(1321 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1322 color = fg.copy(alpha = 0.7f),1323 fontFamily = PeachType.Mono,1324 fontSize = 10.sp,1325 modifier = Modifier.padding(top = 6.dp),1326 )13271328 AnimatedVisibility(enabled) {1329 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1330 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1331 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }13321333 Text(1334 "When your duress password is entered at the lock screen, Peach will:",1335 color = fg.copy(alpha = 0.7f),1336 fontFamily = PeachType.Mono,1337 fontSize = 10.sp,1338 modifier = Modifier.padding(top = 4.dp),1339 )13401341 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1342 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }13431344 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }13451346 val canSave = password.isNotBlank() && confirm.isNotBlank()1347 DuressSaveButton("Save duress password", fg, canSave) {1348 error = when {1349 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1350 password != confirm -> "Passwords do not match"1351 else -> null1352 }1353 if (error == null) scope.launch {1354 if (vm.saveDuress(password, action)) {1355 password = ""; confirm = ""; enabled = false1356 } else {1357 error = "Couldn't save — the duress passphrase must differ from your real password"1358 }1359 }1360 }1361 }1362 }1363}13641365@Composable1366private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1367 val accent = LocalPeachAccent.current1368 Row(1369 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1370 verticalAlignment = Alignment.CenterVertically,1371 horizontalArrangement = Arrangement.spacedBy(10.dp),1372 ) {1373 Box(1374 Modifier.size(16.dp).clip(CircleShape)1375 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1376 contentAlignment = Alignment.Center,1377 ) {1378 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1379 }1380 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1381 }1382}13831384@Composable1385private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1386 val accent = LocalPeachAccent.current1387 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1388 Box(1389 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1390 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1391 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1392 .let { if (enabled) it.clickable(onClick = onClick) else it }1393 .padding(vertical = 11.dp),1394 contentAlignment = Alignment.Center,1395 ) {1396 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1397 }1398}13991400@Composable1401private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1402 Box(1403 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1404 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1405 .clickable(onClick = onClick)1406 .padding(vertical = 11.dp),1407 contentAlignment = Alignment.Center,1408 ) {1409 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1410 }1411}141214131414@Composable1415private fun SyncModeRow(1416 title: String,1417 detail: String,1418 selected: Boolean,1419 fg: Color,1420 onSelect: () -> Unit,1421) {1422 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1423 Column(Modifier.weight(1f)) {1424 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1425 Text(1426 detail,1427 color = fg.copy(alpha = 0.7f),1428 fontFamily = PeachType.Body,1429 fontSize = 11.sp,1430 lineHeight = 16.sp,1431 modifier = Modifier.padding(top = 2.dp),1432 )1433 }1434 Text(1435 if (selected) "●" else "○",1436 color = if (selected) fg else fg.copy(alpha = 0.45f),1437 fontFamily = PeachType.Mono,1438 fontSize = 14.sp,1439 modifier = Modifier.padding(start = 10.dp),1440 )1441 }1442}