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.ChangelogPhase73import com.peachpasswords.ui.updates.UpdatePhase74import com.peachpasswords.ui.updates.UpdateViewModel75import com.peachpasswords.updates.compareVersions76import androidx.hilt.navigation.compose.hiltViewModel77import com.peachpasswords.vault.VaultSettings78import com.peachpasswords.vault.VaultSnapshot79import kotlinx.coroutines.launch80import kotlinx.coroutines.delay81import java.text.SimpleDateFormat82import java.util.Date83import java.util.Locale8485private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"868788899091929394@Composable95fun CardBody(96 id: CardId,97 fg: Color,98 vm: SettingsViewModel,99 settings: VaultSettings,100 entriesCount: Int,101 onNavigate: (PeachNav) -> Unit,102) {103 when (id) {104 CardId.Appearance -> AppearanceBody(fg, vm, settings)105 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)106 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)107 CardId.Sync -> SyncBody(fg, onNavigate)108 CardId.Pro -> ProBody(fg, vm)109 CardId.Feedback -> FeedbackBody(fg, vm)110 }111}112113114115@Composable116private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {117 Text(118 text,119 modifier = Modifier.padding(top = top, bottom = 4.dp),120 color = fg.copy(alpha = 0.58f),121 fontFamily = PeachType.Mono,122 fontSize = 8.sp,123 letterSpacing = 0.22.em,124 )125}126127128129130131132@Composable133private fun SettingsGroup(134 label: String,135 fg: Color,136 initiallyOpen: Boolean = true,137 top: Dp = 14.dp,138 content: @Composable () -> Unit,139) {140 var open by remember { mutableStateOf(initiallyOpen) }141 Row(142 Modifier.fillMaxWidth()143 .clip(RoundedCornerShape(6.dp))144 .clickable { open = !open }145 .padding(top = top, bottom = 4.dp),146 verticalAlignment = Alignment.CenterVertically,147 ) {148 Text(149 label,150 modifier = Modifier.weight(1f),151 color = fg.copy(alpha = 0.58f),152 fontFamily = PeachType.Mono,153 fontSize = 8.sp,154 letterSpacing = 0.22.em,155 )156 Text(157 if (open) "–" else "+",158 color = fg.copy(alpha = 0.58f),159 fontFamily = PeachType.Mono,160 fontSize = 10.sp,161 )162 }163 AnimatedVisibility(visible = open) { content() }164}165166@Composable167private fun RowLine(168 fg: Color,169 modifier: Modifier = Modifier,170 verticalPadding: Dp = 9.dp,171 content: @Composable RowScope.() -> Unit,172) {173 Row(174 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),175 verticalAlignment = Alignment.CenterVertically,176 horizontalArrangement = Arrangement.SpaceBetween,177 content = content,178 )179 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))180}181182@Composable183private fun RowScope.Name(text: String, fg: Color) {184 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))185}186187@Composable188private fun ValueText(text: String, fg: Color) {189 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)190}191192@Composable193private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {194 Box(195 Modifier.clip(RoundedCornerShape(8.dp))196 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))197 .background(fg.copy(alpha = 0.08f))198 .clickable(onClick = onClick)199 .padding(horizontal = 12.dp, vertical = 6.dp),200 ) {201 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)202 }203}204205@Composable206private fun SyncDisclosure(207 title: String,208 summary: String,209 expanded: Boolean,210 fg: Color,211 onToggle: () -> Unit,212) {213 Row(214 modifier = Modifier215 .fillMaxWidth()216 .heightIn(min = 48.dp)217 .clip(RoundedCornerShape(8.dp))218 .clickable(onClick = onToggle)219 .padding(vertical = 8.dp),220 verticalAlignment = Alignment.CenterVertically,221 ) {222 Column(Modifier.weight(1f)) {223 Text(224 title.uppercase(),225 color = fg.copy(alpha = 0.58f),226 fontFamily = PeachType.Mono,227 fontSize = 8.sp,228 letterSpacing = 0.22.em,229 )230 Text(231 summary,232 color = fg.copy(alpha = 0.7f),233 fontFamily = PeachType.Body,234 fontSize = 11.sp,235 modifier = Modifier.padding(top = 3.dp),236 )237 }238 Text(239 if (expanded) "hide" else "show",240 color = fg,241 fontFamily = PeachType.Mono,242 fontSize = 10.sp,243 )244 }245}246247@Composable248private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {249 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")250 Box(251 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))252 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))253 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))254 .clickable { onToggle(!on) },255 ) {256 Box(257 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)258 .padding(start = knob - 2.dp)259 .size(14.dp).clip(CircleShape)260 .background(if (on) fg else fg.copy(alpha = 0.58f)),261 )262 }263}264265@Composable266private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {267 Box(268 Modifier.clip(RoundedCornerShape(999.dp))269 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))270 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)271 .clickable(onClick = onClick)272 .padding(horizontal = 12.dp, vertical = 5.dp),273 ) {274 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)275 }276}277278@Composable279private fun ProBadge(fg: Color) {280 Box(281 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))282 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),283 ) {284 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)285 }286}287288@Composable289private fun Hint(text: String, fg: Color) {290 Text(291 text,292 modifier = Modifier.padding(top = 6.dp),293 color = fg.copy(alpha = 0.6f),294 fontFamily = PeachType.Display,295 fontStyle = FontStyle.Italic,296 fontSize = 11.5.sp,297 lineHeight = 16.sp,298 )299}300301302303@OptIn(ExperimentalLayoutApi::class)304@Composable305private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {306 SubLabel("ACCENT", fg, top = 0.dp)307 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {308 ACCENT_COLORS.forEach { (name, color) ->309 Box(310 Modifier.size(22.dp).clip(CircleShape).background(color)311 .border(312 if (s.accentColor == name) 2.dp else 1.dp,313 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),314 CircleShape,315 )316 .clickable { vm.setAccent(name) },317 )318 }319 }320 SubLabel("TEXT SIZE", fg)321 RowLine(fg) {322 Name("Text size", fg)323 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {324 Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }325 Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }326 Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }327 }328 }329 SubLabel("ICONS", fg)330 RowLine(fg) {331 Name("Site icons", fg)332 SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) { vm.setFaviconsEnabled(!s.faviconsEnabled) }333 }334 SubLabel("MOTION", fg)335 RowLine(fg) {336 Name("Reduce motion", fg)337 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }338 }339}340341342343@Composable344private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {345 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")346 val current = s.autoLockMinutes ?: 0347348 SubLabel("LOCKING", fg, top = 0.dp)349 RowLine(fg) {350 Name("Allow screenshots", fg)351 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }352 }353 RowLine(fg) {354 Name("Auto-lock after", fg)355 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)356 SubtleButton(lockOptions[idx].second, fg) {357 val next = lockOptions[(idx + 1) % lockOptions.size].first358 vm.setAutoLockMinutes(if (next == 0) 0 else next)359 }360 }361 SubLabel("BIOMETRICS", fg)362 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity363 RowLine(fg) {364 Name("Unlock with biometrics", fg)365 Toggle(s.biometricsEnabled, fg) { on ->366 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()367 }368 }369 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)370 if (bioError != null) {371 Text(372 bioError,373 color = com.peachpasswords.ui.theme.peachColors.Breach,374 fontFamily = PeachType.Mono, fontSize = 10.sp,375 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),376 )377 }378 SubLabel("AUTOFILL", fg)379 run {380 381 382 val ctx = LocalContext.current383 val enabled = AutofillEnablement.isEnabled(ctx)384 RowLine(fg) {385 Column(Modifier.weight(1f)) {386 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)387 Text(388 if (enabled) {389 "Peach is your autofill service."390 } else {391 "Android decides which app fills passwords. Turn Peach on in system settings."392 },393 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,394 modifier = Modifier.padding(top = 2.dp),395 )396 }397 if (!enabled) {398 SubtleButton("enable", fg) {399 400 401 402 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }403 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }404 }405 } else {406 ValueText("on", fg)407 }408 }409 }410411 SubLabel("PASSKEYS", fg)412 RowLine(fg) {413 Column(Modifier.weight(1f)) {414 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)415 Text(416 "Peach can handle passkey sign-in and creation through Android Credential Manager.",417 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,418 modifier = Modifier.padding(top = 2.dp),419 )420 }421 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }422 }423 RowLine(fg) {424 Name("Status", fg)425 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)426 }427 SubLabel("RECOVERY", fg)428 RowLine(fg) {429 Column(Modifier.weight(1f)) {430 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)431 Text(432 "Keep the paper copy current before rotating sync identity or replacing devices.",433 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,434 modifier = Modifier.padding(top = 2.dp),435 )436 }437 SubtleButton("view", fg) {438 if (bioActivity != null) {439 com.peachpasswords.ui.lock.Biometrics.authenticate(440 activity = bioActivity,441 title = "View recovery words",442 subtitle = "Confirm your identity to view your recovery words",443 onSuccess = { onNavigate(PeachNav.RecoveryView) },444 )445 } else onNavigate(PeachNav.RecoveryView)446 }447 }448 SubLabel("BREACH PROTECTION", fg)449 val checkingBreaches by vm.checkingBreaches.collectAsState()450 val breachResult by vm.breachResult.collectAsState()451 RowLine(fg) {452 Name("Breach monitoring", fg)453 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }454 }455 RowLine(fg) {456 Name("Check all passwords", fg)457 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }458 }459 breachResult?.let { result ->460 RowLine(fg) {461 Name("Last check", fg)462 ValueText(result, fg)463 }464 }465466 SubLabel("MASTER PASSWORD", fg)467 ChangePasswordRow(fg, vm)468469 SubLabel("DURESS PASSWORD", fg)470 val duressIsPro by vm.isPro.collectAsState()471 if (!duressIsPro) {472 RowLine(fg) {473 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {474 Name("Duress password", fg); ProBadge(fg)475 }476 ValueText("off", fg)477 }478 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)479 } else {480 DuressSection(fg, vm)481 }482}483484@Composable485private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {486 var open by remember { mutableStateOf(false) }487 var current by remember { mutableStateOf("") }488 var next by remember { mutableStateOf("") }489 var confirm by remember { mutableStateOf("") }490 var error by remember { mutableStateOf<String?>(null) }491 var done by remember { mutableStateOf(false) }492 val scope = rememberCoroutineScope()493494 RowLine(fg) {495 Name("Master password", fg)496 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }497 }498 AnimatedVisibility(open) {499 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {500 PwField("current password", current, fg) { current = it }501 PwField("new password (min 12 chars)", next, fg) { next = it }502 PwField("confirm new password", confirm, fg) { confirm = it }503 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }504 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)505 SubtleButton("save new password", fg) {506 error = when {507 current.isBlank() -> "enter your current password"508 next.length < 12 -> "new password must be at least 12 characters"509 next != confirm -> "new passwords do not match"510 next == current -> "new password must differ from current"511 else -> null512 }513 if (error == null) scope.launch {514 if (vm.changePassword(current, next)) {515 current = ""; next = ""; confirm = ""; open = false; done = true516 } else error = "incorrect current password"517 }518 }519 }520 }521}522523@Composable524private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {525 PeachField(526 label = placeholder,527 value = value,528 onValueChange = onChange,529 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,530 visualTransformation = PasswordVisualTransformation(),531 )532}533534535536@Composable537private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {538 val vm: UpdateViewModel = hiltViewModel()539 val available by vm.available.collectAsState()540 val state by vm.state.collectAsState()541 val changelog by vm.changelog.collectAsState()542 var changelogOpen by remember { mutableStateOf(false) }543544 RowLine(fg) {545 Name("Version", fg)546 ValueText(BuildConfig.VERSION_NAME, fg)547 }548 RowLine(fg) {549 Column(Modifier.weight(1f)) {550 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)551 Text(552 when {553 available != null -> "v${available?.version} is available"554 state.phase == UpdatePhase.Checking -> "Checking…"555 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"556 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"557 else -> "Peach checks once a day and tells you when there's a new version."558 },559 color = fg.copy(alpha = 0.7f),560 fontFamily = PeachType.Body,561 fontSize = 11.sp,562 lineHeight = 16.sp,563 modifier = Modifier.padding(top = 2.dp),564 )565 }566 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {567 SubtleButton("changelog", fg) {568 changelogOpen = !changelogOpen569 if (changelogOpen) vm.loadChangelog()570 }571 if (available != null) {572 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }573 } else {574 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {575 vm.check()576 }577 }578 }579 }580 if (changelogOpen) {581 when (changelog.phase) {582 ChangelogPhase.Idle -> {}583 ChangelogPhase.Loading -> Text(584 "Loading changelog…",585 color = fg.copy(alpha = 0.68f),586 fontFamily = PeachType.Body,587 fontSize = 11.sp,588 modifier = Modifier.padding(vertical = 8.dp),589 )590 ChangelogPhase.Failed -> RowLine(fg) {591 Name("Couldn't reach the update server", fg)592 SubtleButton("retry", fg) { vm.loadChangelog() }593 }594 ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {595 changelog.entries.forEachIndexed { index, entry ->596 Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {597 Row(verticalAlignment = Alignment.CenterVertically) {598 Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)599 if (compareVersions(entry.version, vm.currentVersion) == 0) {600 Text(" current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)601 }602 }603 Text(604 entry.notes ?: "No notes for this release.",605 color = fg.copy(alpha = 0.72f),606 fontFamily = PeachType.Body,607 fontSize = 11.sp,608 lineHeight = 16.sp,609 modifier = Modifier.padding(top = 3.dp),610 )611 }612 if (index < changelog.entries.lastIndex) {613 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))614 }615 }616 }617 }618 }619}620621@Composable622private fun VaultBody(623 fg: Color,624 vm: SettingsViewModel,625 s: VaultSettings,626 entriesCount: Int,627 onNavigate: (PeachNav) -> Unit,628) {629 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")630 val current = s.trashRetentionDays ?: 0631 val isPro by vm.isPro.collectAsState()632 val dedupCount by vm.dedupCount.collectAsState()633 val dedupRunning by vm.dedupRunning.collectAsState()634 val dedupDone by vm.dedupDone.collectAsState()635 val history by vm.vaultHistory.collectAsState()636 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()637638 SettingsGroup("ENTRIES", fg, top = 0.dp) {639 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }640 RowLine(fg) {641 Name("Duplicates", fg)642 val done = dedupDone643 if (done != null) {644 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)645 } else {646 val count = dedupCount647 val label = when {648 dedupRunning -> "scanning..."649 count == null -> "scan"650 count == 0 -> "none found"651 else -> "$count found"652 }653 SubtleButton(label, fg) {654 if (count == null || count == 0) vm.scanDuplicates()655 }656 }657 }658 if ((dedupCount ?: 0) > 0 && dedupDone == null) {659 Column(660 Modifier.fillMaxWidth()661 .padding(top = 2.dp, bottom = 8.dp)662 .clip(RoundedCornerShape(8.dp))663 .background(fg.copy(alpha = 0.08f))664 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))665 .padding(10.dp),666 verticalArrangement = Arrangement.spacedBy(8.dp),667 ) {668 val count = dedupCount ?: 0669 Text(670 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",671 color = fg.copy(alpha = 0.82f),672 fontFamily = PeachType.Body,673 fontSize = 11.sp,674 lineHeight = 16.sp,675 )676 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {677 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }678 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }679 }680 }681 }682 }683684 SettingsGroup("MAINTENANCE", fg) {685 RowLine(fg) {686 Name("Trash auto-delete", fg)687 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)688 SubtleButton(trashOptions[idx].second, fg) {689 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)690 }691 }692 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }693 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }694 }695696 SettingsGroup("DATA", fg) {697 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }698 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }699 RowLine(fg) {700 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }701 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }702 }703 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }704 }705706 SettingsGroup("UPDATES", fg) {707 UpdatesRows(fg, onNavigate)708 }709710 SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {711 var historyShowAll by remember { mutableStateOf(false) }712 if (history.isEmpty()) {713 Text(714 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",715 color = fg.copy(alpha = 0.68f),716 fontFamily = PeachType.Body,717 fontSize = 11.sp,718 lineHeight = 16.sp,719 modifier = Modifier.padding(bottom = 6.dp),720 )721 } else {722 Text(723 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",724 color = fg.copy(alpha = 0.68f),725 fontFamily = PeachType.Body,726 fontSize = 11.sp,727 lineHeight = 16.sp,728 modifier = Modifier.padding(bottom = 4.dp),729 )730 history.take(if (historyShowAll) history.size else 3).forEach { snap ->731 RowLine(fg) {732 Column(Modifier.weight(1f)) {733 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)734 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)735 }736 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {737 vm.restoreVaultSnapshot(snap.id)738 }739 }740 }741 if (history.size > 3) {742 RowLine(fg) {743 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)744 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }745 }746 }747 Text(748 "${history.size} of 5 snapshots",749 color = fg.copy(alpha = 0.58f),750 fontFamily = PeachType.Mono,751 fontSize = 10.sp,752 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),753 )754 }755 }756757 SettingsGroup("ADVANCED", fg, initiallyOpen = false) {758 RowLine(fg) {759 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)760 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }761 }762 RowLine(fg) {763 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)764 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }765 }766 }767}768769private fun snapshotSubLabel(snapshot: VaultSnapshot): String {770 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }771 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")772}773774private fun snapshotReasonLabel(reason: String): String = when (reason) {775 "local_save" -> "saved"776 "sync_received" -> "synced"777 "import" -> "imported"778 "restore" -> "restored"779 else -> reason780}781782private fun snapshotTimeLabel(savedAt: Long): String {783 val diff = System.currentTimeMillis() - savedAt784 if (diff < 60_000) return "Just now"785 if (diff < 3_600_000) return "${diff / 60_000}m ago"786 val date = Date(savedAt)787 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())788 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)789 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)790 if (today == thenDay) return "Today $time"791 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))792 if (yesterday == thenDay) return "Yesterday $time"793 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)794}795796797798@Composable799private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {800 val context = LocalContext.current801 val syncVm: SyncViewModel = hiltViewModel()802 val pairedDevices by syncVm.pairedDevices.collectAsState()803 val pendingVouched by syncVm.pendingVouched.collectAsState()804 val syncStatus by syncVm.status.collectAsState()805 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()806 val syncMode by syncVm.syncMode.collectAsState()807 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()808 var notificationsAvailable by remember {809 mutableStateOf(canShowBackgroundSyncNotification(context))810 }811 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }812 val scope = rememberCoroutineScope()813 var confirmUnpair by remember { mutableStateOf<String?>(null) }814 var rotateConfirmOpen by remember { mutableStateOf(false) }815 var rotating by remember { mutableStateOf(false) }816 var rotateError by remember { mutableStateOf<String?>(null) }817 var relayUrlDraft by remember(configuredRelayUrl) {818 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))819 }820 var relayUrlStatus by remember { mutableStateOf<String?>(null) }821 var showConnection by remember { mutableStateOf(false) }822 var showRecovery by remember { mutableStateOf(false) }823824 SubLabel("ADD A DEVICE", fg, top = 0.dp)825 RowLine(fg) {826 Column(Modifier.weight(1f)) {827 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)828 Text(829 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",830 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,831 modifier = Modifier.padding(top = 2.dp),832 )833 }834 835 836 837 838 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }839 Spacer(Modifier.width(PeachSpacing.xs))840 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }841 }842843 SubLabel("SYNC STATUS", fg)844 val indicator = syncIndicator(syncStatus, pairedDevices.size)845 RowLine(fg) {846 SyncIndicatorDot(847 indicator = indicator,848 label = syncIndicatorLabel(indicator, syncStatus),849 modifier = Modifier.padding(end = PeachSpacing.sm),850 size = 8.dp,851 )852 Text(853 describeSyncOutcome(syncStatus),854 color = fg.copy(alpha = 0.7f),855 fontFamily = PeachType.Body,856 fontSize = 11.sp,857 lineHeight = 16.sp,858 modifier = Modifier.weight(1f),859 )860 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {861 if (!syncStatus.syncing) syncVm.syncNow()862 }863 }864865 SubLabel("PAIRED DEVICES", fg)866 if (pairedDevices.isEmpty()) {867 PeachEmptyOrEndState(868 message = "No paired devices yet.",869 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",870 modifier = Modifier.padding(top = PeachSpacing.xs),871 )872 } else {873 pairedDevices.forEach { d ->874 RowLine(fg) {875 Column(Modifier.weight(1f)) {876 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)877 Text(878 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",879 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,880 modifier = Modifier.padding(top = 2.dp),881 )882 }883 if (confirmUnpair != d.deviceId) {884 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }885 }886 }887 888 889 890 891 892 if (confirmUnpair == d.deviceId) {893 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {894 Text(895 "Stops this device receiving future changes. It doesn't lock it " +896 "out of what it already has - if it's lost or stolen, also " +897 "change your master password and generate new recovery words.",898 color = fg.copy(alpha = 0.7f),899 fontFamily = PeachType.Body,900 fontSize = 11.sp,901 lineHeight = 16.sp,902 )903 Row(904 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),905 horizontalArrangement = Arrangement.End,906 ) {907 SubtleButton("cancel", fg) { confirmUnpair = null }908 Spacer(Modifier.width(PeachSpacing.sm))909 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }910 }911 }912 }913 }914 }915916 SyncDisclosure(917 title = "Connection",918 summary = "Background sync, relay, and privacy choices",919 expanded = showConnection,920 fg = fg,921 onToggle = { showConnection = !showConnection },922 )923 if (showConnection) {924 RowLine(fg) {925 Column(Modifier.weight(1f)) {926 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)927 Text(928 when {929 backgroundSyncEnabled && !notificationsAvailable ->930 "Action needed — Android notifications are off, so background sync is not visible."931 backgroundSyncEnabled ->932 "On — keeps this phone reachable for paired devices."933 else ->934 "Off — Peach syncs only while you are using the app."935 },936 color = fg.copy(alpha = 0.7f),937 fontFamily = PeachType.Body,938 fontSize = 11.sp,939 lineHeight = 16.sp,940 modifier = Modifier.padding(top = 2.dp),941 )942 }943 SubtleButton(944 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",945 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,946 ) { showBackgroundSyncPrompt = true }947 }948 SyncModeRow(949 title = "Public relay",950 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",951 selected = syncMode == SyncMode.PUBLIC,952 fg = fg,953 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }954 SyncModeRow(955 title = "Private relay",956 detail = "Use your own peach-relay on your own domain.",957 selected = syncMode == SyncMode.PRIVATE,958 fg = fg,959 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }960961 if (syncMode == SyncMode.PRIVATE) {962 Text(963 "RELAY URL",964 color = fg.copy(alpha = 0.6f),965 fontFamily = PeachType.Mono,966 fontSize = 8.sp,967 letterSpacing = 0.22.em,968 modifier = Modifier.padding(top = 8.dp),969 )970 Box(971 Modifier.fillMaxWidth().padding(top = 6.dp)972 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))973 .padding(horizontal = 12.dp, vertical = 10.dp),974 ) {975 BasicTextField(976 value = relayUrlDraft,977 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },978 singleLine = true,979 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),980 cursorBrush = SolidColor(fg),981 modifier = Modifier.fillMaxWidth(),982 )983 }984 val needsRelayUrl = configuredRelayUrl.isEmpty()985 RowLine(fg) {986 val message = relayUrlStatus987 ?: if (needsRelayUrl) {988 "Sync is paused until you enter your relay URL."989 } else {990 "Only secure wss:// relays are accepted."991 }992 val warn = relayUrlStatus?.startsWith("Use") == true ||993 (relayUrlStatus == null && needsRelayUrl)994 ValueText(message, if (warn) peachColors.Breach else fg)995 SubtleButton("save", fg) {996 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {997 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)998 "Saved"999 } else {1000 "Use a valid wss:// relay URL"1001 }1002 }1003 }1004 }10051006 SyncModeRow(1007 title = "No sync",1008 detail = "Keep this device separate and move backups by hand.",1009 selected = syncMode == SyncMode.OFF,1010 fg = fg,1011 ) { syncVm.setSyncMode(SyncMode.OFF) }1012 Hint("Pairing authorizes a device, not a cloud account.", fg)1013 }10141015 if (pendingVouched.isNotEmpty()) {1016 SubLabel("PENDING DEVICES", fg)1017 pendingVouched.forEach { dev ->1018 RowLine(fg) {1019 Column(Modifier.weight(1f)) {1020 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1021 Text(1022 "Was added by another device. Allow it to sync?",1023 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1024 modifier = Modifier.padding(top = 2.dp),1025 )1026 }1027 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1028 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1029 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1030 }1031 }1032 }1033 }10341035 SyncDisclosure(1036 title = "Recovery & security",1037 summary = "For a lost or stolen device",1038 expanded = showRecovery,1039 fg = fg,1040 onToggle = { showRecovery = !showRecovery },1041 )1042 if (showRecovery) {1043 RowLine(fg) {1044 Column(Modifier.weight(1f)) {1045 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1046 Text(1047 "Scan only when this populated vault previously trusted the source device.",1048 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1049 modifier = Modifier.padding(top = 2.dp),1050 )1051 }1052 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1053 }1054 if (rotateConfirmOpen) {1055 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1056 rotateError?.let {1057 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1058 }1059 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1060 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1061 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1062 if (rotating) return@SubtleButton1063 rotating = true1064 rotateError = null1065 scope.launch {1066 val ok = syncVm.rotateSyncIdentity()1067 rotating = false1068 if (ok) {1069 rotateConfirmOpen = false1070 onNavigate(PeachNav.RecoverySetup)1071 } else {1072 rotateError = "Rotation failed. Unlock your real vault and try again."1073 }1074 }1075 }1076 }1077 } else {1078 RowLine(fg) {1079 Column(Modifier.weight(1f)) {1080 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1081 Text(1082 "Disconnects every device and replaces the keys used for sync.",1083 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1084 modifier = Modifier.padding(top = 2.dp),1085 )1086 }1087 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1088 }1089 }1090 }10911092 if (showBackgroundSyncPrompt) {1093 BackgroundSyncDialog { enabled ->1094 showBackgroundSyncPrompt = false1095 notificationsAvailable = canShowBackgroundSyncNotification(context)1096 syncVm.setBackgroundSyncEnabled(enabled)1097 }1098 }1099}11001101private fun deviceTypeLabel(type: String?): String = when (type) {1102 "android" -> "Android"1103 "phone" -> "Phone"1104 "tablet" -> "Tablet"1105 "browser" -> "Browser"1106 else -> "Device"1107}11081109private fun syncTimeLabel(lastSyncedAt: Long?): String {1110 if (lastSyncedAt == null) return "never synced"1111 val diff = System.currentTimeMillis() - lastSyncedAt1112 if (diff < 60_000) return "just now"1113 if (diff < 3_600_000) return "${diff / 60_000}m ago"1114 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1115 return "${diff / 86_400_000}d ago"1116}11171118private fun isSyncStale(lastSyncedAt: Long?): Boolean =1119 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1120112111221123@Composable1124private fun ProBody(fg: Color, vm: SettingsViewModel) {1125 val ctx = LocalContext.current1126 val isPro by vm.isPro.collectAsState()1127 if (isPro) {1128 ProActiveBody(fg, vm)1129 return1130 }1131 val features = listOf(1132 "Peach Sync" to "End-to-end encrypted",1133 "Peach Codex" to "Restore from paper",1134 "Passkeys" to "Zero-knowledge WebAuthn",1135 "Advanced Vault Health" to "Reuse · age · exposure",1136 "Canary entries" to "Detects vault compromise",1137 "Duress vault" to "Decoy under coercion",1138 )1139 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1140 Text(1141 "Everything, everywhere.",1142 modifier = Modifier.padding(top = 8.dp),1143 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1144 )1145 Text(1146 "One key unlocks every Pro feature — for the lifetime of the app.",1147 modifier = Modifier.padding(top = 6.dp),1148 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1149 )1150 Spacer(Modifier.height(10.dp))1151 features.forEachIndexed { i, (name, desc) ->1152 1153 1154 1155 1156 RowLine(fg, verticalPadding = 6.dp) {1157 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1158 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1159 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1160 }1161 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1162 }1163 }1164 Spacer(Modifier.height(12.dp))1165 Text(1166 "$39",1167 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1168 )1169 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1170 Spacer(Modifier.height(10.dp))1171 Box(1172 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1173 .background(fg.copy(alpha = 0.16f))1174 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1175 .clickable {1176 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1177 }1178 .padding(vertical = 12.dp),1179 contentAlignment = Alignment.Center,1180 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }11811182 1183 1184 1185 1186 Spacer(Modifier.height(44.dp))11871188 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1189 var key by remember { mutableStateOf("") }1190 val activating by vm.activating.collectAsState()1191 val activationError by vm.activationError.collectAsState()1192 Box(1193 Modifier.fillMaxWidth().padding(top = 6.dp)1194 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1195 .padding(horizontal = 12.dp, vertical = 10.dp),1196 ) {1197 BasicTextField(1198 value = key,1199 1200 1201 1202 1203 onValueChange = { key = it; vm.clearActivationError() },1204 singleLine = true,1205 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1206 cursorBrush = SolidColor(fg),1207 modifier = Modifier.fillMaxWidth(),1208 decorationBox = { inner ->1209 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1210 inner()1211 },1212 )1213 }1214 if (activationError != null) {1215 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1216 }1217 Spacer(Modifier.height(8.dp))1218 Box(1219 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1220 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1221 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1222 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1223 .padding(vertical = 12.dp),1224 contentAlignment = Alignment.Center,1225 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1226}12271228@Composable1229private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1230 val ctx = LocalContext.current1231 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1232 val licenseKey by vm.licenseKey.collectAsState()1233 val issuedAt by vm.licenseIssuedAt.collectAsState()1234 var showKey by remember { mutableStateOf(false) }1235 var copied by remember { mutableStateOf(false) }1236 val scope = rememberCoroutineScope()1237 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1238 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1239 }1240 Text(1241 "Peach Pro",1242 modifier = Modifier.fillMaxWidth(),1243 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1244 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1245 )1246 Text(1247 formatLicenseActiveSince(issuedAt),1248 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1249 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1250 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1251 )1252 licenseKey?.let { key ->1253 Spacer(Modifier.height(12.dp))1254 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1255 RowLine(fg) {1256 Text(1257 if (showKey) key else "************************",1258 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1259 fontFamily = PeachType.Mono,1260 fontSize = 10.sp,1261 modifier = Modifier.weight(1f),1262 )1263 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1264 SubtleButton(if (copied) "copied" else "copy", fg) {1265 PeachClipboard.copy(ctx, "peach license", key)1266 copied = true1267 scope.launch {1268 delay(1_500)1269 copied = false1270 }1271 }1272 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1273 }1274 }1275 }1276 Spacer(Modifier.height(12.dp))1277 features.forEachIndexed { i, name ->1278 RowLine(fg) {1279 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1280 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1281 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1282 }1283 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1284 }1285 }1286 Spacer(Modifier.height(12.dp))1287 Box(1288 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1289 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1290 .clickable { vm.deactivate() }1291 .padding(vertical = 12.dp),1292 contentAlignment = Alignment.Center,1293 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1294}12951296private fun formatLicenseActiveSince(issuedAt: Long?): String {1297 if (issuedAt == null) return "Active"1298 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1299 return "Active since $monthYear"1300}1301130213031304@OptIn(ExperimentalLayoutApi::class)1305@Composable1306private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1307 val categories = listOf("Bug report", "Feature request", "Question", "Other")1308 var category by remember { mutableStateOf(categories[0]) }1309 var message by remember { mutableStateOf("") }1310 val state by vm.feedbackState.collectAsState()13111312 1313 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }13141315 if (state is FeedbackState.Sent) {1316 Box(1317 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1318 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1319 ) {1320 Column {1321 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1322 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))1323 }1324 }1325 return1326 }13271328 val sending = state is FeedbackState.Sending13291330 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)1331 SubLabel("TYPE", fg)1332 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1333 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1334 }1335 SubLabel("MESSAGE", fg)1336 Box(1337 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1338 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1339 .padding(10.dp),1340 ) {1341 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1342 BasicTextField(1343 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1344 enabled = !sending,1345 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1346 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1347 )1348 }1349 Text(1350 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1351 modifier = Modifier.padding(top = 6.dp),1352 color = fg.copy(alpha = 0.52f),1353 fontFamily = PeachType.Mono,1354 fontSize = 10.sp,1355 lineHeight = 14.sp,1356 )1357 Spacer(Modifier.height(8.dp))13581359 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1360 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1361 Box(1362 Modifier.clip(RoundedCornerShape(8.dp))1363 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1364 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1365 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1366 .padding(horizontal = 12.dp, vertical = 6.dp),1367 ) {1368 Text(1369 feedbackSubmitLabel(state),1370 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1371 fontFamily = PeachType.Mono, fontSize = 11.sp,1372 )1373 }1374 if (sending) {1375 CircularProgressIndicator(1376 color = LocalPeachAccent.current,1377 strokeWidth = 2.dp,1378 modifier = Modifier.size(16.dp),1379 )1380 }1381 }13821383 (state as? FeedbackState.Error)?.let { err ->1384 Text(1385 err.message,1386 modifier = Modifier.padding(top = 8.dp),1387 color = peachColors.Breach,1388 fontFamily = PeachType.Mono,1389 fontSize = 11.sp,1390 lineHeight = 15.sp,1391 )1392 }13931394 Hint("Sent anonymously · no account required", fg)1395}1396139713981399@Composable1400internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1401 val configured by vm.duressConfigured.collectAsState()1402 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1403}14041405@Composable1406private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1407 Text(1408 "Duress password is active",1409 color = peachColors.HealthOk,1410 fontFamily = PeachType.Mono,1411 fontSize = 11.sp,1412 modifier = Modifier.padding(top = 6.dp),1413 )1414 Spacer(Modifier.height(8.dp))1415 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1416 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1417}14181419@Composable1420private fun DuressForm(fg: Color, vm: SettingsViewModel) {1421 var enabled by remember { mutableStateOf(false) }1422 var password by remember { mutableStateOf("") }1423 var confirm by remember { mutableStateOf("") }1424 var action by remember { mutableStateOf(DuressAction.DECOY) }1425 var error by remember { mutableStateOf<String?>(null) }1426 val scope = rememberCoroutineScope()14271428 RowLine(fg) {1429 Name("Duress password", fg)1430 Toggle(enabled, fg) { on ->1431 enabled = on1432 if (!on) { password = ""; confirm = ""; error = null }1433 }1434 }1435 Text(1436 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1437 color = fg.copy(alpha = 0.7f),1438 fontFamily = PeachType.Mono,1439 fontSize = 10.sp,1440 modifier = Modifier.padding(top = 6.dp),1441 )14421443 AnimatedVisibility(enabled) {1444 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1445 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1446 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }14471448 Text(1449 "When your duress password is entered at the lock screen, Peach will:",1450 color = fg.copy(alpha = 0.7f),1451 fontFamily = PeachType.Mono,1452 fontSize = 10.sp,1453 modifier = Modifier.padding(top = 4.dp),1454 )14551456 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1457 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }14581459 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }14601461 val canSave = password.isNotBlank() && confirm.isNotBlank()1462 DuressSaveButton("Save duress password", fg, canSave) {1463 error = when {1464 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1465 password != confirm -> "Passwords do not match"1466 else -> null1467 }1468 if (error == null) scope.launch {1469 if (vm.saveDuress(password, action)) {1470 password = ""; confirm = ""; enabled = false1471 } else {1472 error = "Couldn't save — the duress passphrase must differ from your real password"1473 }1474 }1475 }1476 }1477 }1478}14791480@Composable1481private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1482 val accent = LocalPeachAccent.current1483 Row(1484 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1485 verticalAlignment = Alignment.CenterVertically,1486 horizontalArrangement = Arrangement.spacedBy(10.dp),1487 ) {1488 Box(1489 Modifier.size(16.dp).clip(CircleShape)1490 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1491 contentAlignment = Alignment.Center,1492 ) {1493 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1494 }1495 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1496 }1497}14981499@Composable1500private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1501 val accent = LocalPeachAccent.current1502 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1503 Box(1504 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1505 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1506 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1507 .let { if (enabled) it.clickable(onClick = onClick) else it }1508 .padding(vertical = 11.dp),1509 contentAlignment = Alignment.Center,1510 ) {1511 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1512 }1513}15141515@Composable1516private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1517 Box(1518 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1519 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1520 .clickable(onClick = onClick)1521 .padding(vertical = 11.dp),1522 contentAlignment = Alignment.Center,1523 ) {1524 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1525 }1526}152715281529@Composable1530private fun SyncModeRow(1531 title: String,1532 detail: String,1533 selected: Boolean,1534 fg: Color,1535 onSelect: () -> Unit,1536) {1537 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1538 Column(Modifier.weight(1f)) {1539 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1540 Text(1541 detail,1542 color = fg.copy(alpha = 0.7f),1543 fontFamily = PeachType.Body,1544 fontSize = 11.sp,1545 lineHeight = 16.sp,1546 modifier = Modifier.padding(top = 2.dp),1547 )1548 }1549 Text(1550 if (selected) "●" else "○",1551 color = if (selected) fg else fg.copy(alpha = 0.45f),1552 fontFamily = PeachType.Mono,1553 fontSize = 14.sp,1554 modifier = Modifier.padding(start = 10.dp),1555 )1556 }1557}