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 kotlinx.coroutines.launch82import kotlinx.coroutines.delay83import java.text.SimpleDateFormat84import java.util.Date85import java.util.Locale8687private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"8889internal const val FAVICON_DISCLOSURE_TEXT =90 "Peach requests site icons from DuckDuckGo. Turning this on discloses your full vault domain inventory to DuckDuckGo as icons are requested."919293949596979899@Composable100fun CardBody(101 id: CardId,102 fg: Color,103 vm: SettingsViewModel,104 settings: VaultSettings,105 entriesCount: Int,106 onNavigate: (PeachNav) -> Unit,107) {108 when (id) {109 CardId.Appearance -> AppearanceBody(fg, vm, settings)110 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)111 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)112 CardId.Sync -> SyncBody(fg, onNavigate)113 CardId.Pro -> ProBody(fg, vm)114 CardId.Feedback -> FeedbackBody(fg, vm)115 }116}117118119120@Composable121private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {122 Text(123 text,124 modifier = Modifier.padding(top = top, bottom = 4.dp),125 color = fg.copy(alpha = 0.58f),126 fontFamily = PeachType.Mono,127 fontSize = 8.sp,128 letterSpacing = 0.22.em,129 )130}131132133134135136137@Composable138private fun SettingsGroup(139 label: String,140 fg: Color,141 initiallyOpen: Boolean = true,142 top: Dp = 14.dp,143 content: @Composable () -> Unit,144) {145 var open by remember { mutableStateOf(initiallyOpen) }146 Row(147 Modifier.fillMaxWidth()148 .clip(RoundedCornerShape(6.dp))149 .clickable { open = !open }150 .padding(top = top, bottom = 4.dp),151 verticalAlignment = Alignment.CenterVertically,152 ) {153 Text(154 label,155 modifier = Modifier.weight(1f),156 color = fg.copy(alpha = 0.58f),157 fontFamily = PeachType.Mono,158 fontSize = 8.sp,159 letterSpacing = 0.22.em,160 )161 Text(162 if (open) "–" else "+",163 color = fg.copy(alpha = 0.58f),164 fontFamily = PeachType.Mono,165 fontSize = 10.sp,166 )167 }168 169 170 AnimatedVisibility(visible = open) {171 Column(Modifier.fillMaxWidth()) { content() }172 }173}174175@Composable176private fun RowLine(177 fg: Color,178 modifier: Modifier = Modifier,179 verticalPadding: Dp = 9.dp,180 content: @Composable RowScope.() -> Unit,181) {182 Row(183 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),184 verticalAlignment = Alignment.CenterVertically,185 horizontalArrangement = Arrangement.SpaceBetween,186 content = content,187 )188 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))189}190191@Composable192private fun RowScope.Name(text: String, fg: Color) {193 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))194}195196@Composable197private fun ValueText(text: String, fg: Color, modifier: Modifier = Modifier) {198 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier)199}200201@Composable202private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {203 Box(204 Modifier.clip(RoundedCornerShape(8.dp))205 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))206 .background(fg.copy(alpha = 0.08f))207 .clickable(onClick = onClick)208 .padding(horizontal = 12.dp, vertical = 6.dp),209 ) {210 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1)211 }212}213214@Composable215private fun SyncDisclosure(216 title: String,217 summary: String,218 expanded: Boolean,219 fg: Color,220 onToggle: () -> Unit,221) {222 Row(223 modifier = Modifier224 .fillMaxWidth()225 .heightIn(min = 48.dp)226 .clip(RoundedCornerShape(8.dp))227 .clickable(onClick = onToggle)228 .padding(vertical = 8.dp),229 verticalAlignment = Alignment.CenterVertically,230 ) {231 Column(Modifier.weight(1f)) {232 Text(233 title.uppercase(),234 color = fg.copy(alpha = 0.58f),235 fontFamily = PeachType.Mono,236 fontSize = 8.sp,237 letterSpacing = 0.22.em,238 )239 Text(240 summary,241 color = fg.copy(alpha = 0.7f),242 fontFamily = PeachType.Body,243 fontSize = 11.sp,244 modifier = Modifier.padding(top = 3.dp),245 )246 }247 Text(248 if (expanded) "hide" else "show",249 color = fg,250 fontFamily = PeachType.Mono,251 fontSize = 10.sp,252 )253 }254}255256@Composable257private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {258 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")259 Box(260 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))261 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))262 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))263 .clickable { onToggle(!on) },264 ) {265 Box(266 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)267 .padding(start = knob - 2.dp)268 .size(14.dp).clip(CircleShape)269 .background(if (on) fg else fg.copy(alpha = 0.58f)),270 )271 }272}273274@Composable275private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {276 Box(277 Modifier.clip(RoundedCornerShape(999.dp))278 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))279 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)280 .clickable(onClick = onClick)281 .padding(horizontal = 12.dp, vertical = 5.dp),282 ) {283 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)284 }285}286287@Composable288private fun ProBadge(fg: Color) {289 Box(290 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))291 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),292 ) {293 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)294 }295}296297@Composable298private fun Hint(text: String, fg: Color) {299 Text(300 text,301 modifier = Modifier.padding(top = 6.dp),302 color = fg.copy(alpha = 0.6f),303 fontFamily = PeachType.Display,304 fontStyle = FontStyle.Italic,305 fontSize = 11.5.sp,306 lineHeight = 16.sp,307 )308}309310311312@OptIn(ExperimentalLayoutApi::class)313@Composable314private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {315 var showFaviconDisclosure by remember { mutableStateOf(false) }316 SubLabel("ACCENT", fg, top = 0.dp)317 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {318 ACCENT_COLORS.forEach { (name, color) ->319 Box(320 Modifier.size(22.dp).clip(CircleShape).background(color)321 .border(322 if (s.accentColor == name) 2.dp else 1.dp,323 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),324 CircleShape,325 )326 .clickable { vm.setAccent(name) },327 )328 }329 }330 SubLabel("TEXT SIZE", fg)331 RowLine(fg) {332 Name("Text size", fg)333 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {334 Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }335 Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }336 Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }337 }338 }339 SubLabel("UI SCALE", fg)340 RowLine(fg) {341 Name("Large-screen sizing", fg)342 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {343 Pill("compact", s.uiScale == "compact", fg) { vm.setUiScale("compact") }344 Pill("auto", s.uiScale == "auto", fg) { vm.setUiScale("auto") }345 Pill("large", s.uiScale == "large", fg) { vm.setUiScale("large") }346 }347 }348 Hint("Auto keeps foldables roomy while preventing tablet controls from inflating.", fg)349 SubLabel("ICONS", fg)350 RowLine(fg) {351 Name("Site icons", fg)352 SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) {353 if (s.faviconsEnabled) vm.setFaviconsEnabled(false) else showFaviconDisclosure = true354 }355 }356 if (showFaviconDisclosure) {357 AlertDialog(358 onDismissRequest = { showFaviconDisclosure = false },359 title = { Text("Enable site icons?", color = fg, fontFamily = PeachType.Body) },360 text = { Text(FAVICON_DISCLOSURE_TEXT, color = fg, fontFamily = PeachType.Body) },361 confirmButton = {362 SubtleButton("Enable", fg) {363 showFaviconDisclosure = false364 vm.setFaviconsEnabled(true)365 }366 },367 dismissButton = {368 SubtleButton("Cancel", fg) { showFaviconDisclosure = false }369 },370 )371 }372 SubLabel("MOTION", fg)373 RowLine(fg) {374 Name("Reduce motion", fg)375 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }376 }377}378379380381@Composable382private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {383 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")384 val current = s.autoLockMinutes ?: 0385 val pinEnabled by vm.pinEnabled.collectAsState()386 var pinOpen by remember { mutableStateOf(false) }387 var pinRemovalOpen by remember { mutableStateOf(false) }388 var pinMaster by remember { mutableStateOf("") }389 var pin by remember { mutableStateOf("") }390 var pinConfirm by remember { mutableStateOf("") }391 var pinRemovalMaster by remember { mutableStateOf("") }392 var pinError by remember { mutableStateOf<String?>(null) }393 val pinScope = rememberCoroutineScope()394395 SubLabel("LOCKING", fg, top = 0.dp)396 RowLine(fg) {397 Name("Allow screenshots", fg)398 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }399 }400 RowLine(fg) {401 Name("Auto-lock after", fg)402 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)403 SubtleButton(lockOptions[idx].second, fg) {404 val next = lockOptions[(idx + 1) % lockOptions.size].first405 vm.setAutoLockMinutes(if (next == 0) 0 else next)406 }407 }408 SubLabel("PIN UNLOCK", fg)409 RowLine(fg) {410 Name("Unlock with PIN", fg)411 Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {412 ValueText(if (pinEnabled) "enabled" else "off", fg)413 SubtleButton(if (pinEnabled) "change" else "set", fg) {414 pin = ""415 pinConfirm = ""416 pinMaster = ""417 pinError = null418 pinOpen = !pinOpen419 }420 }421 }422 if (pinEnabled) {423 RowLine(fg) {424 Name("Remove PIN unlock", fg)425 SubtleButton("turn off", fg) {426 pinRemovalMaster = ""427 pinError = null428 pinRemovalOpen = !pinRemovalOpen429 }430 }431 }432 AnimatedVisibility(pinRemovalOpen) {433 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {434 PeachField(435 label = "master password",436 value = pinRemovalMaster,437 onValueChange = { pinRemovalMaster = it; pinError = null },438 keyboardType = KeyboardType.Password,439 visualTransformation = PasswordVisualTransformation(),440 )441 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }442 SubtleButton("remove PIN", fg) {443 pinScope.launch {444 val error = vm.disablePin(pinRemovalMaster)445 if (error == null) {446 pinRemovalMaster = ""447 pinRemovalOpen = false448 } else {449 pinError = error450 }451 }452 }453 }454 }455 AnimatedVisibility(pinOpen) {456 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {457 PeachField(458 label = "master password",459 value = pinMaster,460 onValueChange = { pinMaster = it; pinError = null },461 keyboardType = KeyboardType.Password,462 visualTransformation = PasswordVisualTransformation(),463 )464 PeachField(465 label = "new PIN (4–12 digits)",466 value = pin,467 onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },468 keyboardType = KeyboardType.NumberPassword,469 visualTransformation = PasswordVisualTransformation(),470 )471 PeachField(472 label = "confirm PIN",473 value = pinConfirm,474 onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },475 keyboardType = KeyboardType.NumberPassword,476 visualTransformation = PasswordVisualTransformation(),477 )478 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }479 SubtleButton("save PIN", fg) {480 pinError = if (pin != pinConfirm) "PINs do not match" else null481 if (pinError == null) pinScope.launch {482 val error = vm.savePin(pinMaster, pin)483 if (error == null) {484 pinMaster = ""485 pin = ""486 pinConfirm = ""487 pinOpen = false488 } else {489 pinError = error490 }491 }492 }493 }494 }495 SubLabel("BIOMETRICS", fg)496 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity497 RowLine(fg) {498 Name("Unlock with biometrics", fg)499 Toggle(s.biometricsEnabled, fg) { on ->500 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()501 }502 }503 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)504 if (bioError != null) {505 Text(506 bioError,507 color = com.peachpasswords.ui.theme.peachColors.Breach,508 fontFamily = PeachType.Mono, fontSize = 10.sp,509 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),510 )511 }512 SubLabel("AUTOFILL", fg)513 run {514 515 516 val ctx = LocalContext.current517 val enabled = AutofillEnablement.isEnabled(ctx)518 RowLine(fg) {519 Column(Modifier.weight(1f)) {520 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)521 Text(522 if (enabled) {523 "Peach is your autofill service."524 } else {525 "Android decides which app fills passwords. Turn Peach on in system settings."526 },527 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,528 modifier = Modifier.padding(top = 2.dp),529 )530 }531 if (!enabled) {532 SubtleButton("enable", fg) {533 534 535 536 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }537 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }538 }539 } else {540 ValueText("on", fg)541 }542 }543 }544545 RowLine(fg) {546 Column(Modifier.weight(1f)) {547 Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)548 Text(549 "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",550 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,551 modifier = Modifier.padding(top = 2.dp),552 )553 }554 Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }555 }556557 SubLabel("PASSKEYS", fg)558 RowLine(fg) {559 Column(Modifier.weight(1f)) {560 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)561 Text(562 "Peach can handle passkey sign-in and creation through Android Credential Manager.",563 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,564 modifier = Modifier.padding(top = 2.dp),565 )566 }567 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }568 }569 RowLine(fg) {570 Name("Status", fg)571 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)572 }573 SubLabel("RECOVERY", fg)574 RowLine(fg) {575 Column(Modifier.weight(1f)) {576 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)577 Text(578 "Keep the paper copy current before rotating sync identity or replacing devices.",579 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,580 modifier = Modifier.padding(top = 2.dp),581 )582 }583 SubtleButton("view", fg) {584 if (bioActivity != null) {585 com.peachpasswords.ui.lock.Biometrics.authenticate(586 activity = bioActivity,587 title = "View recovery words",588 subtitle = "Confirm your identity to view your recovery words",589 onSuccess = { onNavigate(PeachNav.RecoveryView) },590 )591 } else onNavigate(PeachNav.RecoveryView)592 }593 }594 SubLabel("BREACH PROTECTION", fg)595 val checkingBreaches by vm.checkingBreaches.collectAsState()596 val breachResult by vm.breachResult.collectAsState()597 RowLine(fg) {598 Name("Breach monitoring", fg)599 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }600 }601 RowLine(fg) {602 Name("Check all passwords", fg)603 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }604 }605 breachResult?.let { result ->606 RowLine(fg) {607 Name("Last check", fg)608 ValueText(result, fg)609 }610 }611612 SubLabel("MASTER PASSWORD", fg)613 ChangePasswordRow(fg, vm)614615 SubLabel("DURESS PASSWORD", fg)616 val duressIsPro by vm.isPro.collectAsState()617 if (!duressIsPro) {618 RowLine(fg) {619 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {620 Name("Duress password", fg); ProBadge(fg)621 }622 ValueText("off", fg)623 }624 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)625 } else {626 DuressSection(fg, vm)627 }628}629630@Composable631private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {632 var open by remember { mutableStateOf(false) }633 var current by remember { mutableStateOf("") }634 var next by remember { mutableStateOf("") }635 var confirm by remember { mutableStateOf("") }636 var error by remember { mutableStateOf<String?>(null) }637 var done by remember { mutableStateOf(false) }638 val scope = rememberCoroutineScope()639640 RowLine(fg) {641 Name("Master password", fg)642 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }643 }644 AnimatedVisibility(open) {645 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {646 PwField("current password", current, fg) { current = it }647 PwField("new password (min 12 chars)", next, fg) { next = it }648 PwField("confirm new password", confirm, fg) { confirm = it }649 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }650 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)651 SubtleButton("save new password", fg) {652 error = when {653 current.isBlank() -> "enter your current password"654 next.length < 12 -> "new password must be at least 12 characters"655 next != confirm -> "new passwords do not match"656 next == current -> "new password must differ from current"657 else -> null658 }659 if (error == null) scope.launch {660 if (vm.changePassword(current, next)) {661 current = ""; next = ""; confirm = ""; open = false; done = true662 } else error = "incorrect current password"663 }664 }665 }666 }667}668669@Composable670private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {671 PeachField(672 label = placeholder,673 value = value,674 onValueChange = onChange,675 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,676 visualTransformation = PasswordVisualTransformation(),677 )678}679680681682@Composable683private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {684 val vm: UpdateViewModel = hiltViewModel()685 val available by vm.available.collectAsState()686 val state by vm.state.collectAsState()687 val changelog by vm.changelog.collectAsState()688 var changelogOpen by remember { mutableStateOf(false) }689690 RowLine(fg) {691 Name("Version", fg)692 ValueText(BuildConfig.VERSION_NAME, fg)693 }694 RowLine(fg) {695 Column(Modifier.weight(1f)) {696 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)697 Text(698 when {699 available != null -> "v${available?.version} is available"700 state.phase == UpdatePhase.Checking -> "Checking…"701 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"702 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"703 else -> "Peach checks once a day and tells you when there's a new version."704 },705 color = fg.copy(alpha = 0.7f),706 fontFamily = PeachType.Body,707 fontSize = 11.sp,708 lineHeight = 16.sp,709 modifier = Modifier.padding(top = 2.dp),710 )711 }712 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {713 SubtleButton("changelog", fg) {714 changelogOpen = !changelogOpen715 if (changelogOpen) vm.loadChangelog()716 }717 if (available != null) {718 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }719 } else {720 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {721 vm.check()722 }723 }724 }725 }726 if (changelogOpen) {727 when (changelog.phase) {728 ChangelogPhase.Idle -> {}729 ChangelogPhase.Loading -> Text(730 "Loading changelog…",731 color = fg.copy(alpha = 0.68f),732 fontFamily = PeachType.Body,733 fontSize = 11.sp,734 modifier = Modifier.padding(vertical = 8.dp),735 )736 ChangelogPhase.Failed -> RowLine(fg) {737 Name("Couldn't reach the update server", fg)738 SubtleButton("retry", fg) { vm.loadChangelog() }739 }740 ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {741 changelog.entries.forEachIndexed { index, entry ->742 Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {743 Row(verticalAlignment = Alignment.CenterVertically) {744 Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)745 if (compareVersions(entry.version, vm.currentVersion) == 0) {746 Text(" current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)747 }748 }749 Text(750 entry.notes ?: "No notes for this release.",751 color = fg.copy(alpha = 0.72f),752 fontFamily = PeachType.Body,753 fontSize = 11.sp,754 lineHeight = 16.sp,755 modifier = Modifier.padding(top = 3.dp),756 )757 }758 if (index < changelog.entries.lastIndex) {759 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))760 }761 }762 }763 }764 }765}766767@Composable768private fun VaultBody(769 fg: Color,770 vm: SettingsViewModel,771 s: VaultSettings,772 entriesCount: Int,773 onNavigate: (PeachNav) -> Unit,774) {775 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")776 val current = s.trashRetentionDays ?: 0777 val isPro by vm.isPro.collectAsState()778 val dedupCount by vm.dedupCount.collectAsState()779 val dedupRunning by vm.dedupRunning.collectAsState()780 val dedupDone by vm.dedupDone.collectAsState()781 val history by vm.vaultHistory.collectAsState()782 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()783784 SettingsGroup("ENTRIES", fg, top = 0.dp) {785 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }786 RowLine(fg) {787 Name("Duplicates", fg)788 val done = dedupDone789 if (done != null) {790 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)791 } else {792 val count = dedupCount793 val label = when {794 dedupRunning -> "scanning..."795 count == null -> "scan"796 count == 0 -> "none found"797 else -> "$count found"798 }799 SubtleButton(label, fg) {800 if (count == null || count == 0) vm.scanDuplicates()801 }802 }803 }804 if ((dedupCount ?: 0) > 0 && dedupDone == null) {805 Column(806 Modifier.fillMaxWidth()807 .padding(top = 2.dp, bottom = 8.dp)808 .clip(RoundedCornerShape(8.dp))809 .background(fg.copy(alpha = 0.08f))810 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))811 .padding(10.dp),812 verticalArrangement = Arrangement.spacedBy(8.dp),813 ) {814 val count = dedupCount ?: 0815 Text(816 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",817 color = fg.copy(alpha = 0.82f),818 fontFamily = PeachType.Body,819 fontSize = 11.sp,820 lineHeight = 16.sp,821 )822 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {823 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }824 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }825 }826 }827 }828 }829830 SettingsGroup("MAINTENANCE", fg) {831 RowLine(fg) {832 Name("Trash auto-delete", fg)833 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)834 SubtleButton(trashOptions[idx].second, fg) {835 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)836 }837 }838 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }839 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }840 }841842 SettingsGroup("DATA", fg) {843 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }844 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }845 RowLine(fg) {846 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }847 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }848 }849 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }850 }851852 SettingsGroup("UPDATES", fg) {853 UpdatesRows(fg, onNavigate)854 }855856 SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {857 var historyShowAll by remember { mutableStateOf(false) }858 if (history.isEmpty()) {859 Text(860 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",861 color = fg.copy(alpha = 0.68f),862 fontFamily = PeachType.Body,863 fontSize = 11.sp,864 lineHeight = 16.sp,865 modifier = Modifier.padding(bottom = 6.dp),866 )867 } else {868 Text(869 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",870 color = fg.copy(alpha = 0.68f),871 fontFamily = PeachType.Body,872 fontSize = 11.sp,873 lineHeight = 16.sp,874 modifier = Modifier.padding(bottom = 4.dp),875 )876 history.take(if (historyShowAll) history.size else 3).forEach { snap ->877 RowLine(fg) {878 Column(Modifier.weight(1f)) {879 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)880 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)881 }882 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {883 vm.restoreVaultSnapshot(snap.id)884 }885 }886 }887 if (history.size > 3) {888 RowLine(fg) {889 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)890 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }891 }892 }893 Text(894 "${history.size} of 5 snapshots",895 color = fg.copy(alpha = 0.58f),896 fontFamily = PeachType.Mono,897 fontSize = 10.sp,898 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),899 )900 }901 }902903 SettingsGroup("ADVANCED", fg, initiallyOpen = false) {904 RowLine(fg) {905 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)906 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }907 }908 RowLine(fg) {909 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)910 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }911 }912 }913}914915private fun snapshotSubLabel(snapshot: VaultSnapshot): String {916 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }917 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")918}919920private fun snapshotReasonLabel(reason: String): String = when (reason) {921 "local_save" -> "saved"922 "sync_received" -> "synced"923 "import" -> "imported"924 "restore" -> "restored"925 else -> reason926}927928private fun snapshotTimeLabel(savedAt: Long): String {929 val diff = System.currentTimeMillis() - savedAt930 if (diff < 60_000) return "Just now"931 if (diff < 3_600_000) return "${diff / 60_000}m ago"932 val date = Date(savedAt)933 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())934 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)935 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)936 if (today == thenDay) return "Today $time"937 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))938 if (yesterday == thenDay) return "Yesterday $time"939 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)940}941942943944@Composable945private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {946 val context = LocalContext.current947 val syncVm: SyncViewModel = hiltViewModel()948 val pairedDevices by syncVm.pairedDevices.collectAsState()949 val pendingVouched by syncVm.pendingVouched.collectAsState()950 val syncStatus by syncVm.status.collectAsState()951 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()952 val syncMode by syncVm.syncMode.collectAsState()953 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()954 var notificationsAvailable by remember {955 mutableStateOf(canShowBackgroundSyncNotification(context))956 }957 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }958 val scope = rememberCoroutineScope()959 var confirmUnpair by remember { mutableStateOf<String?>(null) }960 var renamingDeviceId by remember { mutableStateOf<String?>(null) }961 var renameDraft by remember { mutableStateOf("") }962 var rotateConfirmOpen by remember { mutableStateOf(false) }963 var rotating by remember { mutableStateOf(false) }964 var rotateError by remember { mutableStateOf<String?>(null) }965 var relayUrlDraft by remember(configuredRelayUrl) {966 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))967 }968 var relayUrlStatus by remember { mutableStateOf<String?>(null) }969 var showConnection by remember { mutableStateOf(false) }970 var showRecovery by remember { mutableStateOf(false) }971972 SubLabel("ADD A DEVICE", fg, top = 0.dp)973 RowLine(fg) {974 Column(Modifier.weight(1f)) {975 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)976 Text(977 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",978 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,979 modifier = Modifier.padding(top = 2.dp),980 )981 }982 983 984 985 986 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }987 Spacer(Modifier.width(PeachSpacing.xs))988 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }989 }990991 SubLabel("SYNC STATUS", fg)992 val indicator = syncIndicator(syncStatus, pairedDevices.size)993 RowLine(fg) {994 SyncIndicatorDot(995 indicator = indicator,996 label = syncIndicatorLabel(indicator, syncStatus),997 modifier = Modifier.padding(end = PeachSpacing.sm),998 size = 8.dp,999 )1000 Text(1001 describeSyncOutcome(syncStatus),1002 color = fg.copy(alpha = 0.7f),1003 fontFamily = PeachType.Body,1004 fontSize = 11.sp,1005 lineHeight = 16.sp,1006 modifier = Modifier.weight(1f),1007 )1008 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {1009 if (!syncStatus.syncing) syncVm.syncNow()1010 }1011 }10121013 SubLabel("PAIRED DEVICES", fg)1014 if (pairedDevices.isEmpty()) {1015 PeachEmptyOrEndState(1016 message = "No paired devices yet.",1017 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",1018 modifier = Modifier.padding(top = PeachSpacing.xs),1019 )1020 } else {1021 pairedDevices.forEach { d ->1022 RowLine(fg) {1023 Column(Modifier.weight(1f)) {1024 if (renamingDeviceId == d.deviceId) {1025 Box(1026 Modifier.fillMaxWidth()1027 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1028 .padding(horizontal = 10.dp, vertical = 8.dp),1029 ) {1030 BasicTextField(1031 value = renameDraft,1032 onValueChange = { renameDraft = it.take(64) },1033 singleLine = true,1034 textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1035 cursorBrush = SolidColor(fg),1036 modifier = Modifier.fillMaxWidth(),1037 )1038 }1039 } else {1040 Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1041 }1042 Text(1043 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1044 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1045 modifier = Modifier.padding(top = 2.dp),1046 )1047 }1048 if (confirmUnpair != d.deviceId) {1049 if (renamingDeviceId == d.deviceId) {1050 SubtleButton("cancel", fg) { renamingDeviceId = null }1051 Spacer(Modifier.width(PeachSpacing.xs))1052 SubtleButton("save", fg) {1053 if (renameDraft.isNotBlank()) {1054 syncVm.renameDevice(d.deviceId, renameDraft)1055 renamingDeviceId = null1056 }1057 }1058 } else {1059 SubtleButton("rename", fg) {1060 renameDraft = d.localAlias ?: d.deviceName ?: ""1061 renamingDeviceId = d.deviceId1062 }1063 }1064 if (renamingDeviceId != d.deviceId) {1065 Spacer(Modifier.width(PeachSpacing.xs))1066 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1067 }1068 }1069 }1070 1071 1072 1073 1074 1075 if (confirmUnpair == d.deviceId) {1076 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1077 Text(1078 "Stops this device receiving future changes. It doesn't lock it " +1079 "out of what it already has - if it's lost or stolen, also " +1080 "change your master password and generate new recovery words.",1081 color = fg.copy(alpha = 0.7f),1082 fontFamily = PeachType.Body,1083 fontSize = 11.sp,1084 lineHeight = 16.sp,1085 )1086 Row(1087 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1088 horizontalArrangement = Arrangement.End,1089 ) {1090 SubtleButton("cancel", fg) { confirmUnpair = null }1091 Spacer(Modifier.width(PeachSpacing.sm))1092 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1093 }1094 }1095 }1096 }1097 }10981099 SyncDisclosure(1100 title = "Connection",1101 summary = "Background sync, relay, and privacy choices",1102 expanded = showConnection,1103 fg = fg,1104 onToggle = { showConnection = !showConnection },1105 )1106 if (showConnection) {1107 RowLine(fg) {1108 Column(Modifier.weight(1f)) {1109 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1110 Text(1111 when {1112 backgroundSyncEnabled && !notificationsAvailable ->1113 "Action needed — Android notifications are off, so background sync is not visible."1114 backgroundSyncEnabled ->1115 "On — keeps this phone reachable for paired devices."1116 else ->1117 "Off — Peach syncs only while you are using the app."1118 },1119 color = fg.copy(alpha = 0.7f),1120 fontFamily = PeachType.Body,1121 fontSize = 11.sp,1122 lineHeight = 16.sp,1123 modifier = Modifier.padding(top = 2.dp),1124 )1125 }1126 SubtleButton(1127 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1128 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1129 ) { showBackgroundSyncPrompt = true }1130 }1131 SyncModeRow(1132 title = "Public relay",1133 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1134 selected = syncMode == SyncMode.PUBLIC,1135 fg = fg,1136 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1137 SyncModeRow(1138 title = "Private relay",1139 detail = "Use your own peach-relay on your own domain.",1140 selected = syncMode == SyncMode.PRIVATE,1141 fg = fg,1142 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }11431144 if (syncMode == SyncMode.PRIVATE) {1145 Text(1146 "RELAY URL",1147 color = fg.copy(alpha = 0.6f),1148 fontFamily = PeachType.Mono,1149 fontSize = 8.sp,1150 letterSpacing = 0.22.em,1151 modifier = Modifier.padding(top = 8.dp),1152 )1153 Box(1154 Modifier.fillMaxWidth().padding(top = 6.dp)1155 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1156 .padding(horizontal = 12.dp, vertical = 10.dp),1157 ) {1158 BasicTextField(1159 value = relayUrlDraft,1160 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1161 singleLine = true,1162 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1163 cursorBrush = SolidColor(fg),1164 modifier = Modifier.fillMaxWidth(),1165 )1166 }1167 val needsRelayUrl = configuredRelayUrl.isEmpty()1168 RowLine(fg) {1169 val message = relayUrlStatus1170 ?: if (needsRelayUrl) {1171 "Sync is paused until you enter your relay URL."1172 } else {1173 "Only secure wss:// relays are accepted."1174 }1175 val warn = relayUrlStatus?.startsWith("Use") == true ||1176 (relayUrlStatus == null && needsRelayUrl)1177 ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1178 Spacer(Modifier.width(PeachSpacing.xs))1179 SubtleButton("save", fg) {1180 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1181 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1182 "Saved"1183 } else {1184 "Use a valid wss:// relay URL"1185 }1186 }1187 }1188 }11891190 SyncModeRow(1191 title = "No sync",1192 detail = "Keep this device separate and move backups by hand.",1193 selected = syncMode == SyncMode.OFF,1194 fg = fg,1195 ) { syncVm.setSyncMode(SyncMode.OFF) }1196 Hint("Pairing authorizes a device, not a cloud account.", fg)1197 }11981199 if (pendingVouched.isNotEmpty()) {1200 SubLabel("PENDING DEVICES", fg)1201 pendingVouched.forEach { dev ->1202 RowLine(fg) {1203 Column(Modifier.weight(1f)) {1204 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1205 Text(1206 "Was added by another device. Allow it to sync?",1207 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1208 modifier = Modifier.padding(top = 2.dp),1209 )1210 }1211 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1212 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1213 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1214 }1215 }1216 }1217 }12181219 SyncDisclosure(1220 title = "Recovery & security",1221 summary = "For a lost or stolen device",1222 expanded = showRecovery,1223 fg = fg,1224 onToggle = { showRecovery = !showRecovery },1225 )1226 if (showRecovery) {1227 RowLine(fg) {1228 Column(Modifier.weight(1f)) {1229 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1230 Text(1231 "Scan only when this populated vault previously trusted the source device.",1232 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1233 modifier = Modifier.padding(top = 2.dp),1234 )1235 }1236 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1237 }1238 if (rotateConfirmOpen) {1239 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1240 rotateError?.let {1241 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1242 }1243 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1244 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1245 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1246 if (rotating) return@SubtleButton1247 rotating = true1248 rotateError = null1249 scope.launch {1250 val ok = syncVm.rotateSyncIdentity()1251 rotating = false1252 if (ok) {1253 rotateConfirmOpen = false1254 onNavigate(PeachNav.RecoverySetup)1255 } else {1256 rotateError = "Rotation failed. Unlock your real vault and try again."1257 }1258 }1259 }1260 }1261 } else {1262 RowLine(fg) {1263 Column(Modifier.weight(1f)) {1264 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1265 Text(1266 "Disconnects every device and replaces the keys used for sync.",1267 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1268 modifier = Modifier.padding(top = 2.dp),1269 )1270 }1271 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1272 }1273 }1274 }12751276 if (showBackgroundSyncPrompt) {1277 BackgroundSyncDialog { enabled ->1278 showBackgroundSyncPrompt = false1279 notificationsAvailable = canShowBackgroundSyncNotification(context)1280 syncVm.setBackgroundSyncEnabled(enabled)1281 }1282 }1283}12841285private fun deviceTypeLabel(type: String?): String = when (type) {1286 "android" -> "Android"1287 "phone" -> "Phone"1288 "tablet" -> "Tablet"1289 "browser" -> "Browser"1290 else -> "Device"1291}12921293private fun syncTimeLabel(lastSyncedAt: Long?): String {1294 if (lastSyncedAt == null) return "never synced"1295 val diff = System.currentTimeMillis() - lastSyncedAt1296 if (diff < 60_000) return "just now"1297 if (diff < 3_600_000) return "${diff / 60_000}m ago"1298 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1299 return "${diff / 86_400_000}d ago"1300}13011302private fun isSyncStale(lastSyncedAt: Long?): Boolean =1303 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1304130513061307@Composable1308private fun ProBody(fg: Color, vm: SettingsViewModel) {1309 val ctx = LocalContext.current1310 val isPro by vm.isPro.collectAsState()1311 if (isPro) {1312 ProActiveBody(fg, vm)1313 return1314 }1315 val features = listOf(1316 "Peach Sync" to "End-to-end encrypted",1317 "Peach Codex" to "Restore from paper",1318 "Passkeys" to "Zero-knowledge WebAuthn",1319 "Advanced Vault Health" to "Reuse · age · exposure",1320 "Canary entries" to "Detects vault compromise",1321 "Duress vault" to "Decoy under coercion",1322 )1323 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1324 Text(1325 "Everything, everywhere.",1326 modifier = Modifier.padding(top = 8.dp),1327 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1328 )1329 Text(1330 "One key unlocks every Pro feature — for the lifetime of the app.",1331 modifier = Modifier.padding(top = 6.dp),1332 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1333 )1334 Spacer(Modifier.height(10.dp))1335 features.forEachIndexed { i, (name, desc) ->1336 1337 1338 1339 1340 RowLine(fg, verticalPadding = 6.dp) {1341 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1342 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1343 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1344 }1345 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1346 }1347 }1348 Spacer(Modifier.height(12.dp))1349 Text(1350 "$39",1351 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1352 )1353 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1354 Spacer(Modifier.height(10.dp))1355 Box(1356 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1357 .background(fg.copy(alpha = 0.16f))1358 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1359 .clickable {1360 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1361 }1362 .padding(vertical = 12.dp),1363 contentAlignment = Alignment.Center,1364 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }13651366 1367 1368 1369 1370 Spacer(Modifier.height(44.dp))13711372 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1373 var key by remember { mutableStateOf("") }1374 val activating by vm.activating.collectAsState()1375 val activationError by vm.activationError.collectAsState()1376 Box(1377 Modifier.fillMaxWidth().padding(top = 6.dp)1378 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1379 .padding(horizontal = 12.dp, vertical = 10.dp),1380 ) {1381 BasicTextField(1382 value = key,1383 1384 1385 1386 1387 onValueChange = { key = it; vm.clearActivationError() },1388 singleLine = true,1389 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1390 cursorBrush = SolidColor(fg),1391 modifier = Modifier.fillMaxWidth(),1392 decorationBox = { inner ->1393 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1394 inner()1395 },1396 )1397 }1398 if (activationError != null) {1399 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1400 }1401 Spacer(Modifier.height(8.dp))1402 Box(1403 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1404 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1405 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1406 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1407 .padding(vertical = 12.dp),1408 contentAlignment = Alignment.Center,1409 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1410}14111412@Composable1413private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1414 val ctx = LocalContext.current1415 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1416 val licenseKey by vm.licenseKey.collectAsState()1417 val issuedAt by vm.licenseIssuedAt.collectAsState()1418 var showKey by remember { mutableStateOf(false) }1419 var copied by remember { mutableStateOf(false) }1420 val scope = rememberCoroutineScope()1421 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1422 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1423 }1424 Text(1425 "Peach Pro",1426 modifier = Modifier.fillMaxWidth(),1427 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1428 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1429 )1430 Text(1431 formatLicenseActiveSince(issuedAt),1432 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1433 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1434 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1435 )1436 licenseKey?.let { key ->1437 Spacer(Modifier.height(12.dp))1438 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1439 RowLine(fg) {1440 Text(1441 if (showKey) key else "************************",1442 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1443 fontFamily = PeachType.Mono,1444 fontSize = 10.sp,1445 modifier = Modifier.weight(1f),1446 )1447 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1448 SubtleButton(if (copied) "copied" else "copy", fg) {1449 PeachClipboard.copy(ctx, "peach license", key)1450 copied = true1451 scope.launch {1452 delay(1_500)1453 copied = false1454 }1455 }1456 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1457 }1458 }1459 }1460 Spacer(Modifier.height(12.dp))1461 features.forEachIndexed { i, name ->1462 RowLine(fg) {1463 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1464 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1465 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1466 }1467 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1468 }1469 }1470 Spacer(Modifier.height(12.dp))1471 Box(1472 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1473 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1474 .clickable { vm.deactivate() }1475 .padding(vertical = 12.dp),1476 contentAlignment = Alignment.Center,1477 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1478}14791480private fun formatLicenseActiveSince(issuedAt: Long?): String {1481 if (issuedAt == null) return "Active"1482 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1483 return "Active since $monthYear"1484}1485148614871488@OptIn(ExperimentalLayoutApi::class)1489@Composable1490private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1491 val categories = listOf("Bug report", "Feature request", "Question", "Other")1492 var category by remember { mutableStateOf(categories[0]) }1493 var message by remember { mutableStateOf("") }1494 val state by vm.feedbackState.collectAsState()14951496 1497 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }14981499 if (state is FeedbackState.Sent) {1500 Box(1501 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1502 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1503 ) {1504 Column {1505 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1506 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))1507 }1508 }1509 return1510 }15111512 val sending = state is FeedbackState.Sending15131514 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)1515 SubLabel("TYPE", fg)1516 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1517 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1518 }1519 SubLabel("MESSAGE", fg)1520 Box(1521 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1522 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1523 .padding(10.dp),1524 ) {1525 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1526 BasicTextField(1527 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1528 enabled = !sending,1529 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1530 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1531 )1532 }1533 Text(1534 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1535 modifier = Modifier.padding(top = 6.dp),1536 color = fg.copy(alpha = 0.52f),1537 fontFamily = PeachType.Mono,1538 fontSize = 10.sp,1539 lineHeight = 14.sp,1540 )1541 Spacer(Modifier.height(8.dp))15421543 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1544 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1545 Box(1546 Modifier.clip(RoundedCornerShape(8.dp))1547 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1548 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1549 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1550 .padding(horizontal = 12.dp, vertical = 6.dp),1551 ) {1552 Text(1553 feedbackSubmitLabel(state),1554 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1555 fontFamily = PeachType.Mono, fontSize = 11.sp,1556 )1557 }1558 if (sending) {1559 CircularProgressIndicator(1560 color = LocalPeachAccent.current,1561 strokeWidth = 2.dp,1562 modifier = Modifier.size(16.dp),1563 )1564 }1565 }15661567 (state as? FeedbackState.Error)?.let { err ->1568 Text(1569 err.message,1570 modifier = Modifier.padding(top = 8.dp),1571 color = peachColors.Breach,1572 fontFamily = PeachType.Mono,1573 fontSize = 11.sp,1574 lineHeight = 15.sp,1575 )1576 }15771578 Hint("Sent anonymously · no account required", fg)1579}1580158115821583@Composable1584internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1585 val configured by vm.duressConfigured.collectAsState()1586 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1587}15881589@Composable1590private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1591 Text(1592 "Duress password is active",1593 color = peachColors.HealthOk,1594 fontFamily = PeachType.Mono,1595 fontSize = 11.sp,1596 modifier = Modifier.padding(top = 6.dp),1597 )1598 Spacer(Modifier.height(8.dp))1599 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1600 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1601}16021603@Composable1604private fun DuressForm(fg: Color, vm: SettingsViewModel) {1605 var enabled by remember { mutableStateOf(false) }1606 var password by remember { mutableStateOf("") }1607 var confirm by remember { mutableStateOf("") }1608 var action by remember { mutableStateOf(DuressAction.DECOY) }1609 var error by remember { mutableStateOf<String?>(null) }1610 val scope = rememberCoroutineScope()16111612 RowLine(fg) {1613 Name("Duress password", fg)1614 Toggle(enabled, fg) { on ->1615 enabled = on1616 if (!on) { password = ""; confirm = ""; error = null }1617 }1618 }1619 Text(1620 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1621 color = fg.copy(alpha = 0.7f),1622 fontFamily = PeachType.Mono,1623 fontSize = 10.sp,1624 modifier = Modifier.padding(top = 6.dp),1625 )16261627 AnimatedVisibility(enabled) {1628 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1629 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1630 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }16311632 Text(1633 "When your duress password is entered at the lock screen, Peach will:",1634 color = fg.copy(alpha = 0.7f),1635 fontFamily = PeachType.Mono,1636 fontSize = 10.sp,1637 modifier = Modifier.padding(top = 4.dp),1638 )16391640 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1641 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }16421643 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }16441645 val canSave = password.isNotBlank() && confirm.isNotBlank()1646 DuressSaveButton("Save duress password", fg, canSave) {1647 error = when {1648 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1649 password != confirm -> "Passwords do not match"1650 else -> null1651 }1652 if (error == null) scope.launch {1653 if (vm.saveDuress(password, action)) {1654 password = ""; confirm = ""; enabled = false1655 } else {1656 error = "Couldn't save — the duress passphrase must differ from your real password"1657 }1658 }1659 }1660 }1661 }1662}16631664@Composable1665private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1666 val accent = LocalPeachAccent.current1667 Row(1668 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1669 verticalAlignment = Alignment.CenterVertically,1670 horizontalArrangement = Arrangement.spacedBy(10.dp),1671 ) {1672 Box(1673 Modifier.size(16.dp).clip(CircleShape)1674 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1675 contentAlignment = Alignment.Center,1676 ) {1677 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1678 }1679 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1680 }1681}16821683@Composable1684private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1685 val accent = LocalPeachAccent.current1686 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1687 Box(1688 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1689 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1690 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1691 .let { if (enabled) it.clickable(onClick = onClick) else it }1692 .padding(vertical = 11.dp),1693 contentAlignment = Alignment.Center,1694 ) {1695 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1696 }1697}16981699@Composable1700private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1701 Box(1702 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1703 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1704 .clickable(onClick = onClick)1705 .padding(vertical = 11.dp),1706 contentAlignment = Alignment.Center,1707 ) {1708 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1709 }1710}171117121713@Composable1714private fun SyncModeRow(1715 title: String,1716 detail: String,1717 selected: Boolean,1718 fg: Color,1719 onSelect: () -> Unit,1720) {1721 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1722 Column(Modifier.weight(1f)) {1723 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1724 Text(1725 detail,1726 color = fg.copy(alpha = 0.7f),1727 fontFamily = PeachType.Body,1728 fontSize = 11.sp,1729 lineHeight = 16.sp,1730 modifier = Modifier.padding(top = 2.dp),1731 )1732 }1733 Text(1734 if (selected) "●" else "○",1735 color = if (selected) fg else fg.copy(alpha = 0.45f),1736 fontFamily = PeachType.Mono,1737 fontSize = 14.sp,1738 modifier = Modifier.padding(start = 10.dp),1739 )1740 }1741}