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.material3.AlertDialog30import androidx.compose.runtime.DisposableEffect31import androidx.compose.runtime.collectAsState32import androidx.compose.runtime.getValue33import com.peachpasswords.feedback.FeedbackState34import com.peachpasswords.shared.PeachClipboard35import com.peachpasswords.sync.SyncMode36import com.peachpasswords.sync.canShowBackgroundSyncNotification37import com.peachpasswords.sync.describeSyncOutcome38import com.peachpasswords.sync.privateRelayDraftOf39import com.peachpasswords.sync.syncIndicator40import com.peachpasswords.sync.syncIndicatorLabel41import com.peachpasswords.ui.components.SyncIndicatorDot42import com.peachpasswords.ui.theme.LocalPeachAccent43import com.peachpasswords.vault.DuressAction44import com.peachpasswords.ui.theme.peachColors45import androidx.compose.runtime.mutableStateOf46import androidx.compose.runtime.remember47import androidx.compose.runtime.rememberCoroutineScope48import androidx.compose.runtime.setValue49import androidx.compose.ui.Alignment50import androidx.compose.ui.Modifier51import androidx.compose.ui.draw.clip52import androidx.compose.ui.graphics.Color53import androidx.compose.ui.graphics.SolidColor54import androidx.compose.ui.platform.LocalContext55import androidx.compose.ui.text.TextStyle56import androidx.compose.ui.text.font.FontStyle57import androidx.compose.ui.text.input.PasswordVisualTransformation58import androidx.compose.ui.text.input.KeyboardType59import androidx.compose.ui.unit.Dp60import androidx.compose.ui.unit.dp61import androidx.compose.ui.unit.em62import androidx.compose.ui.unit.sp63import androidx.compose.material3.Text64import com.peachpasswords.BuildConfig65import com.peachpasswords.autofill.AutofillEnablement66import com.peachpasswords.ui.PeachNav67import com.peachpasswords.ui.components.PeachEmptyOrEndState68import com.peachpasswords.ui.components.PeachField69import com.peachpasswords.ui.pairing.BackgroundSyncDialog70import com.peachpasswords.ui.sync.SyncViewModel71import com.peachpasswords.ui.theme.ACCENT_COLORS72import com.peachpasswords.ui.theme.PeachSpacing73import com.peachpasswords.ui.theme.PeachType74import com.peachpasswords.ui.updates.ChangelogPhase75import com.peachpasswords.ui.updates.UpdatePhase76import com.peachpasswords.ui.updates.UpdateViewModel77import com.peachpasswords.updates.compareVersions78import androidx.hilt.navigation.compose.hiltViewModel79import com.peachpasswords.vault.VaultSettings80import com.peachpasswords.vault.VaultSnapshot81import com.peachpasswords.vault.faviconsAllowed82import kotlinx.coroutines.launch83import kotlinx.coroutines.delay84import java.text.SimpleDateFormat85import java.util.Date86import java.util.Locale8788private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8990internal const val FAVICON_DISCLOSURE_TEXT =91 "Peach requests site icons from DuckDuckGo. Turning this on discloses your full vault domain inventory to DuckDuckGo as icons are requested."9293949596979899100@Composable101fun CardBody(102 id: CardId,103 fg: Color,104 vm: SettingsViewModel,105 settings: VaultSettings,106 entriesCount: Int,107 onNavigate: (PeachNav) -> Unit,108) {109 when (id) {110 CardId.Appearance -> AppearanceBody(fg, vm, settings)111 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)112 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)113 CardId.Sync -> SyncBody(fg, onNavigate)114 CardId.Pro -> ProBody(fg, vm)115 CardId.Feedback -> FeedbackBody(fg, vm)116 }117}118119120121@Composable122private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {123 Text(124 text,125 modifier = Modifier.padding(top = top, bottom = 4.dp),126 color = fg.copy(alpha = 0.58f),127 fontFamily = PeachType.Mono,128 fontSize = 8.sp,129 letterSpacing = 0.22.em,130 )131}132133134135136137138@Composable139private fun SettingsGroup(140 label: String,141 fg: Color,142 initiallyOpen: Boolean = true,143 top: Dp = 14.dp,144 content: @Composable () -> Unit,145) {146 var open by remember { mutableStateOf(initiallyOpen) }147 Row(148 Modifier.fillMaxWidth()149 .clip(RoundedCornerShape(6.dp))150 .clickable { open = !open }151 .padding(top = top, bottom = 4.dp),152 verticalAlignment = Alignment.CenterVertically,153 ) {154 Text(155 label,156 modifier = Modifier.weight(1f),157 color = fg.copy(alpha = 0.58f),158 fontFamily = PeachType.Mono,159 fontSize = 8.sp,160 letterSpacing = 0.22.em,161 )162 Text(163 if (open) "–" else "+",164 color = fg.copy(alpha = 0.58f),165 fontFamily = PeachType.Mono,166 fontSize = 10.sp,167 )168 }169 170 171 AnimatedVisibility(visible = open) {172 Column(Modifier.fillMaxWidth()) { content() }173 }174}175176@Composable177private fun RowLine(178 fg: Color,179 modifier: Modifier = Modifier,180 verticalPadding: Dp = 9.dp,181 content: @Composable RowScope.() -> Unit,182) {183 Row(184 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),185 verticalAlignment = Alignment.CenterVertically,186 horizontalArrangement = Arrangement.SpaceBetween,187 content = content,188 )189 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))190}191192@Composable193private fun RowScope.Name(text: String, fg: Color) {194 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))195}196197@Composable198private fun ValueText(text: String, fg: Color, modifier: Modifier = Modifier) {199 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier)200}201202@Composable203private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {204 Box(205 Modifier.clip(RoundedCornerShape(8.dp))206 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))207 .background(fg.copy(alpha = 0.08f))208 .clickable(onClick = onClick)209 .padding(horizontal = 12.dp, vertical = 6.dp),210 ) {211 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1)212 }213}214215@Composable216private fun SyncDisclosure(217 title: String,218 summary: String,219 expanded: Boolean,220 fg: Color,221 onToggle: () -> Unit,222) {223 Row(224 modifier = Modifier225 .fillMaxWidth()226 .heightIn(min = 48.dp)227 .clip(RoundedCornerShape(8.dp))228 .clickable(onClick = onToggle)229 .padding(vertical = 8.dp),230 verticalAlignment = Alignment.CenterVertically,231 ) {232 Column(Modifier.weight(1f)) {233 Text(234 title.uppercase(),235 color = fg.copy(alpha = 0.58f),236 fontFamily = PeachType.Mono,237 fontSize = 8.sp,238 letterSpacing = 0.22.em,239 )240 Text(241 summary,242 color = fg.copy(alpha = 0.7f),243 fontFamily = PeachType.Body,244 fontSize = 11.sp,245 modifier = Modifier.padding(top = 3.dp),246 )247 }248 Text(249 if (expanded) "hide" else "show",250 color = fg,251 fontFamily = PeachType.Mono,252 fontSize = 10.sp,253 )254 }255}256257@Composable258private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {259 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")260 Box(261 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))262 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))263 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))264 .clickable { onToggle(!on) },265 ) {266 Box(267 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)268 .padding(start = knob - 2.dp)269 .size(14.dp).clip(CircleShape)270 .background(if (on) fg else fg.copy(alpha = 0.58f)),271 )272 }273}274275@Composable276private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {277 Box(278 Modifier.clip(RoundedCornerShape(999.dp))279 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))280 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)281 .clickable(onClick = onClick)282 .padding(horizontal = 12.dp, vertical = 5.dp),283 ) {284 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)285 }286}287288@Composable289private fun ProBadge(fg: Color) {290 Box(291 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))292 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),293 ) {294 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)295 }296}297298@Composable299private fun Hint(text: String, fg: Color) {300 Text(301 text,302 modifier = Modifier.padding(top = 6.dp),303 color = fg.copy(alpha = 0.6f),304 fontFamily = PeachType.Display,305 fontStyle = FontStyle.Italic,306 fontSize = 11.5.sp,307 lineHeight = 16.sp,308 )309}310311312313@OptIn(ExperimentalLayoutApi::class)314@Composable315private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {316 var showFaviconDisclosure by remember { mutableStateOf(false) }317 SubLabel("ACCENT", fg, top = 0.dp)318 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {319 ACCENT_COLORS.forEach { (name, color) ->320 Box(321 Modifier.size(22.dp).clip(CircleShape).background(color)322 .border(323 if (s.accentColor == name) 2.dp else 1.dp,324 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),325 CircleShape,326 )327 .clickable { vm.setAccent(name) },328 )329 }330 }331 SubLabel("TEXT SIZE", fg)332 RowLine(fg) {333 Name("Text size", fg)334 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {335 Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }336 Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }337 Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }338 }339 }340 SubLabel("UI SCALE", fg)341 RowLine(fg) {342 Name("Large-screen sizing", fg)343 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {344 Pill("compact", s.uiScale == "compact", fg) { vm.setUiScale("compact") }345 Pill("auto", s.uiScale == "auto", fg) { vm.setUiScale("auto") }346 Pill("large", s.uiScale == "large", fg) { vm.setUiScale("large") }347 }348 }349 Hint("Auto keeps foldables roomy while preventing tablet controls from inflating.", fg)350 SubLabel("ICONS", fg)351 RowLine(fg) {352 Name("Site icons", fg)353 val siteIconsAllowed = faviconsAllowed(s)354 SubtleButton(if (siteIconsAllowed) "On" else "Off", fg) {355 if (siteIconsAllowed) vm.setFaviconsEnabled(false) else showFaviconDisclosure = true356 }357 }358 if (showFaviconDisclosure) {359 AlertDialog(360 onDismissRequest = { showFaviconDisclosure = false },361 title = { Text("Enable site icons?", color = fg, fontFamily = PeachType.Body) },362 text = { Text(FAVICON_DISCLOSURE_TEXT, color = fg, fontFamily = PeachType.Body) },363 confirmButton = {364 SubtleButton("Enable", fg) {365 showFaviconDisclosure = false366 vm.setFaviconsEnabled(true)367 }368 },369 dismissButton = {370 SubtleButton("Cancel", fg) { showFaviconDisclosure = false }371 },372 )373 }374 SubLabel("MOTION", fg)375 RowLine(fg) {376 Name("Reduce motion", fg)377 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }378 }379}380381382383@Composable384private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {385 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")386 val current = s.autoLockMinutes ?: 0387 val pinEnabled by vm.pinEnabled.collectAsState()388 var pinOpen by remember { mutableStateOf(false) }389 var pinRemovalOpen by remember { mutableStateOf(false) }390 var pinMaster by remember { mutableStateOf("") }391 var pin by remember { mutableStateOf("") }392 var pinConfirm by remember { mutableStateOf("") }393 var pinRemovalMaster by remember { mutableStateOf("") }394 var pinError by remember { mutableStateOf<String?>(null) }395 val pinScope = rememberCoroutineScope()396397 SubLabel("LOCKING", fg, top = 0.dp)398 RowLine(fg) {399 Name("Allow screenshots", fg)400 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }401 }402 RowLine(fg) {403 Name("Auto-lock after", fg)404 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)405 SubtleButton(lockOptions[idx].second, fg) {406 val next = lockOptions[(idx + 1) % lockOptions.size].first407 vm.setAutoLockMinutes(if (next == 0) 0 else next)408 }409 }410 SubLabel("PIN UNLOCK", fg)411 RowLine(fg) {412 Name("Unlock with PIN", fg)413 Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {414 ValueText(if (pinEnabled) "enabled" else "off", fg)415 SubtleButton(if (pinEnabled) "change" else "set", fg) {416 pin = ""417 pinConfirm = ""418 pinMaster = ""419 pinError = null420 pinOpen = !pinOpen421 }422 }423 }424 if (pinEnabled) {425 RowLine(fg) {426 Name("Remove PIN unlock", fg)427 SubtleButton("turn off", fg) {428 pinRemovalMaster = ""429 pinError = null430 pinRemovalOpen = !pinRemovalOpen431 }432 }433 }434 AnimatedVisibility(pinRemovalOpen) {435 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {436 PeachField(437 label = "master password",438 value = pinRemovalMaster,439 onValueChange = { pinRemovalMaster = it; pinError = null },440 keyboardType = KeyboardType.Password,441 visualTransformation = PasswordVisualTransformation(),442 )443 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }444 SubtleButton("remove PIN", fg) {445 pinScope.launch {446 val error = vm.disablePin(pinRemovalMaster)447 if (error == null) {448 pinRemovalMaster = ""449 pinRemovalOpen = false450 } else {451 pinError = error452 }453 }454 }455 }456 }457 AnimatedVisibility(pinOpen) {458 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {459 PeachField(460 label = "master password",461 value = pinMaster,462 onValueChange = { pinMaster = it; pinError = null },463 keyboardType = KeyboardType.Password,464 visualTransformation = PasswordVisualTransformation(),465 )466 PeachField(467 label = "new PIN (4–12 digits)",468 value = pin,469 onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },470 keyboardType = KeyboardType.NumberPassword,471 visualTransformation = PasswordVisualTransformation(),472 )473 PeachField(474 label = "confirm PIN",475 value = pinConfirm,476 onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },477 keyboardType = KeyboardType.NumberPassword,478 visualTransformation = PasswordVisualTransformation(),479 )480 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }481 SubtleButton("save PIN", fg) {482 pinError = if (pin != pinConfirm) "PINs do not match" else null483 if (pinError == null) pinScope.launch {484 val error = vm.savePin(pinMaster, pin)485 if (error == null) {486 pinMaster = ""487 pin = ""488 pinConfirm = ""489 pinOpen = false490 } else {491 pinError = error492 }493 }494 }495 }496 }497 SubLabel("BIOMETRICS", fg)498 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity499 RowLine(fg) {500 Name("Unlock with biometrics", fg)501 Toggle(s.biometricsEnabled, fg) { on ->502 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()503 }504 }505 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)506 if (bioError != null) {507 Text(508 bioError,509 color = com.peachpasswords.ui.theme.peachColors.Breach,510 fontFamily = PeachType.Mono, fontSize = 10.sp,511 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),512 )513 }514 SubLabel("AUTOFILL", fg)515 run {516 517 518 val ctx = LocalContext.current519 val enabled = AutofillEnablement.isEnabled(ctx)520 RowLine(fg) {521 Column(Modifier.weight(1f)) {522 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)523 Text(524 if (enabled) {525 "Peach is your autofill service."526 } else {527 "Android decides which app fills passwords. Turn Peach on in system settings."528 },529 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,530 modifier = Modifier.padding(top = 2.dp),531 )532 }533 if (!enabled) {534 SubtleButton("enable", fg) {535 536 537 538 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }539 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }540 }541 } else {542 ValueText("on", fg)543 }544 }545 }546547 RowLine(fg) {548 Column(Modifier.weight(1f)) {549 Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)550 Text(551 "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",552 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,553 modifier = Modifier.padding(top = 2.dp),554 )555 }556 Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }557 }558559 SubLabel("PASSKEYS", fg)560 RowLine(fg) {561 Column(Modifier.weight(1f)) {562 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)563 Text(564 "Peach can handle passkey sign-in and creation through Android Credential Manager.",565 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,566 modifier = Modifier.padding(top = 2.dp),567 )568 }569 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }570 }571 RowLine(fg) {572 Name("Status", fg)573 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)574 }575 SubLabel("RECOVERY", fg)576 RowLine(fg) {577 Column(Modifier.weight(1f)) {578 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)579 Text(580 "Keep the paper copy current before rotating sync identity or replacing devices.",581 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,582 modifier = Modifier.padding(top = 2.dp),583 )584 }585 SubtleButton("view", fg) {586 if (bioActivity != null) {587 com.peachpasswords.ui.lock.Biometrics.authenticate(588 activity = bioActivity,589 title = "View recovery words",590 subtitle = "Confirm your identity to view your recovery words",591 onSuccess = { onNavigate(PeachNav.RecoveryView) },592 )593 } else onNavigate(PeachNav.RecoveryView)594 }595 }596 SubLabel("BREACH PROTECTION", fg)597 val checkingBreaches by vm.checkingBreaches.collectAsState()598 val breachResult by vm.breachResult.collectAsState()599 RowLine(fg) {600 Name("Breach monitoring", fg)601 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }602 }603 RowLine(fg) {604 Name("Check all passwords", fg)605 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }606 }607 breachResult?.let { result ->608 RowLine(fg) {609 Name("Last check", fg)610 ValueText(result, fg)611 }612 }613614 SubLabel("MASTER PASSWORD", fg)615 ChangePasswordRow(fg, vm)616617 SubLabel("DURESS PASSWORD", fg)618 val duressIsPro by vm.isPro.collectAsState()619 if (!duressIsPro) {620 RowLine(fg) {621 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {622 Name("Duress password", fg); ProBadge(fg)623 }624 ValueText("off", fg)625 }626 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)627 } else {628 DuressSection(fg, vm)629 }630}631632@Composable633private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {634 var open by remember { mutableStateOf(false) }635 var current by remember { mutableStateOf("") }636 var next by remember { mutableStateOf("") }637 var confirm by remember { mutableStateOf("") }638 var error by remember { mutableStateOf<String?>(null) }639 var done by remember { mutableStateOf(false) }640 val scope = rememberCoroutineScope()641642 RowLine(fg) {643 Name("Master password", fg)644 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }645 }646 AnimatedVisibility(open) {647 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {648 PwField("current password", current, fg) { current = it }649 PwField("new password (min 12 chars)", next, fg) { next = it }650 PwField("confirm new password", confirm, fg) { confirm = it }651 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }652 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)653 SubtleButton("save new password", fg) {654 error = when {655 current.isBlank() -> "enter your current password"656 next.length < 12 -> "new password must be at least 12 characters"657 next != confirm -> "new passwords do not match"658 next == current -> "new password must differ from current"659 else -> null660 }661 if (error == null) scope.launch {662 if (vm.changePassword(current, next)) {663 current = ""; next = ""; confirm = ""; open = false; done = true664 } else error = "incorrect current password"665 }666 }667 }668 }669}670671@Composable672private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {673 PeachField(674 label = placeholder,675 value = value,676 onValueChange = onChange,677 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,678 visualTransformation = PasswordVisualTransformation(),679 )680}681682683684@Composable685private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {686 val vm: UpdateViewModel = hiltViewModel()687 val available by vm.available.collectAsState()688 val state by vm.state.collectAsState()689 val changelog by vm.changelog.collectAsState()690 var changelogOpen by remember { mutableStateOf(false) }691692 RowLine(fg) {693 Name("Version", fg)694 ValueText(BuildConfig.VERSION_NAME, fg)695 }696 RowLine(fg) {697 Column(Modifier.weight(1f)) {698 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)699 Text(700 when {701 available != null -> "v${available?.version} is available"702 state.phase == UpdatePhase.Checking -> "Checking…"703 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"704 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"705 else -> "Peach checks once a day and tells you when there's a new version."706 },707 color = fg.copy(alpha = 0.7f),708 fontFamily = PeachType.Body,709 fontSize = 11.sp,710 lineHeight = 16.sp,711 modifier = Modifier.padding(top = 2.dp),712 )713 }714 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {715 SubtleButton("changelog", fg) {716 changelogOpen = !changelogOpen717 if (changelogOpen) vm.loadChangelog()718 }719 if (available != null) {720 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }721 } else {722 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {723 vm.check()724 }725 }726 }727 }728 if (changelogOpen) {729 when (changelog.phase) {730 ChangelogPhase.Idle -> {}731 ChangelogPhase.Loading -> Text(732 "Loading changelog…",733 color = fg.copy(alpha = 0.68f),734 fontFamily = PeachType.Body,735 fontSize = 11.sp,736 modifier = Modifier.padding(vertical = 8.dp),737 )738 ChangelogPhase.Failed -> RowLine(fg) {739 Name("Couldn't reach the update server", fg)740 SubtleButton("retry", fg) { vm.loadChangelog() }741 }742 ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {743 changelog.entries.forEachIndexed { index, entry ->744 Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {745 Row(verticalAlignment = Alignment.CenterVertically) {746 Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)747 if (compareVersions(entry.version, vm.currentVersion) == 0) {748 Text(" current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)749 }750 }751 Text(752 entry.notes ?: "No notes for this release.",753 color = fg.copy(alpha = 0.72f),754 fontFamily = PeachType.Body,755 fontSize = 11.sp,756 lineHeight = 16.sp,757 modifier = Modifier.padding(top = 3.dp),758 )759 }760 if (index < changelog.entries.lastIndex) {761 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))762 }763 }764 }765 }766 }767}768769@Composable770private fun VaultBody(771 fg: Color,772 vm: SettingsViewModel,773 s: VaultSettings,774 entriesCount: Int,775 onNavigate: (PeachNav) -> Unit,776) {777 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")778 val current = s.trashRetentionDays ?: 0779 val isPro by vm.isPro.collectAsState()780 val dedupCount by vm.dedupCount.collectAsState()781 val dedupRunning by vm.dedupRunning.collectAsState()782 val dedupDone by vm.dedupDone.collectAsState()783 val history by vm.vaultHistory.collectAsState()784 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()785786 SettingsGroup("ENTRIES", fg, top = 0.dp) {787 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }788 RowLine(fg) {789 Name("Duplicates", fg)790 val done = dedupDone791 if (done != null) {792 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)793 } else {794 val count = dedupCount795 val label = when {796 dedupRunning -> "scanning..."797 count == null -> "scan"798 count == 0 -> "none found"799 else -> "$count found"800 }801 SubtleButton(label, fg) {802 if (count == null || count == 0) vm.scanDuplicates()803 }804 }805 }806 if ((dedupCount ?: 0) > 0 && dedupDone == null) {807 Column(808 Modifier.fillMaxWidth()809 .padding(top = 2.dp, bottom = 8.dp)810 .clip(RoundedCornerShape(8.dp))811 .background(fg.copy(alpha = 0.08f))812 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))813 .padding(10.dp),814 verticalArrangement = Arrangement.spacedBy(8.dp),815 ) {816 val count = dedupCount ?: 0817 Text(818 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",819 color = fg.copy(alpha = 0.82f),820 fontFamily = PeachType.Body,821 fontSize = 11.sp,822 lineHeight = 16.sp,823 )824 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {825 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }826 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }827 }828 }829 }830 }831832 SettingsGroup("MAINTENANCE", fg) {833 RowLine(fg) {834 Name("Trash auto-delete", fg)835 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)836 SubtleButton(trashOptions[idx].second, fg) {837 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)838 }839 }840 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }841 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }842 }843844 SettingsGroup("DATA", fg) {845 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }846 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }847 RowLine(fg) {848 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }849 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }850 }851 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }852 }853854 SettingsGroup("UPDATES", fg) {855 UpdatesRows(fg, onNavigate)856 }857858 SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {859 var historyShowAll by remember { mutableStateOf(false) }860 if (history.isEmpty()) {861 Text(862 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",863 color = fg.copy(alpha = 0.68f),864 fontFamily = PeachType.Body,865 fontSize = 11.sp,866 lineHeight = 16.sp,867 modifier = Modifier.padding(bottom = 6.dp),868 )869 } else {870 Text(871 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",872 color = fg.copy(alpha = 0.68f),873 fontFamily = PeachType.Body,874 fontSize = 11.sp,875 lineHeight = 16.sp,876 modifier = Modifier.padding(bottom = 4.dp),877 )878 history.take(if (historyShowAll) history.size else 3).forEach { snap ->879 RowLine(fg) {880 Column(Modifier.weight(1f)) {881 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)882 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)883 }884 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {885 vm.restoreVaultSnapshot(snap.id)886 }887 }888 }889 if (history.size > 3) {890 RowLine(fg) {891 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)892 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }893 }894 }895 Text(896 "${history.size} of 5 snapshots",897 color = fg.copy(alpha = 0.58f),898 fontFamily = PeachType.Mono,899 fontSize = 10.sp,900 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),901 )902 }903 }904905 SettingsGroup("ADVANCED", fg, initiallyOpen = false) {906 RowLine(fg) {907 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)908 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }909 }910 RowLine(fg) {911 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)912 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }913 }914 }915}916917private fun snapshotSubLabel(snapshot: VaultSnapshot): String {918 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }919 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")920}921922private fun snapshotReasonLabel(reason: String): String = when (reason) {923 "local_save" -> "saved"924 "sync_received" -> "synced"925 "import" -> "imported"926 "restore" -> "restored"927 else -> reason928}929930private fun snapshotTimeLabel(savedAt: Long): String {931 val diff = System.currentTimeMillis() - savedAt932 if (diff < 60_000) return "Just now"933 if (diff < 3_600_000) return "${diff / 60_000}m ago"934 val date = Date(savedAt)935 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())936 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)937 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)938 if (today == thenDay) return "Today $time"939 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))940 if (yesterday == thenDay) return "Yesterday $time"941 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)942}943944945946@Composable947private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {948 val context = LocalContext.current949 val syncVm: SyncViewModel = hiltViewModel()950 val pairedDevices by syncVm.pairedDevices.collectAsState()951 val pendingVouched by syncVm.pendingVouched.collectAsState()952 val syncStatus by syncVm.status.collectAsState()953 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()954 val syncMode by syncVm.syncMode.collectAsState()955 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()956 var notificationsAvailable by remember {957 mutableStateOf(canShowBackgroundSyncNotification(context))958 }959 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }960 val scope = rememberCoroutineScope()961 var confirmUnpair by remember { mutableStateOf<String?>(null) }962 var renamingDeviceId by remember { mutableStateOf<String?>(null) }963 var renameDraft by remember { mutableStateOf("") }964 var rotateConfirmOpen by remember { mutableStateOf(false) }965 var rotating by remember { mutableStateOf(false) }966 var rotateError by remember { mutableStateOf<String?>(null) }967 var relayUrlDraft by remember(configuredRelayUrl) {968 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))969 }970 var relayUrlStatus by remember { mutableStateOf<String?>(null) }971 var showConnection by remember { mutableStateOf(false) }972 var showRecovery by remember { mutableStateOf(false) }973974 SubLabel("ADD A DEVICE", fg, top = 0.dp)975 RowLine(fg) {976 Column(Modifier.weight(1f)) {977 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)978 Text(979 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",980 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,981 modifier = Modifier.padding(top = 2.dp),982 )983 }984 985 986 987 988 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }989 Spacer(Modifier.width(PeachSpacing.xs))990 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }991 }992993 SubLabel("SYNC STATUS", fg)994 val indicator = syncIndicator(syncStatus, pairedDevices.size)995 RowLine(fg) {996 SyncIndicatorDot(997 indicator = indicator,998 label = syncIndicatorLabel(indicator, syncStatus),999 modifier = Modifier.padding(end = PeachSpacing.sm),1000 size = 8.dp,1001 )1002 Text(1003 describeSyncOutcome(syncStatus),1004 color = fg.copy(alpha = 0.7f),1005 fontFamily = PeachType.Body,1006 fontSize = 11.sp,1007 lineHeight = 16.sp,1008 modifier = Modifier.weight(1f),1009 )1010 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {1011 if (!syncStatus.syncing) syncVm.syncNow()1012 }1013 }10141015 SubLabel("PAIRED DEVICES", fg)1016 if (pairedDevices.isEmpty()) {1017 PeachEmptyOrEndState(1018 message = "No paired devices yet.",1019 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",1020 modifier = Modifier.padding(top = PeachSpacing.xs),1021 )1022 } else {1023 pairedDevices.forEach { d ->1024 RowLine(fg) {1025 Column(Modifier.weight(1f)) {1026 if (renamingDeviceId == d.deviceId) {1027 Box(1028 Modifier.fillMaxWidth()1029 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1030 .padding(horizontal = 10.dp, vertical = 8.dp),1031 ) {1032 BasicTextField(1033 value = renameDraft,1034 onValueChange = { renameDraft = it.take(64) },1035 singleLine = true,1036 textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1037 cursorBrush = SolidColor(fg),1038 modifier = Modifier.fillMaxWidth(),1039 )1040 }1041 } else {1042 Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1043 }1044 Text(1045 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1046 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1047 modifier = Modifier.padding(top = 2.dp),1048 )1049 }1050 if (confirmUnpair != d.deviceId) {1051 if (renamingDeviceId == d.deviceId) {1052 SubtleButton("cancel", fg) { renamingDeviceId = null }1053 Spacer(Modifier.width(PeachSpacing.xs))1054 SubtleButton("save", fg) {1055 if (renameDraft.isNotBlank()) {1056 syncVm.renameDevice(d.deviceId, renameDraft)1057 renamingDeviceId = null1058 }1059 }1060 } else {1061 SubtleButton("rename", fg) {1062 renameDraft = d.localAlias ?: d.deviceName ?: ""1063 renamingDeviceId = d.deviceId1064 }1065 }1066 if (renamingDeviceId != d.deviceId) {1067 Spacer(Modifier.width(PeachSpacing.xs))1068 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1069 }1070 }1071 }1072 1073 1074 1075 1076 1077 if (confirmUnpair == d.deviceId) {1078 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1079 Text(1080 "Stops this device receiving future changes. It doesn't lock it " +1081 "out of what it already has - if it's lost or stolen, also " +1082 "change your master password and generate new recovery words.",1083 color = fg.copy(alpha = 0.7f),1084 fontFamily = PeachType.Body,1085 fontSize = 11.sp,1086 lineHeight = 16.sp,1087 )1088 Row(1089 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1090 horizontalArrangement = Arrangement.End,1091 ) {1092 SubtleButton("cancel", fg) { confirmUnpair = null }1093 Spacer(Modifier.width(PeachSpacing.sm))1094 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1095 }1096 }1097 }1098 }1099 }11001101 SyncDisclosure(1102 title = "Connection",1103 summary = "Background sync, relay, and privacy choices",1104 expanded = showConnection,1105 fg = fg,1106 onToggle = { showConnection = !showConnection },1107 )1108 if (showConnection) {1109 RowLine(fg) {1110 Column(Modifier.weight(1f)) {1111 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1112 Text(1113 when {1114 backgroundSyncEnabled && !notificationsAvailable ->1115 "Action needed — Android notifications are off, so background sync is not visible."1116 backgroundSyncEnabled ->1117 "On — keeps this phone reachable for paired devices."1118 else ->1119 "Off — Peach syncs only while you are using the app."1120 },1121 color = fg.copy(alpha = 0.7f),1122 fontFamily = PeachType.Body,1123 fontSize = 11.sp,1124 lineHeight = 16.sp,1125 modifier = Modifier.padding(top = 2.dp),1126 )1127 }1128 SubtleButton(1129 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1130 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1131 ) { showBackgroundSyncPrompt = true }1132 }1133 SyncModeRow(1134 title = "Public relay",1135 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1136 selected = syncMode == SyncMode.PUBLIC,1137 fg = fg,1138 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1139 SyncModeRow(1140 title = "Private relay",1141 detail = "Use your own peach-relay on your own domain.",1142 selected = syncMode == SyncMode.PRIVATE,1143 fg = fg,1144 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }11451146 if (syncMode == SyncMode.PRIVATE) {1147 Text(1148 "RELAY URL",1149 color = fg.copy(alpha = 0.6f),1150 fontFamily = PeachType.Mono,1151 fontSize = 8.sp,1152 letterSpacing = 0.22.em,1153 modifier = Modifier.padding(top = 8.dp),1154 )1155 Box(1156 Modifier.fillMaxWidth().padding(top = 6.dp)1157 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1158 .padding(horizontal = 12.dp, vertical = 10.dp),1159 ) {1160 BasicTextField(1161 value = relayUrlDraft,1162 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1163 singleLine = true,1164 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1165 cursorBrush = SolidColor(fg),1166 modifier = Modifier.fillMaxWidth(),1167 )1168 }1169 val needsRelayUrl = configuredRelayUrl.isEmpty()1170 RowLine(fg) {1171 val message = relayUrlStatus1172 ?: if (needsRelayUrl) {1173 "Sync is paused until you enter your relay URL."1174 } else {1175 "Only secure wss:// relays are accepted."1176 }1177 val warn = relayUrlStatus?.startsWith("Use") == true ||1178 (relayUrlStatus == null && needsRelayUrl)1179 ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1180 Spacer(Modifier.width(PeachSpacing.xs))1181 SubtleButton("save", fg) {1182 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1183 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1184 "Saved"1185 } else {1186 "Use a valid wss:// relay URL"1187 }1188 }1189 }1190 }11911192 SyncModeRow(1193 title = "No sync",1194 detail = "Keep this device separate and move backups by hand.",1195 selected = syncMode == SyncMode.OFF,1196 fg = fg,1197 ) { syncVm.setSyncMode(SyncMode.OFF) }1198 Hint("Pairing authorizes a device, not a cloud account.", fg)1199 }12001201 if (pendingVouched.isNotEmpty()) {1202 SubLabel("PENDING DEVICES", fg)1203 pendingVouched.forEach { dev ->1204 RowLine(fg) {1205 Column(Modifier.weight(1f)) {1206 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1207 Text(1208 "Was added by another device. Allow it to sync?",1209 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1210 modifier = Modifier.padding(top = 2.dp),1211 )1212 }1213 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1214 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1215 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1216 }1217 }1218 }1219 }12201221 SyncDisclosure(1222 title = "Recovery & security",1223 summary = "For a lost or stolen device",1224 expanded = showRecovery,1225 fg = fg,1226 onToggle = { showRecovery = !showRecovery },1227 )1228 if (showRecovery) {1229 RowLine(fg) {1230 Column(Modifier.weight(1f)) {1231 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1232 Text(1233 "Scan only when this populated vault previously trusted the source device.",1234 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1235 modifier = Modifier.padding(top = 2.dp),1236 )1237 }1238 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1239 }1240 if (rotateConfirmOpen) {1241 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1242 rotateError?.let {1243 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1244 }1245 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1246 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1247 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1248 if (rotating) return@SubtleButton1249 rotating = true1250 rotateError = null1251 scope.launch {1252 val ok = syncVm.rotateSyncIdentity()1253 rotating = false1254 if (ok) {1255 rotateConfirmOpen = false1256 onNavigate(PeachNav.RecoverySetup)1257 } else {1258 rotateError = "Rotation failed. Unlock your real vault and try again."1259 }1260 }1261 }1262 }1263 } else {1264 RowLine(fg) {1265 Column(Modifier.weight(1f)) {1266 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1267 Text(1268 "Disconnects every device and replaces the keys used for sync.",1269 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1270 modifier = Modifier.padding(top = 2.dp),1271 )1272 }1273 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1274 }1275 }1276 }12771278 if (showBackgroundSyncPrompt) {1279 BackgroundSyncDialog { enabled ->1280 showBackgroundSyncPrompt = false1281 notificationsAvailable = canShowBackgroundSyncNotification(context)1282 syncVm.setBackgroundSyncEnabled(enabled)1283 }1284 }1285}12861287private fun deviceTypeLabel(type: String?): String = when (type) {1288 "android" -> "Android"1289 "phone" -> "Phone"1290 "tablet" -> "Tablet"1291 "browser" -> "Browser"1292 else -> "Device"1293}12941295private fun syncTimeLabel(lastSyncedAt: Long?): String {1296 if (lastSyncedAt == null) return "never synced"1297 val diff = System.currentTimeMillis() - lastSyncedAt1298 if (diff < 60_000) return "just now"1299 if (diff < 3_600_000) return "${diff / 60_000}m ago"1300 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1301 return "${diff / 86_400_000}d ago"1302}13031304private fun isSyncStale(lastSyncedAt: Long?): Boolean =1305 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1306130713081309@Composable1310private fun ProBody(fg: Color, vm: SettingsViewModel) {1311 val ctx = LocalContext.current1312 val isPro by vm.isPro.collectAsState()1313 if (isPro) {1314 ProActiveBody(fg, vm)1315 return1316 }1317 val features = listOf(1318 "Peach Sync" to "End-to-end encrypted",1319 "Peach Codex" to "Restore from paper",1320 "Passkeys" to "Zero-knowledge WebAuthn",1321 "Advanced Vault Health" to "Reuse · age · exposure",1322 "Canary entries" to "Detects vault compromise",1323 "Duress vault" to "Decoy under coercion",1324 )1325 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1326 Text(1327 "Everything, everywhere.",1328 modifier = Modifier.padding(top = 8.dp),1329 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1330 )1331 Text(1332 "One key unlocks every Pro feature — for the lifetime of the app.",1333 modifier = Modifier.padding(top = 6.dp),1334 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1335 )1336 Spacer(Modifier.height(10.dp))1337 features.forEachIndexed { i, (name, desc) ->1338 1339 1340 1341 1342 RowLine(fg, verticalPadding = 6.dp) {1343 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1344 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1345 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1346 }1347 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1348 }1349 }1350 Spacer(Modifier.height(12.dp))1351 Text(1352 "$39",1353 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1354 )1355 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1356 Spacer(Modifier.height(10.dp))1357 Box(1358 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1359 .background(fg.copy(alpha = 0.16f))1360 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1361 .clickable {1362 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1363 }1364 .padding(vertical = 12.dp),1365 contentAlignment = Alignment.Center,1366 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }13671368 1369 1370 1371 1372 Spacer(Modifier.height(44.dp))13731374 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1375 var key by remember { mutableStateOf("") }1376 val activating by vm.activating.collectAsState()1377 val activationError by vm.activationError.collectAsState()1378 Box(1379 Modifier.fillMaxWidth().padding(top = 6.dp)1380 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1381 .padding(horizontal = 12.dp, vertical = 10.dp),1382 ) {1383 BasicTextField(1384 value = key,1385 1386 1387 1388 1389 onValueChange = { key = it; vm.clearActivationError() },1390 singleLine = true,1391 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1392 cursorBrush = SolidColor(fg),1393 modifier = Modifier.fillMaxWidth(),1394 decorationBox = { inner ->1395 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1396 inner()1397 },1398 )1399 }1400 if (activationError != null) {1401 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1402 }1403 Spacer(Modifier.height(8.dp))1404 Box(1405 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1406 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1407 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1408 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1409 .padding(vertical = 12.dp),1410 contentAlignment = Alignment.Center,1411 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1412}14131414@Composable1415private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1416 val ctx = LocalContext.current1417 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1418 val licenseKey by vm.licenseKey.collectAsState()1419 val issuedAt by vm.licenseIssuedAt.collectAsState()1420 var showKey by remember { mutableStateOf(false) }1421 var copied by remember { mutableStateOf(false) }1422 val scope = rememberCoroutineScope()1423 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1424 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1425 }1426 Text(1427 "Peach Pro",1428 modifier = Modifier.fillMaxWidth(),1429 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1430 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1431 )1432 Text(1433 formatLicenseActiveSince(issuedAt),1434 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1435 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1436 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1437 )1438 licenseKey?.let { key ->1439 Spacer(Modifier.height(12.dp))1440 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1441 RowLine(fg) {1442 Text(1443 if (showKey) key else "************************",1444 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1445 fontFamily = PeachType.Mono,1446 fontSize = 10.sp,1447 modifier = Modifier.weight(1f),1448 )1449 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1450 SubtleButton(if (copied) "copied" else "copy", fg) {1451 PeachClipboard.copy(ctx, "peach license", key)1452 copied = true1453 scope.launch {1454 delay(1_500)1455 copied = false1456 }1457 }1458 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1459 }1460 }1461 }1462 Spacer(Modifier.height(12.dp))1463 features.forEachIndexed { i, name ->1464 RowLine(fg) {1465 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1466 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1467 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1468 }1469 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1470 }1471 }1472 Spacer(Modifier.height(12.dp))1473 Box(1474 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1475 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1476 .clickable { vm.deactivate() }1477 .padding(vertical = 12.dp),1478 contentAlignment = Alignment.Center,1479 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1480}14811482private fun formatLicenseActiveSince(issuedAt: Long?): String {1483 if (issuedAt == null) return "Active"1484 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1485 return "Active since $monthYear"1486}1487148814891490@OptIn(ExperimentalLayoutApi::class)1491@Composable1492private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1493 val categories = listOf("Bug report", "Feature request", "Question", "Other")1494 var category by remember { mutableStateOf(categories[0]) }1495 var message by remember { mutableStateOf("") }1496 val state by vm.feedbackState.collectAsState()14971498 1499 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }15001501 if (state is FeedbackState.Sent) {1502 Box(1503 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1504 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1505 ) {1506 Column {1507 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1508 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))1509 }1510 }1511 return1512 }15131514 val sending = state is FeedbackState.Sending15151516 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)1517 SubLabel("TYPE", fg)1518 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1519 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1520 }1521 SubLabel("MESSAGE", fg)1522 Box(1523 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1524 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1525 .padding(10.dp),1526 ) {1527 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1528 BasicTextField(1529 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1530 enabled = !sending,1531 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1532 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1533 )1534 }1535 Text(1536 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1537 modifier = Modifier.padding(top = 6.dp),1538 color = fg.copy(alpha = 0.52f),1539 fontFamily = PeachType.Mono,1540 fontSize = 10.sp,1541 lineHeight = 14.sp,1542 )1543 Spacer(Modifier.height(8.dp))15441545 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1546 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1547 Box(1548 Modifier.clip(RoundedCornerShape(8.dp))1549 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1550 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1551 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1552 .padding(horizontal = 12.dp, vertical = 6.dp),1553 ) {1554 Text(1555 feedbackSubmitLabel(state),1556 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1557 fontFamily = PeachType.Mono, fontSize = 11.sp,1558 )1559 }1560 if (sending) {1561 CircularProgressIndicator(1562 color = LocalPeachAccent.current,1563 strokeWidth = 2.dp,1564 modifier = Modifier.size(16.dp),1565 )1566 }1567 }15681569 (state as? FeedbackState.Error)?.let { err ->1570 Text(1571 err.message,1572 modifier = Modifier.padding(top = 8.dp),1573 color = peachColors.Breach,1574 fontFamily = PeachType.Mono,1575 fontSize = 11.sp,1576 lineHeight = 15.sp,1577 )1578 }15791580 Hint("Sent anonymously · no account required", fg)1581}1582158315841585@Composable1586internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1587 val configured by vm.duressConfigured.collectAsState()1588 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1589}15901591@Composable1592private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1593 Text(1594 "Duress password is active",1595 color = peachColors.HealthOk,1596 fontFamily = PeachType.Mono,1597 fontSize = 11.sp,1598 modifier = Modifier.padding(top = 6.dp),1599 )1600 Spacer(Modifier.height(8.dp))1601 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1602 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1603}16041605@Composable1606private fun DuressForm(fg: Color, vm: SettingsViewModel) {1607 var enabled by remember { mutableStateOf(false) }1608 var password by remember { mutableStateOf("") }1609 var confirm by remember { mutableStateOf("") }1610 var action by remember { mutableStateOf(DuressAction.DECOY) }1611 var error by remember { mutableStateOf<String?>(null) }1612 val scope = rememberCoroutineScope()16131614 RowLine(fg) {1615 Name("Duress password", fg)1616 Toggle(enabled, fg) { on ->1617 enabled = on1618 if (!on) { password = ""; confirm = ""; error = null }1619 }1620 }1621 Text(1622 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1623 color = fg.copy(alpha = 0.7f),1624 fontFamily = PeachType.Mono,1625 fontSize = 10.sp,1626 modifier = Modifier.padding(top = 6.dp),1627 )16281629 AnimatedVisibility(enabled) {1630 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1631 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1632 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }16331634 Text(1635 "When your duress password is entered at the lock screen, Peach will:",1636 color = fg.copy(alpha = 0.7f),1637 fontFamily = PeachType.Mono,1638 fontSize = 10.sp,1639 modifier = Modifier.padding(top = 4.dp),1640 )16411642 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1643 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }16441645 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }16461647 val canSave = password.isNotBlank() && confirm.isNotBlank()1648 DuressSaveButton("Save duress password", fg, canSave) {1649 error = when {1650 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1651 password != confirm -> "Passwords do not match"1652 else -> null1653 }1654 if (error == null) scope.launch {1655 if (vm.saveDuress(password, action)) {1656 password = ""; confirm = ""; enabled = false1657 } else {1658 error = "Couldn't save — the duress passphrase must differ from your real password"1659 }1660 }1661 }1662 }1663 }1664}16651666@Composable1667private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1668 val accent = LocalPeachAccent.current1669 Row(1670 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1671 verticalAlignment = Alignment.CenterVertically,1672 horizontalArrangement = Arrangement.spacedBy(10.dp),1673 ) {1674 Box(1675 Modifier.size(16.dp).clip(CircleShape)1676 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1677 contentAlignment = Alignment.Center,1678 ) {1679 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1680 }1681 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1682 }1683}16841685@Composable1686private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1687 val accent = LocalPeachAccent.current1688 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1689 Box(1690 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1691 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1692 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1693 .let { if (enabled) it.clickable(onClick = onClick) else it }1694 .padding(vertical = 11.dp),1695 contentAlignment = Alignment.Center,1696 ) {1697 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1698 }1699}17001701@Composable1702private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1703 Box(1704 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1705 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1706 .clickable(onClick = onClick)1707 .padding(vertical = 11.dp),1708 contentAlignment = Alignment.Center,1709 ) {1710 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1711 }1712}171317141715@Composable1716private fun SyncModeRow(1717 title: String,1718 detail: String,1719 selected: Boolean,1720 fg: Color,1721 onSelect: () -> Unit,1722) {1723 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1724 Column(Modifier.weight(1f)) {1725 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1726 Text(1727 detail,1728 color = fg.copy(alpha = 0.7f),1729 fontFamily = PeachType.Body,1730 fontSize = 11.sp,1731 lineHeight = 16.sp,1732 modifier = Modifier.padding(top = 2.dp),1733 )1734 }1735 Text(1736 if (selected) "●" else "○",1737 color = if (selected) fg else fg.copy(alpha = 0.45f),1738 fontFamily = PeachType.Mono,1739 fontSize = 14.sp,1740 modifier = Modifier.padding(start = 10.dp),1741 )1742 }1743}