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.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.shape.CircleShape24import androidx.compose.foundation.shape.RoundedCornerShape25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.runtime.Composable27import androidx.compose.material3.CircularProgressIndicator28import androidx.compose.runtime.DisposableEffect29import androidx.compose.runtime.collectAsState30import androidx.compose.runtime.getValue31import com.peachpasswords.feedback.FeedbackState32import com.peachpasswords.shared.PeachClipboard33import com.peachpasswords.sync.SyncMode34import com.peachpasswords.sync.SyncStatus35import com.peachpasswords.ui.theme.LocalPeachAccent36import com.peachpasswords.vault.DuressAction37import com.peachpasswords.ui.theme.peachColors38import androidx.compose.runtime.mutableStateOf39import androidx.compose.runtime.remember40import androidx.compose.runtime.rememberCoroutineScope41import androidx.compose.runtime.setValue42import androidx.compose.ui.Alignment43import androidx.compose.ui.Modifier44import androidx.compose.ui.draw.clip45import androidx.compose.ui.graphics.Color46import androidx.compose.ui.graphics.SolidColor47import androidx.compose.ui.platform.LocalContext48import androidx.compose.ui.text.TextStyle49import androidx.compose.ui.text.font.FontStyle50import androidx.compose.ui.text.input.PasswordVisualTransformation51import androidx.compose.ui.unit.Dp52import androidx.compose.ui.unit.dp53import androidx.compose.ui.unit.em54import androidx.compose.ui.unit.sp55import androidx.compose.material3.Text56import com.peachpasswords.BuildConfig57import com.peachpasswords.autofill.AutofillEnablement58import com.peachpasswords.ui.PeachNav59import com.peachpasswords.ui.components.PeachEmptyOrEndState60import com.peachpasswords.ui.components.PeachField61import com.peachpasswords.ui.sync.SyncViewModel62import com.peachpasswords.ui.theme.ACCENT_COLORS63import com.peachpasswords.ui.theme.PeachSpacing64import com.peachpasswords.ui.theme.PeachType65import com.peachpasswords.ui.updates.UpdatePhase66import com.peachpasswords.ui.updates.UpdateViewModel67import androidx.hilt.navigation.compose.hiltViewModel68import com.peachpasswords.vault.VaultSettings69import com.peachpasswords.vault.VaultSnapshot70import kotlinx.coroutines.launch71import kotlinx.coroutines.delay72import java.text.SimpleDateFormat73import java.util.Date74import java.util.Locale7576private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"777879808182838485@Composable86fun CardBody(87 id: CardId,88 fg: Color,89 vm: SettingsViewModel,90 settings: VaultSettings,91 entriesCount: Int,92 onNavigate: (PeachNav) -> Unit,93) {94 when (id) {95 CardId.Appearance -> AppearanceBody(fg, vm, settings)96 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)97 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)98 CardId.Sync -> SyncBody(fg, onNavigate)99 CardId.Pro -> ProBody(fg, vm)100 CardId.Feedback -> {101 102 103 AboutUpdatesBody(fg, onNavigate)104 FeedbackBody(fg, vm)105 }106 }107}108109110111@Composable112private fun AboutUpdatesBody(fg: Color, onNavigate: (PeachNav) -> Unit) {113 val vm: UpdateViewModel = hiltViewModel()114 val available by vm.available.collectAsState()115 val state by vm.state.collectAsState()116117 SubLabel("THIS BUILD", fg, top = 0.dp)118 RowLine(fg) {119 Name("Version", fg)120 ValueText(BuildConfig.VERSION_NAME, fg)121 }122 RowLine(fg) {123 Column(Modifier.weight(1f)) {124 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)125 Text(126 when {127 available != null -> "v${available?.version} is available"128 state.phase == UpdatePhase.Checking -> "Checking…"129 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"130 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"131 else -> "Peach checks once a day and tells you when there's a new version."132 },133 color = fg.copy(alpha = 0.7f),134 fontFamily = PeachType.Body,135 fontSize = 11.sp,136 lineHeight = 16.sp,137 modifier = Modifier.padding(top = 2.dp),138 )139 }140 if (available != null) {141 SubtleButton("update", fg) { onNavigate(PeachNav.Update) }142 } else {143 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {144 vm.check()145 }146 }147 }148}149150151152@Composable153private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {154 Text(155 text,156 modifier = Modifier.padding(top = top, bottom = 4.dp),157 color = fg.copy(alpha = 0.58f),158 fontFamily = PeachType.Mono,159 fontSize = 8.sp,160 letterSpacing = 0.22.em,161 )162}163164@Composable165private fun RowLine(166 fg: Color,167 modifier: Modifier = Modifier,168 verticalPadding: Dp = 9.dp,169 content: @Composable RowScope.() -> Unit,170) {171 Row(172 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),173 verticalAlignment = Alignment.CenterVertically,174 horizontalArrangement = Arrangement.SpaceBetween,175 content = content,176 )177 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))178}179180@Composable181private fun RowScope.Name(text: String, fg: Color) {182 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))183}184185@Composable186private fun ValueText(text: String, fg: Color) {187 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp)188}189190@Composable191private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {192 Box(193 Modifier.clip(RoundedCornerShape(8.dp))194 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))195 .background(fg.copy(alpha = 0.08f))196 .clickable(onClick = onClick)197 .padding(horizontal = 12.dp, vertical = 6.dp),198 ) {199 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)200 }201}202203@Composable204private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {205 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")206 Box(207 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))208 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))209 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))210 .clickable { onToggle(!on) },211 ) {212 Box(213 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)214 .padding(start = knob - 2.dp)215 .size(14.dp).clip(CircleShape)216 .background(if (on) fg else fg.copy(alpha = 0.58f)),217 )218 }219}220221@Composable222private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {223 Box(224 Modifier.clip(RoundedCornerShape(999.dp))225 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))226 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)227 .clickable(onClick = onClick)228 .padding(horizontal = 12.dp, vertical = 5.dp),229 ) {230 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)231 }232}233234@Composable235private fun ProBadge(fg: Color) {236 Box(237 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))238 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),239 ) {240 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)241 }242}243244@Composable245private fun Hint(text: String, fg: Color) {246 Text(247 text,248 modifier = Modifier.padding(top = 6.dp),249 color = fg.copy(alpha = 0.6f),250 fontFamily = PeachType.Display,251 fontStyle = FontStyle.Italic,252 fontSize = 11.5.sp,253 lineHeight = 16.sp,254 )255}256257258259@OptIn(ExperimentalLayoutApi::class)260@Composable261private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {262 SubLabel("ACCENT", fg, top = 0.dp)263 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {264 ACCENT_COLORS.forEach { (name, color) ->265 Box(266 Modifier.size(22.dp).clip(CircleShape).background(color)267 .border(268 if (s.accentColor == name) 2.dp else 1.dp,269 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),270 CircleShape,271 )272 .clickable { vm.setAccent(name) },273 )274 }275 }276 SubLabel("MOTION", fg)277 RowLine(fg) {278 Name("Reduce motion", fg)279 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }280 }281}282283284285@Composable286private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {287 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")288 val current = s.autoLockMinutes ?: 0289290 SubLabel("LOCKING", fg, top = 0.dp)291 RowLine(fg) {292 Name("Auto-lock after", fg)293 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)294 SubtleButton(lockOptions[idx].second, fg) {295 val next = lockOptions[(idx + 1) % lockOptions.size].first296 vm.setAutoLockMinutes(if (next == 0) 0 else next)297 }298 }299 SubLabel("BIOMETRICS", fg)300 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity301 RowLine(fg) {302 Name("Unlock with biometrics", fg)303 Toggle(s.biometricsEnabled, fg) { on ->304 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()305 }306 }307 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)308 if (bioError != null) {309 Text(310 bioError,311 color = com.peachpasswords.ui.theme.peachColors.Breach,312 fontFamily = PeachType.Mono, fontSize = 10.sp,313 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),314 )315 }316 SubLabel("AUTOFILL", fg)317 run {318 319 320 val ctx = LocalContext.current321 val enabled = AutofillEnablement.isEnabled(ctx)322 RowLine(fg) {323 Column(Modifier.weight(1f)) {324 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)325 Text(326 if (enabled) {327 "Peach is your autofill service."328 } else {329 "Android decides which app fills passwords. Turn Peach on in system settings."330 },331 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,332 modifier = Modifier.padding(top = 2.dp),333 )334 }335 if (!enabled) {336 SubtleButton("enable", fg) {337 338 339 340 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }341 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }342 }343 } else {344 ValueText("on", fg)345 }346 }347 }348349 SubLabel("PASSKEYS", fg)350 RowLine(fg) {351 Column(Modifier.weight(1f)) {352 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)353 Text(354 "Peach can handle passkey sign-in and creation through Android Credential Manager.",355 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,356 modifier = Modifier.padding(top = 2.dp),357 )358 }359 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }360 }361 RowLine(fg) {362 Name("Status", fg)363 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)364 }365 SubLabel("RECOVERY", fg)366 RowLine(fg) {367 Column(Modifier.weight(1f)) {368 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)369 Text(370 "Keep the paper copy current before rotating sync identity or replacing devices.",371 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,372 modifier = Modifier.padding(top = 2.dp),373 )374 }375 SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }376 }377 SubLabel("BREACH PROTECTION", fg)378 val checkingBreaches by vm.checkingBreaches.collectAsState()379 val breachResult by vm.breachResult.collectAsState()380 RowLine(fg) {381 Name("Breach monitoring", fg)382 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }383 }384 RowLine(fg) {385 Name("Check all passwords", fg)386 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }387 }388 breachResult?.let { result ->389 RowLine(fg) {390 Name("Last check", fg)391 ValueText(result, fg)392 }393 }394395 SubLabel("MASTER PASSWORD", fg)396 ChangePasswordRow(fg, vm)397398 SubLabel("DURESS PASSWORD", fg)399 val duressIsPro by vm.isPro.collectAsState()400 if (!duressIsPro) {401 RowLine(fg) {402 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {403 Name("Duress password", fg); ProBadge(fg)404 }405 ValueText("off", fg)406 }407 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)408 } else {409 DuressSection(fg, vm)410 }411}412413@Composable414private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {415 var open by remember { mutableStateOf(false) }416 var current by remember { mutableStateOf("") }417 var next by remember { mutableStateOf("") }418 var confirm by remember { mutableStateOf("") }419 var error by remember { mutableStateOf<String?>(null) }420 var done by remember { mutableStateOf(false) }421 val scope = rememberCoroutineScope()422423 RowLine(fg) {424 Name("Master password", fg)425 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }426 }427 AnimatedVisibility(open) {428 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {429 PwField("current password", current, fg) { current = it }430 PwField("new password (min 12 chars)", next, fg) { next = it }431 PwField("confirm new password", confirm, fg) { confirm = it }432 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }433 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)434 SubtleButton("save new password", fg) {435 error = when {436 current.isBlank() -> "enter your current password"437 next.length < 12 -> "new password must be at least 12 characters"438 next != confirm -> "new passwords do not match"439 next == current -> "new password must differ from current"440 else -> null441 }442 if (error == null) scope.launch {443 if (vm.changePassword(current, next)) {444 current = ""; next = ""; confirm = ""; open = false; done = true445 } else error = "incorrect current password"446 }447 }448 }449 }450}451452@Composable453private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {454 PeachField(455 label = placeholder,456 value = value,457 onValueChange = onChange,458 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,459 visualTransformation = PasswordVisualTransformation(),460 )461}462463464465@Composable466private fun VaultBody(467 fg: Color,468 vm: SettingsViewModel,469 s: VaultSettings,470 entriesCount: Int,471 onNavigate: (PeachNav) -> Unit,472) {473 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")474 val current = s.trashRetentionDays ?: 0475 val isPro by vm.isPro.collectAsState()476 val dedupCount by vm.dedupCount.collectAsState()477 val dedupRunning by vm.dedupRunning.collectAsState()478 val dedupDone by vm.dedupDone.collectAsState()479 val history by vm.vaultHistory.collectAsState()480 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()481482 SubLabel("ENTRIES", fg, top = 0.dp)483 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }484 RowLine(fg) {485 Name("Duplicates", fg)486 val done = dedupDone487 if (done != null) {488 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)489 } else {490 val count = dedupCount491 val label = when {492 dedupRunning -> "scanning..."493 count == null -> "scan"494 count == 0 -> "none found"495 else -> "$count found"496 }497 SubtleButton(label, fg) {498 if (count == null || count == 0) vm.scanDuplicates()499 }500 }501 }502 if ((dedupCount ?: 0) > 0 && dedupDone == null) {503 Column(504 Modifier.fillMaxWidth()505 .padding(top = 2.dp, bottom = 8.dp)506 .clip(RoundedCornerShape(8.dp))507 .background(fg.copy(alpha = 0.08f))508 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))509 .padding(10.dp),510 verticalArrangement = Arrangement.spacedBy(8.dp),511 ) {512 val count = dedupCount ?: 0513 Text(514 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",515 color = fg.copy(alpha = 0.82f),516 fontFamily = PeachType.Body,517 fontSize = 11.sp,518 lineHeight = 16.sp,519 )520 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {521 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }522 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }523 }524 }525 }526527 SubLabel("MAINTENANCE", fg)528 RowLine(fg) {529 Name("Trash auto-delete", fg)530 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)531 SubtleButton(trashOptions[idx].second, fg) {532 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)533 }534 }535 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }536 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }537538 SubLabel("DATA", fg)539 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }540 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }541 RowLine(fg) {542 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }543 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }544 }545 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }546547 var historyShowAll by remember { mutableStateOf(false) }548 SubLabel("VAULT HISTORY", fg)549 if (history.isEmpty()) {550 Text(551 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",552 color = fg.copy(alpha = 0.68f),553 fontFamily = PeachType.Body,554 fontSize = 11.sp,555 lineHeight = 16.sp,556 modifier = Modifier.padding(bottom = 6.dp),557 )558 } else {559 Text(560 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",561 color = fg.copy(alpha = 0.68f),562 fontFamily = PeachType.Body,563 fontSize = 11.sp,564 lineHeight = 16.sp,565 modifier = Modifier.padding(bottom = 4.dp),566 )567 history.take(if (historyShowAll) history.size else 3).forEach { snap ->568 RowLine(fg) {569 Column(Modifier.weight(1f)) {570 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)571 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)572 }573 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {574 vm.restoreVaultSnapshot(snap.id)575 }576 }577 }578 if (history.size > 3) {579 RowLine(fg) {580 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)581 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }582 }583 }584 Text(585 "${history.size} of 5 snapshots",586 color = fg.copy(alpha = 0.58f),587 fontFamily = PeachType.Mono,588 fontSize = 10.sp,589 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),590 )591 }592593 var advancedOpen by remember { mutableStateOf(false) }594 SubLabel("ADVANCED", fg)595 RowLine(fg) {596 Name("Advanced", fg)597 SubtleButton(if (advancedOpen) "hide" else "show", fg) { advancedOpen = !advancedOpen }598 }599 if (advancedOpen) {600 RowLine(fg) {601 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)602 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }603 }604 RowLine(fg) {605 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)606 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }607 }608 }609}610611private fun snapshotSubLabel(snapshot: VaultSnapshot): String {612 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }613 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")614}615616private fun snapshotReasonLabel(reason: String): String = when (reason) {617 "local_save" -> "saved"618 "sync_received" -> "synced"619 "import" -> "imported"620 "restore" -> "restored"621 else -> reason622}623624private fun snapshotTimeLabel(savedAt: Long): String {625 val diff = System.currentTimeMillis() - savedAt626 if (diff < 60_000) return "Just now"627 if (diff < 3_600_000) return "${diff / 60_000}m ago"628 val date = Date(savedAt)629 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())630 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)631 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)632 if (today == thenDay) return "Today $time"633 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))634 if (yesterday == thenDay) return "Yesterday $time"635 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)636}637638639640@Composable641private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {642 val syncVm: SyncViewModel = hiltViewModel()643 val pairedDevices by syncVm.pairedDevices.collectAsState()644 val pendingVouched by syncVm.pendingVouched.collectAsState()645 val syncStatus by syncVm.status.collectAsState()646 val configuredRelayUrl by syncVm.relayUrl.collectAsState()647 val syncMode by syncVm.syncMode.collectAsState()648 val scope = rememberCoroutineScope()649 var confirmUnpair by remember { mutableStateOf<String?>(null) }650 var rotateConfirmOpen by remember { mutableStateOf(false) }651 var rotating by remember { mutableStateOf(false) }652 var rotateError by remember { mutableStateOf<String?>(null) }653 var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(configuredRelayUrl) }654 var relayUrlStatus by remember { mutableStateOf<String?>(null) }655656 657 658 659 SubLabel("PAIR A DEVICE", fg, top = 0.dp)660 RowLine(fg) {661 Column(Modifier.weight(1f)) {662 Row(verticalAlignment = Alignment.CenterVertically) {663 Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)664 }665 Text(666 "End-to-end encrypted, through a relay that can read none of it.",667 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,668 modifier = Modifier.padding(top = 2.dp),669 )670 }671 }672 RowLine(fg) {673 Column(Modifier.weight(1f)) {674 Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)675 Text(676 "For the other device to scan. Always use this one to pair a browser.",677 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,678 modifier = Modifier.padding(top = 2.dp),679 )680 }681 SubtleButton("show", fg) { onNavigate(PeachNav.CodePair) }682 }683 RowLine(fg) {684 Column(Modifier.weight(1f)) {685 Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)686 Text(687 "Joins that vault. Your entries are kept and the two merge; this phone's " +688 "recovery words are replaced, and shown to you first.",689 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,690 modifier = Modifier.padding(top = 2.dp),691 )692 }693 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }694 }695696 SubLabel("PAIRED DEVICES", fg)697 if (pairedDevices.isEmpty()) {698 PeachEmptyOrEndState(699 message = "No paired devices yet.",700 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",701 modifier = Modifier.padding(top = PeachSpacing.xs),702 )703 } else {704 pairedDevices.forEach { d ->705 RowLine(fg) {706 Column(Modifier.weight(1f)) {707 Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)708 Text(709 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",710 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,711 modifier = Modifier.padding(top = 2.dp),712 )713 }714 if (confirmUnpair != d.deviceId) {715 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }716 }717 }718 719 720 721 722 723 if (confirmUnpair == d.deviceId) {724 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {725 Text(726 "Stops this device receiving future changes. It doesn't lock it " +727 "out of what it already has - if it's lost or stolen, also " +728 "change your master password and generate new recovery words.",729 color = fg.copy(alpha = 0.7f),730 fontFamily = PeachType.Body,731 fontSize = 11.sp,732 lineHeight = 16.sp,733 )734 Row(735 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),736 horizontalArrangement = Arrangement.End,737 ) {738 SubtleButton("cancel", fg) { confirmUnpair = null }739 Spacer(Modifier.width(PeachSpacing.sm))740 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }741 }742 }743 }744 }745 RowLine(fg) {746 ValueText(syncFlashLabel(syncStatus), fg)747 SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {748 if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()749 }750 }751 }752753 754 755 756 757 758 SubLabel("HOW THIS VAULT SYNCS", fg)759 SyncModeRow(760 title = "Public relay",761 detail = "Peach's own relay. Works anywhere, nothing to set up. It never sees " +762 "your vault — everything is encrypted before it leaves this device.",763 selected = syncMode == SyncMode.PUBLIC,764 fg = fg,765 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }766 SyncModeRow(767 title = "Private relay",768 detail = "Your own peach-relay, on your own domain. Same open-source code.",769 selected = syncMode == SyncMode.PRIVATE,770 fg = fg,771 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }772773 if (syncMode == SyncMode.PRIVATE) {774 Text(775 "RELAY URL",776 color = fg.copy(alpha = 0.6f),777 fontFamily = PeachType.Mono,778 fontSize = 8.sp,779 letterSpacing = 0.22.em,780 modifier = Modifier.padding(top = 8.dp),781 )782 Box(783 Modifier.fillMaxWidth().padding(top = 6.dp)784 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))785 .padding(horizontal = 12.dp, vertical = 10.dp),786 ) {787 BasicTextField(788 value = relayUrlDraft,789 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },790 singleLine = true,791 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),792 cursorBrush = SolidColor(fg),793 modifier = Modifier.fillMaxWidth(),794 )795 }796 RowLine(fg) {797 ValueText(relayUrlStatus ?: "Only secure wss:// relays are accepted.", fg)798 SubtleButton("save", fg) {799 relayUrlStatus = if (syncVm.setPrivateRelayUrl(relayUrlDraft)) {800 relayUrlDraft = syncVm.relayUrl.value801 "Saved"802 } else {803 "Use a valid wss:// relay URL"804 }805 }806 }807 }808809 SyncModeRow(810 title = "No sync",811 detail = "This device keeps to itself. Move your vault by hand with Codex or " +812 "CSV export and import.",813 selected = syncMode == SyncMode.OFF,814 fg = fg,815 ) { syncVm.setSyncMode(SyncMode.OFF) }816817 if (pendingVouched.isNotEmpty()) {818 SubLabel("PENDING DEVICES", fg)819 pendingVouched.forEach { dev ->820 RowLine(fg) {821 Column(Modifier.weight(1f)) {822 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)823 Text(824 "Was added by another of your devices. Allow it to sync with this vault?",825 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,826 modifier = Modifier.padding(top = 2.dp),827 )828 }829 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {830 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }831 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }832 }833 }834 }835 }836837 SubLabel("RECOVERY", fg)838 if (rotateConfirmOpen) {839 Hint("This will disconnect all paired devices and your 12 recovery words will change. You'll be shown the new words next.", peachColors.Breach)840 rotateError?.let {841 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))842 }843 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {844 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }845 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {846 if (rotating) return@SubtleButton847 rotating = true848 rotateError = null849 scope.launch {850 val ok = syncVm.rotateSyncIdentity()851 rotating = false852 if (ok) {853 rotateConfirmOpen = false854 onNavigate(PeachNav.RecoverySetup)855 } else {856 rotateError = "Rotation failed. Unlock your real vault and try again."857 }858 }859 }860 }861 } else {862 RowLine(fg) {863 Column(Modifier.weight(1f)) {864 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)865 Text(866 "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.",867 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,868 modifier = Modifier.padding(top = 2.dp),869 )870 }871 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }872 }873 }874875 Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)876}877878private fun deviceTypeLabel(type: String?): String = when (type) {879 "android" -> "Android"880 "phone" -> "Phone"881 "tablet" -> "Tablet"882 "browser" -> "Browser"883 else -> "Device"884}885886private fun syncTimeLabel(lastSyncedAt: Long?): String {887 if (lastSyncedAt == null) return "never synced"888 val diff = System.currentTimeMillis() - lastSyncedAt889 if (diff < 60_000) return "just now"890 if (diff < 3_600_000) return "${diff / 60_000}m ago"891 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"892 return "${diff / 86_400_000}d ago"893}894895private fun isSyncStale(lastSyncedAt: Long?): Boolean =896 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L897898private fun syncFlashLabel(status: SyncStatus): String = when (status) {899 SyncStatus.Idle -> ""900 SyncStatus.Syncing -> "syncing..."901 SyncStatus.Offline -> "offline"902 is SyncStatus.Error -> status.message903 is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}"904}905906907908@Composable909private fun ProBody(fg: Color, vm: SettingsViewModel) {910 val ctx = LocalContext.current911 val isPro by vm.isPro.collectAsState()912 if (isPro) {913 ProActiveBody(fg, vm)914 return915 }916 val features = listOf(917 "Peach Sync" to "Encrypted · peer-to-peer",918 "Peach Codex" to "Restore from paper",919 "Passkeys" to "Zero-knowledge WebAuthn",920 "Advanced Vault Health" to "Reuse · age · exposure",921 "Canary entries" to "Detects vault compromise",922 "Duress vault" to "Decoy under coercion",923 )924 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)925 Text(926 "Everything, everywhere.",927 modifier = Modifier.padding(top = 8.dp),928 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,929 )930 Text(931 "One key unlocks every Pro feature — for the lifetime of the app.",932 modifier = Modifier.padding(top = 6.dp),933 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,934 )935 Spacer(Modifier.height(10.dp))936 features.forEachIndexed { i, (name, desc) ->937 938 939 940 941 RowLine(fg, verticalPadding = 6.dp) {942 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {943 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))944 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)945 }946 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)947 }948 }949 Spacer(Modifier.height(12.dp))950 Text(951 "$39",952 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,953 )954 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)955 Spacer(Modifier.height(10.dp))956 Box(957 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))958 .background(fg.copy(alpha = 0.16f))959 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))960 .clickable {961 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }962 }963 .padding(vertical = 12.dp),964 contentAlignment = Alignment.Center,965 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }966967 968 969 970 971 Spacer(Modifier.height(44.dp))972973 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)974 var key by remember { mutableStateOf("") }975 val activating by vm.activating.collectAsState()976 val activationError by vm.activationError.collectAsState()977 Box(978 Modifier.fillMaxWidth().padding(top = 6.dp)979 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))980 .padding(horizontal = 12.dp, vertical = 10.dp),981 ) {982 BasicTextField(983 value = key,984 985 986 987 988 onValueChange = { key = it; vm.clearActivationError() },989 singleLine = true,990 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),991 cursorBrush = SolidColor(fg),992 modifier = Modifier.fillMaxWidth(),993 decorationBox = { inner ->994 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)995 inner()996 },997 )998 }999 if (activationError != null) {1000 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1001 }1002 Spacer(Modifier.height(8.dp))1003 Box(1004 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1005 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1006 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1007 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1008 .padding(vertical = 12.dp),1009 contentAlignment = Alignment.Center,1010 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1011}10121013@Composable1014private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1015 val ctx = LocalContext.current1016 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1017 val licenseKey by vm.licenseKey.collectAsState()1018 val issuedAt by vm.licenseIssuedAt.collectAsState()1019 var showKey by remember { mutableStateOf(false) }1020 var copied by remember { mutableStateOf(false) }1021 val scope = rememberCoroutineScope()1022 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1023 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1024 }1025 Text(1026 "Peach Pro",1027 modifier = Modifier.fillMaxWidth(),1028 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1029 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1030 )1031 Text(1032 formatLicenseActiveSince(issuedAt),1033 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1034 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1035 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1036 )1037 licenseKey?.let { key ->1038 Spacer(Modifier.height(12.dp))1039 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1040 RowLine(fg) {1041 Text(1042 if (showKey) key else "************************",1043 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1044 fontFamily = PeachType.Mono,1045 fontSize = 10.sp,1046 modifier = Modifier.weight(1f),1047 )1048 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1049 SubtleButton(if (copied) "copied" else "copy", fg) {1050 PeachClipboard.copy(ctx, "peach license", key)1051 copied = true1052 scope.launch {1053 delay(1_500)1054 copied = false1055 }1056 }1057 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1058 }1059 }1060 }1061 Spacer(Modifier.height(12.dp))1062 features.forEachIndexed { i, name ->1063 RowLine(fg) {1064 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1065 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1066 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1067 }1068 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1069 }1070 }1071 Spacer(Modifier.height(12.dp))1072 Box(1073 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1074 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1075 .clickable { vm.deactivate() }1076 .padding(vertical = 12.dp),1077 contentAlignment = Alignment.Center,1078 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1079}10801081private fun formatLicenseActiveSince(issuedAt: Long?): String {1082 if (issuedAt == null) return "Active"1083 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1084 return "Active since $monthYear"1085}1086108710881089@OptIn(ExperimentalLayoutApi::class)1090@Composable1091private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1092 val categories = listOf("Bug report", "Feature request", "Question", "Other")1093 var category by remember { mutableStateOf(categories[0]) }1094 var message by remember { mutableStateOf("") }1095 val state by vm.feedbackState.collectAsState()10961097 1098 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }10991100 if (state is FeedbackState.Sent) {1101 Box(1102 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1103 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1104 ) {1105 Column {1106 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1107 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))1108 }1109 }1110 return1111 }11121113 val sending = state is FeedbackState.Sending11141115 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)1116 SubLabel("TYPE", fg)1117 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1118 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1119 }1120 SubLabel("MESSAGE", fg)1121 Box(1122 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1123 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1124 .padding(10.dp),1125 ) {1126 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1127 BasicTextField(1128 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1129 enabled = !sending,1130 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1131 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1132 )1133 }1134 Text(1135 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1136 modifier = Modifier.padding(top = 6.dp),1137 color = fg.copy(alpha = 0.52f),1138 fontFamily = PeachType.Mono,1139 fontSize = 10.sp,1140 lineHeight = 14.sp,1141 )1142 Spacer(Modifier.height(8.dp))11431144 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1145 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1146 Box(1147 Modifier.clip(RoundedCornerShape(8.dp))1148 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1149 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1150 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1151 .padding(horizontal = 12.dp, vertical = 6.dp),1152 ) {1153 Text(1154 feedbackSubmitLabel(state),1155 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1156 fontFamily = PeachType.Mono, fontSize = 11.sp,1157 )1158 }1159 if (sending) {1160 CircularProgressIndicator(1161 color = LocalPeachAccent.current,1162 strokeWidth = 2.dp,1163 modifier = Modifier.size(16.dp),1164 )1165 }1166 }11671168 (state as? FeedbackState.Error)?.let { err ->1169 Text(1170 err.message,1171 modifier = Modifier.padding(top = 8.dp),1172 color = peachColors.Breach,1173 fontFamily = PeachType.Mono,1174 fontSize = 11.sp,1175 lineHeight = 15.sp,1176 )1177 }11781179 Hint("Sent anonymously · no account required", fg)1180}1181118211831184@Composable1185internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1186 val configured by vm.duressConfigured.collectAsState()1187 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1188}11891190@Composable1191private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1192 Text(1193 "Duress password is active",1194 color = peachColors.HealthOk,1195 fontFamily = PeachType.Mono,1196 fontSize = 11.sp,1197 modifier = Modifier.padding(top = 6.dp),1198 )1199 Spacer(Modifier.height(8.dp))1200 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1201 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1202}12031204@Composable1205private fun DuressForm(fg: Color, vm: SettingsViewModel) {1206 var enabled by remember { mutableStateOf(false) }1207 var password by remember { mutableStateOf("") }1208 var confirm by remember { mutableStateOf("") }1209 var action by remember { mutableStateOf(DuressAction.DECOY) }1210 var error by remember { mutableStateOf<String?>(null) }1211 val scope = rememberCoroutineScope()12121213 RowLine(fg) {1214 Name("Duress password", fg)1215 Toggle(enabled, fg) { on ->1216 enabled = on1217 if (!on) { password = ""; confirm = ""; error = null }1218 }1219 }1220 Text(1221 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1222 color = fg.copy(alpha = 0.7f),1223 fontFamily = PeachType.Mono,1224 fontSize = 10.sp,1225 modifier = Modifier.padding(top = 6.dp),1226 )12271228 AnimatedVisibility(enabled) {1229 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1230 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1231 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }12321233 Text(1234 "When your duress password is entered at the lock screen, Peach will:",1235 color = fg.copy(alpha = 0.7f),1236 fontFamily = PeachType.Mono,1237 fontSize = 10.sp,1238 modifier = Modifier.padding(top = 4.dp),1239 )12401241 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1242 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }12431244 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }12451246 val canSave = password.isNotBlank() && confirm.isNotBlank()1247 DuressSaveButton("Save duress password", fg, canSave) {1248 error = when {1249 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1250 password != confirm -> "Passwords do not match"1251 else -> null1252 }1253 if (error == null) scope.launch {1254 if (vm.saveDuress(password, action)) {1255 password = ""; confirm = ""; enabled = false1256 } else {1257 error = "Couldn't save — the duress passphrase must differ from your real password"1258 }1259 }1260 }1261 }1262 }1263}12641265@Composable1266private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1267 val accent = LocalPeachAccent.current1268 Row(1269 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1270 verticalAlignment = Alignment.CenterVertically,1271 horizontalArrangement = Arrangement.spacedBy(10.dp),1272 ) {1273 Box(1274 Modifier.size(16.dp).clip(CircleShape)1275 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1276 contentAlignment = Alignment.Center,1277 ) {1278 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1279 }1280 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1281 }1282}12831284@Composable1285private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1286 val accent = LocalPeachAccent.current1287 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1288 Box(1289 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1290 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1291 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1292 .let { if (enabled) it.clickable(onClick = onClick) else it }1293 .padding(vertical = 11.dp),1294 contentAlignment = Alignment.Center,1295 ) {1296 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1297 }1298}12991300@Composable1301private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1302 Box(1303 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1304 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1305 .clickable(onClick = onClick)1306 .padding(vertical = 11.dp),1307 contentAlignment = Alignment.Center,1308 ) {1309 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1310 }1311}131213131314@Composable1315private fun SyncModeRow(1316 title: String,1317 detail: String,1318 selected: Boolean,1319 fg: Color,1320 onSelect: () -> Unit,1321) {1322 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1323 Column(Modifier.weight(1f)) {1324 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1325 Text(1326 detail,1327 color = fg.copy(alpha = 0.7f),1328 fontFamily = PeachType.Body,1329 fontSize = 11.sp,1330 lineHeight = 16.sp,1331 modifier = Modifier.padding(top = 2.dp),1332 )1333 }1334 Text(1335 if (selected) "●" else "○",1336 color = if (selected) fg else fg.copy(alpha = 0.45f),1337 fontFamily = PeachType.Mono,1338 fontSize = 14.sp,1339 modifier = Modifier.padding(start = 10.dp),1340 )1341 }1342}