1package com.peachpasswords.ui.settings23import android.content.Intent4import android.net.Uri5import androidx.compose.animation.AnimatedVisibility6import androidx.compose.animation.core.animateDpAsState7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.FlowRow14import androidx.compose.foundation.layout.ExperimentalLayoutApi15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.RowScope17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.heightIn21import androidx.compose.foundation.layout.padding22import androidx.compose.foundation.layout.size23import androidx.compose.foundation.layout.width24import androidx.compose.foundation.shape.CircleShape25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.runtime.Composable28import androidx.compose.material3.CircularProgressIndicator29import androidx.compose.runtime.DisposableEffect30import androidx.compose.runtime.collectAsState31import androidx.compose.runtime.getValue32import com.peachpasswords.feedback.FeedbackState33import com.peachpasswords.shared.PeachClipboard34import com.peachpasswords.sync.SyncMode35import com.peachpasswords.sync.canShowBackgroundSyncNotification36import com.peachpasswords.sync.describeSyncOutcome37import com.peachpasswords.sync.privateRelayDraftOf38import com.peachpasswords.sync.syncIndicator39import com.peachpasswords.sync.syncIndicatorLabel40import com.peachpasswords.ui.components.SyncIndicatorDot41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.vault.DuressAction43import com.peachpasswords.ui.theme.peachColors44import androidx.compose.runtime.mutableStateOf45import androidx.compose.runtime.remember46import androidx.compose.runtime.rememberCoroutineScope47import androidx.compose.runtime.setValue48import androidx.compose.ui.Alignment49import androidx.compose.ui.Modifier50import androidx.compose.ui.draw.clip51import androidx.compose.ui.graphics.Color52import androidx.compose.ui.graphics.SolidColor53import androidx.compose.ui.platform.LocalContext54import androidx.compose.ui.text.TextStyle55import androidx.compose.ui.text.font.FontStyle56import androidx.compose.ui.text.input.PasswordVisualTransformation57import androidx.compose.ui.text.input.KeyboardType58import androidx.compose.ui.unit.Dp59import androidx.compose.ui.unit.dp60import androidx.compose.ui.unit.em61import androidx.compose.ui.unit.sp62import androidx.compose.material3.Text63import com.peachpasswords.BuildConfig64import com.peachpasswords.autofill.AutofillEnablement65import com.peachpasswords.ui.PeachNav66import com.peachpasswords.ui.components.PeachEmptyOrEndState67import com.peachpasswords.ui.components.PeachField68import com.peachpasswords.ui.pairing.BackgroundSyncDialog69import com.peachpasswords.ui.sync.SyncViewModel70import com.peachpasswords.ui.theme.ACCENT_COLORS71import com.peachpasswords.ui.theme.PeachSpacing72import com.peachpasswords.ui.theme.PeachType73import com.peachpasswords.ui.updates.ChangelogPhase74import com.peachpasswords.ui.updates.UpdatePhase75import com.peachpasswords.ui.updates.UpdateViewModel76import com.peachpasswords.updates.compareVersions77import androidx.hilt.navigation.compose.hiltViewModel78import com.peachpasswords.vault.VaultSettings79import com.peachpasswords.vault.VaultSnapshot80import kotlinx.coroutines.launch81import kotlinx.coroutines.delay82import java.text.SimpleDateFormat83import java.util.Date84import java.util.Locale8586private const val PRO_URL = "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"878889909192939495@Composable96fun CardBody(97 id: CardId,98 fg: Color,99 vm: SettingsViewModel,100 settings: VaultSettings,101 entriesCount: Int,102 onNavigate: (PeachNav) -> Unit,103) {104 when (id) {105 CardId.Appearance -> AppearanceBody(fg, vm, settings)106 CardId.Protection -> ProtectionBody(fg, vm, settings, onNavigate)107 CardId.Vault -> VaultBody(fg, vm, settings, entriesCount, onNavigate)108 CardId.Sync -> SyncBody(fg, onNavigate)109 CardId.Pro -> ProBody(fg, vm)110 CardId.Feedback -> FeedbackBody(fg, vm)111 }112}113114115116@Composable117private fun SubLabel(text: String, fg: Color, top: Dp = 14.dp) {118 Text(119 text,120 modifier = Modifier.padding(top = top, bottom = 4.dp),121 color = fg.copy(alpha = 0.58f),122 fontFamily = PeachType.Mono,123 fontSize = 8.sp,124 letterSpacing = 0.22.em,125 )126}127128129130131132133@Composable134private fun SettingsGroup(135 label: String,136 fg: Color,137 initiallyOpen: Boolean = true,138 top: Dp = 14.dp,139 content: @Composable () -> Unit,140) {141 var open by remember { mutableStateOf(initiallyOpen) }142 Row(143 Modifier.fillMaxWidth()144 .clip(RoundedCornerShape(6.dp))145 .clickable { open = !open }146 .padding(top = top, bottom = 4.dp),147 verticalAlignment = Alignment.CenterVertically,148 ) {149 Text(150 label,151 modifier = Modifier.weight(1f),152 color = fg.copy(alpha = 0.58f),153 fontFamily = PeachType.Mono,154 fontSize = 8.sp,155 letterSpacing = 0.22.em,156 )157 Text(158 if (open) "–" else "+",159 color = fg.copy(alpha = 0.58f),160 fontFamily = PeachType.Mono,161 fontSize = 10.sp,162 )163 }164 165 166 AnimatedVisibility(visible = open) {167 Column(Modifier.fillMaxWidth()) { content() }168 }169}170171@Composable172private fun RowLine(173 fg: Color,174 modifier: Modifier = Modifier,175 verticalPadding: Dp = 9.dp,176 content: @Composable RowScope.() -> Unit,177) {178 Row(179 Modifier.fillMaxWidth().then(modifier).padding(vertical = verticalPadding),180 verticalAlignment = Alignment.CenterVertically,181 horizontalArrangement = Arrangement.SpaceBetween,182 content = content,183 )184 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.12f)))185}186187@Composable188private fun RowScope.Name(text: String, fg: Color) {189 Text(text, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp, modifier = Modifier.weight(1f))190}191192@Composable193private fun ValueText(text: String, fg: Color, modifier: Modifier = Modifier) {194 Text(text, color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Mono, fontSize = 11.sp, modifier = modifier)195}196197@Composable198private fun SubtleButton(label: String, fg: Color, onClick: () -> Unit) {199 Box(200 Modifier.clip(RoundedCornerShape(8.dp))201 .border(1.dp, fg.copy(alpha = 0.28f), RoundedCornerShape(8.dp))202 .background(fg.copy(alpha = 0.08f))203 .clickable(onClick = onClick)204 .padding(horizontal = 12.dp, vertical = 6.dp),205 ) {206 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1)207 }208}209210@Composable211private fun SyncDisclosure(212 title: String,213 summary: String,214 expanded: Boolean,215 fg: Color,216 onToggle: () -> Unit,217) {218 Row(219 modifier = Modifier220 .fillMaxWidth()221 .heightIn(min = 48.dp)222 .clip(RoundedCornerShape(8.dp))223 .clickable(onClick = onToggle)224 .padding(vertical = 8.dp),225 verticalAlignment = Alignment.CenterVertically,226 ) {227 Column(Modifier.weight(1f)) {228 Text(229 title.uppercase(),230 color = fg.copy(alpha = 0.58f),231 fontFamily = PeachType.Mono,232 fontSize = 8.sp,233 letterSpacing = 0.22.em,234 )235 Text(236 summary,237 color = fg.copy(alpha = 0.7f),238 fontFamily = PeachType.Body,239 fontSize = 11.sp,240 modifier = Modifier.padding(top = 3.dp),241 )242 }243 Text(244 if (expanded) "hide" else "show",245 color = fg,246 fontFamily = PeachType.Mono,247 fontSize = 10.sp,248 )249 }250}251252@Composable253private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {254 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")255 Box(256 Modifier.size(width = 34.dp, height = 20.dp).clip(RoundedCornerShape(999.dp))257 .background(if (on) fg.copy(alpha = 0.28f) else fg.copy(alpha = 0.08f))258 .border(1.dp, fg.copy(alpha = if (on) 0.5f else 0.32f), RoundedCornerShape(999.dp))259 .clickable { onToggle(!on) },260 ) {261 Box(262 Modifier.padding(top = 2.dp).width(16.dp).fillMaxWidth().height(16.dp)263 .padding(start = knob - 2.dp)264 .size(14.dp).clip(CircleShape)265 .background(if (on) fg else fg.copy(alpha = 0.58f)),266 )267 }268}269270@Composable271private fun Pill(label: String, active: Boolean, fg: Color, onClick: () -> Unit) {272 Box(273 Modifier.clip(RoundedCornerShape(999.dp))274 .border(1.dp, fg.copy(alpha = if (active) 0.55f else 0.32f), RoundedCornerShape(999.dp))275 .background(if (active) fg.copy(alpha = 0.18f) else Color.Transparent)276 .clickable(onClick = onClick)277 .padding(horizontal = 12.dp, vertical = 5.dp),278 ) {279 Text(label, color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)280 }281}282283@Composable284private fun ProBadge(fg: Color) {285 Box(286 Modifier.padding(start = 6.dp).clip(RoundedCornerShape(4.dp))287 .background(fg.copy(alpha = 0.16f)).padding(horizontal = 5.dp, vertical = 1.dp),288 ) {289 Text("Pro", color = fg.copy(alpha = 0.85f), fontFamily = PeachType.Mono, fontSize = 8.sp)290 }291}292293@Composable294private fun Hint(text: String, fg: Color) {295 Text(296 text,297 modifier = Modifier.padding(top = 6.dp),298 color = fg.copy(alpha = 0.6f),299 fontFamily = PeachType.Display,300 fontStyle = FontStyle.Italic,301 fontSize = 11.5.sp,302 lineHeight = 16.sp,303 )304}305306307308@OptIn(ExperimentalLayoutApi::class)309@Composable310private fun AppearanceBody(fg: Color, vm: SettingsViewModel, s: VaultSettings) {311 SubLabel("ACCENT", fg, top = 0.dp)312 FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {313 ACCENT_COLORS.forEach { (name, color) ->314 Box(315 Modifier.size(22.dp).clip(CircleShape).background(color)316 .border(317 if (s.accentColor == name) 2.dp else 1.dp,318 if (s.accentColor == name) fg else fg.copy(alpha = 0.2f),319 CircleShape,320 )321 .clickable { vm.setAccent(name) },322 )323 }324 }325 SubLabel("TEXT SIZE", fg)326 RowLine(fg) {327 Name("Text size", fg)328 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {329 Pill("small", s.textSize == "small", fg) { vm.setTextSize("small") }330 Pill("medium", s.textSize == "medium", fg) { vm.setTextSize("medium") }331 Pill("large", s.textSize == "large", fg) { vm.setTextSize("large") }332 }333 }334 SubLabel("UI SCALE", fg)335 RowLine(fg) {336 Name("Large-screen sizing", fg)337 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {338 Pill("compact", s.uiScale == "compact", fg) { vm.setUiScale("compact") }339 Pill("auto", s.uiScale == "auto", fg) { vm.setUiScale("auto") }340 Pill("large", s.uiScale == "large", fg) { vm.setUiScale("large") }341 }342 }343 Hint("Auto keeps foldables roomy while preventing tablet controls from inflating.", fg)344 SubLabel("ICONS", fg)345 RowLine(fg) {346 Name("Site icons", fg)347 SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) { vm.setFaviconsEnabled(!s.faviconsEnabled) }348 }349 SubLabel("MOTION", fg)350 RowLine(fg) {351 Name("Reduce motion", fg)352 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }353 }354}355356357358@Composable359private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {360 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")361 val current = s.autoLockMinutes ?: 0362 val pinEnabled by vm.pinEnabled.collectAsState()363 var pinOpen by remember { mutableStateOf(false) }364 var pinRemovalOpen by remember { mutableStateOf(false) }365 var pinMaster by remember { mutableStateOf("") }366 var pin by remember { mutableStateOf("") }367 var pinConfirm by remember { mutableStateOf("") }368 var pinRemovalMaster by remember { mutableStateOf("") }369 var pinError by remember { mutableStateOf<String?>(null) }370 val pinScope = rememberCoroutineScope()371372 SubLabel("LOCKING", fg, top = 0.dp)373 RowLine(fg) {374 Name("Allow screenshots", fg)375 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }376 }377 RowLine(fg) {378 Name("Auto-lock after", fg)379 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)380 SubtleButton(lockOptions[idx].second, fg) {381 val next = lockOptions[(idx + 1) % lockOptions.size].first382 vm.setAutoLockMinutes(if (next == 0) 0 else next)383 }384 }385 SubLabel("PIN UNLOCK", fg)386 RowLine(fg) {387 Name("Unlock with PIN", fg)388 Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {389 ValueText(if (pinEnabled) "enabled" else "off", fg)390 SubtleButton(if (pinEnabled) "change" else "set", fg) {391 pin = ""392 pinConfirm = ""393 pinMaster = ""394 pinError = null395 pinOpen = !pinOpen396 }397 }398 }399 if (pinEnabled) {400 RowLine(fg) {401 Name("Remove PIN unlock", fg)402 SubtleButton("turn off", fg) {403 pinRemovalMaster = ""404 pinError = null405 pinRemovalOpen = !pinRemovalOpen406 }407 }408 }409 AnimatedVisibility(pinRemovalOpen) {410 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {411 PeachField(412 label = "master password",413 value = pinRemovalMaster,414 onValueChange = { pinRemovalMaster = it; pinError = null },415 keyboardType = KeyboardType.Password,416 visualTransformation = PasswordVisualTransformation(),417 )418 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }419 SubtleButton("remove PIN", fg) {420 pinScope.launch {421 val error = vm.disablePin(pinRemovalMaster)422 if (error == null) {423 pinRemovalMaster = ""424 pinRemovalOpen = false425 } else {426 pinError = error427 }428 }429 }430 }431 }432 AnimatedVisibility(pinOpen) {433 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {434 PeachField(435 label = "master password",436 value = pinMaster,437 onValueChange = { pinMaster = it; pinError = null },438 keyboardType = KeyboardType.Password,439 visualTransformation = PasswordVisualTransformation(),440 )441 PeachField(442 label = "new PIN (4–12 digits)",443 value = pin,444 onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },445 keyboardType = KeyboardType.NumberPassword,446 visualTransformation = PasswordVisualTransformation(),447 )448 PeachField(449 label = "confirm PIN",450 value = pinConfirm,451 onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },452 keyboardType = KeyboardType.NumberPassword,453 visualTransformation = PasswordVisualTransformation(),454 )455 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }456 SubtleButton("save PIN", fg) {457 pinError = if (pin != pinConfirm) "PINs do not match" else null458 if (pinError == null) pinScope.launch {459 val error = vm.savePin(pinMaster, pin)460 if (error == null) {461 pinMaster = ""462 pin = ""463 pinConfirm = ""464 pinOpen = false465 } else {466 pinError = error467 }468 }469 }470 }471 }472 SubLabel("BIOMETRICS", fg)473 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity474 RowLine(fg) {475 Name("Unlock with biometrics", fg)476 Toggle(s.biometricsEnabled, fg) { on ->477 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()478 }479 }480 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)481 if (bioError != null) {482 Text(483 bioError,484 color = com.peachpasswords.ui.theme.peachColors.Breach,485 fontFamily = PeachType.Mono, fontSize = 10.sp,486 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),487 )488 }489 SubLabel("AUTOFILL", fg)490 run {491 492 493 val ctx = LocalContext.current494 val enabled = AutofillEnablement.isEnabled(ctx)495 RowLine(fg) {496 Column(Modifier.weight(1f)) {497 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)498 Text(499 if (enabled) {500 "Peach is your autofill service."501 } else {502 "Android decides which app fills passwords. Turn Peach on in system settings."503 },504 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,505 modifier = Modifier.padding(top = 2.dp),506 )507 }508 if (!enabled) {509 SubtleButton("enable", fg) {510 511 512 513 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }514 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }515 }516 } else {517 ValueText("on", fg)518 }519 }520 }521522 RowLine(fg) {523 Column(Modifier.weight(1f)) {524 Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)525 Text(526 "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",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 Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }532 }533534 SubLabel("PASSKEYS", fg)535 RowLine(fg) {536 Column(Modifier.weight(1f)) {537 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)538 Text(539 "Peach can handle passkey sign-in and creation through Android Credential Manager.",540 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,541 modifier = Modifier.padding(top = 2.dp),542 )543 }544 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }545 }546 RowLine(fg) {547 Name("Status", fg)548 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)549 }550 SubLabel("RECOVERY", fg)551 RowLine(fg) {552 Column(Modifier.weight(1f)) {553 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)554 Text(555 "Keep the paper copy current before rotating sync identity or replacing devices.",556 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,557 modifier = Modifier.padding(top = 2.dp),558 )559 }560 SubtleButton("view", fg) {561 if (bioActivity != null) {562 com.peachpasswords.ui.lock.Biometrics.authenticate(563 activity = bioActivity,564 title = "View recovery words",565 subtitle = "Confirm your identity to view your recovery words",566 onSuccess = { onNavigate(PeachNav.RecoveryView) },567 )568 } else onNavigate(PeachNav.RecoveryView)569 }570 }571 SubLabel("BREACH PROTECTION", fg)572 val checkingBreaches by vm.checkingBreaches.collectAsState()573 val breachResult by vm.breachResult.collectAsState()574 RowLine(fg) {575 Name("Breach monitoring", fg)576 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }577 }578 RowLine(fg) {579 Name("Check all passwords", fg)580 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }581 }582 breachResult?.let { result ->583 RowLine(fg) {584 Name("Last check", fg)585 ValueText(result, fg)586 }587 }588589 SubLabel("MASTER PASSWORD", fg)590 ChangePasswordRow(fg, vm)591592 SubLabel("DURESS PASSWORD", fg)593 val duressIsPro by vm.isPro.collectAsState()594 if (!duressIsPro) {595 RowLine(fg) {596 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {597 Name("Duress password", fg); ProBadge(fg)598 }599 ValueText("off", fg)600 }601 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)602 } else {603 DuressSection(fg, vm)604 }605}606607@Composable608private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {609 var open by remember { mutableStateOf(false) }610 var current by remember { mutableStateOf("") }611 var next by remember { mutableStateOf("") }612 var confirm by remember { mutableStateOf("") }613 var error by remember { mutableStateOf<String?>(null) }614 var done by remember { mutableStateOf(false) }615 val scope = rememberCoroutineScope()616617 RowLine(fg) {618 Name("Master password", fg)619 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }620 }621 AnimatedVisibility(open) {622 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {623 PwField("current password", current, fg) { current = it }624 PwField("new password (min 12 chars)", next, fg) { next = it }625 PwField("confirm new password", confirm, fg) { confirm = it }626 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }627 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)628 SubtleButton("save new password", fg) {629 error = when {630 current.isBlank() -> "enter your current password"631 next.length < 12 -> "new password must be at least 12 characters"632 next != confirm -> "new passwords do not match"633 next == current -> "new password must differ from current"634 else -> null635 }636 if (error == null) scope.launch {637 if (vm.changePassword(current, next)) {638 current = ""; next = ""; confirm = ""; open = false; done = true639 } else error = "incorrect current password"640 }641 }642 }643 }644}645646@Composable647private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {648 PeachField(649 label = placeholder,650 value = value,651 onValueChange = onChange,652 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,653 visualTransformation = PasswordVisualTransformation(),654 )655}656657658659@Composable660private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {661 val vm: UpdateViewModel = hiltViewModel()662 val available by vm.available.collectAsState()663 val state by vm.state.collectAsState()664 val changelog by vm.changelog.collectAsState()665 var changelogOpen by remember { mutableStateOf(false) }666667 RowLine(fg) {668 Name("Version", fg)669 ValueText(BuildConfig.VERSION_NAME, fg)670 }671 RowLine(fg) {672 Column(Modifier.weight(1f)) {673 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)674 Text(675 when {676 available != null -> "v${available?.version} is available"677 state.phase == UpdatePhase.Checking -> "Checking…"678 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"679 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"680 else -> "Peach checks once a day and tells you when there's a new version."681 },682 color = fg.copy(alpha = 0.7f),683 fontFamily = PeachType.Body,684 fontSize = 11.sp,685 lineHeight = 16.sp,686 modifier = Modifier.padding(top = 2.dp),687 )688 }689 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {690 SubtleButton("changelog", fg) {691 changelogOpen = !changelogOpen692 if (changelogOpen) vm.loadChangelog()693 }694 if (available != null) {695 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }696 } else {697 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {698 vm.check()699 }700 }701 }702 }703 if (changelogOpen) {704 when (changelog.phase) {705 ChangelogPhase.Idle -> {}706 ChangelogPhase.Loading -> Text(707 "Loading changelog…",708 color = fg.copy(alpha = 0.68f),709 fontFamily = PeachType.Body,710 fontSize = 11.sp,711 modifier = Modifier.padding(vertical = 8.dp),712 )713 ChangelogPhase.Failed -> RowLine(fg) {714 Name("Couldn't reach the update server", fg)715 SubtleButton("retry", fg) { vm.loadChangelog() }716 }717 ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {718 changelog.entries.forEachIndexed { index, entry ->719 Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {720 Row(verticalAlignment = Alignment.CenterVertically) {721 Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)722 if (compareVersions(entry.version, vm.currentVersion) == 0) {723 Text(" current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)724 }725 }726 Text(727 entry.notes ?: "No notes for this release.",728 color = fg.copy(alpha = 0.72f),729 fontFamily = PeachType.Body,730 fontSize = 11.sp,731 lineHeight = 16.sp,732 modifier = Modifier.padding(top = 3.dp),733 )734 }735 if (index < changelog.entries.lastIndex) {736 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))737 }738 }739 }740 }741 }742}743744@Composable745private fun VaultBody(746 fg: Color,747 vm: SettingsViewModel,748 s: VaultSettings,749 entriesCount: Int,750 onNavigate: (PeachNav) -> Unit,751) {752 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")753 val current = s.trashRetentionDays ?: 0754 val isPro by vm.isPro.collectAsState()755 val dedupCount by vm.dedupCount.collectAsState()756 val dedupRunning by vm.dedupRunning.collectAsState()757 val dedupDone by vm.dedupDone.collectAsState()758 val history by vm.vaultHistory.collectAsState()759 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()760761 SettingsGroup("ENTRIES", fg, top = 0.dp) {762 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }763 RowLine(fg) {764 Name("Duplicates", fg)765 val done = dedupDone766 if (done != null) {767 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)768 } else {769 val count = dedupCount770 val label = when {771 dedupRunning -> "scanning..."772 count == null -> "scan"773 count == 0 -> "none found"774 else -> "$count found"775 }776 SubtleButton(label, fg) {777 if (count == null || count == 0) vm.scanDuplicates()778 }779 }780 }781 if ((dedupCount ?: 0) > 0 && dedupDone == null) {782 Column(783 Modifier.fillMaxWidth()784 .padding(top = 2.dp, bottom = 8.dp)785 .clip(RoundedCornerShape(8.dp))786 .background(fg.copy(alpha = 0.08f))787 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))788 .padding(10.dp),789 verticalArrangement = Arrangement.spacedBy(8.dp),790 ) {791 val count = dedupCount ?: 0792 Text(793 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",794 color = fg.copy(alpha = 0.82f),795 fontFamily = PeachType.Body,796 fontSize = 11.sp,797 lineHeight = 16.sp,798 )799 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {800 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }801 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }802 }803 }804 }805 }806807 SettingsGroup("MAINTENANCE", fg) {808 RowLine(fg) {809 Name("Trash auto-delete", fg)810 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)811 SubtleButton(trashOptions[idx].second, fg) {812 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)813 }814 }815 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }816 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }817 }818819 SettingsGroup("DATA", fg) {820 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }821 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }822 RowLine(fg) {823 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }824 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }825 }826 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }827 }828829 SettingsGroup("UPDATES", fg) {830 UpdatesRows(fg, onNavigate)831 }832833 SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {834 var historyShowAll by remember { mutableStateOf(false) }835 if (history.isEmpty()) {836 Text(837 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",838 color = fg.copy(alpha = 0.68f),839 fontFamily = PeachType.Body,840 fontSize = 11.sp,841 lineHeight = 16.sp,842 modifier = Modifier.padding(bottom = 6.dp),843 )844 } else {845 Text(846 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",847 color = fg.copy(alpha = 0.68f),848 fontFamily = PeachType.Body,849 fontSize = 11.sp,850 lineHeight = 16.sp,851 modifier = Modifier.padding(bottom = 4.dp),852 )853 history.take(if (historyShowAll) history.size else 3).forEach { snap ->854 RowLine(fg) {855 Column(Modifier.weight(1f)) {856 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)857 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)858 }859 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {860 vm.restoreVaultSnapshot(snap.id)861 }862 }863 }864 if (history.size > 3) {865 RowLine(fg) {866 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)867 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }868 }869 }870 Text(871 "${history.size} of 5 snapshots",872 color = fg.copy(alpha = 0.58f),873 fontFamily = PeachType.Mono,874 fontSize = 10.sp,875 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),876 )877 }878 }879880 SettingsGroup("ADVANCED", fg, initiallyOpen = false) {881 RowLine(fg) {882 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)883 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }884 }885 RowLine(fg) {886 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)887 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }888 }889 }890}891892private fun snapshotSubLabel(snapshot: VaultSnapshot): String {893 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }894 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")895}896897private fun snapshotReasonLabel(reason: String): String = when (reason) {898 "local_save" -> "saved"899 "sync_received" -> "synced"900 "import" -> "imported"901 "restore" -> "restored"902 else -> reason903}904905private fun snapshotTimeLabel(savedAt: Long): String {906 val diff = System.currentTimeMillis() - savedAt907 if (diff < 60_000) return "Just now"908 if (diff < 3_600_000) return "${diff / 60_000}m ago"909 val date = Date(savedAt)910 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())911 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)912 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)913 if (today == thenDay) return "Today $time"914 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))915 if (yesterday == thenDay) return "Yesterday $time"916 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)917}918919920921@Composable922private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {923 val context = LocalContext.current924 val syncVm: SyncViewModel = hiltViewModel()925 val pairedDevices by syncVm.pairedDevices.collectAsState()926 val pendingVouched by syncVm.pendingVouched.collectAsState()927 val syncStatus by syncVm.status.collectAsState()928 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()929 val syncMode by syncVm.syncMode.collectAsState()930 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()931 var notificationsAvailable by remember {932 mutableStateOf(canShowBackgroundSyncNotification(context))933 }934 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }935 val scope = rememberCoroutineScope()936 var confirmUnpair by remember { mutableStateOf<String?>(null) }937 var renamingDeviceId by remember { mutableStateOf<String?>(null) }938 var renameDraft by remember { mutableStateOf("") }939 var rotateConfirmOpen by remember { mutableStateOf(false) }940 var rotating by remember { mutableStateOf(false) }941 var rotateError by remember { mutableStateOf<String?>(null) }942 var relayUrlDraft by remember(configuredRelayUrl) {943 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))944 }945 var relayUrlStatus by remember { mutableStateOf<String?>(null) }946 var showConnection by remember { mutableStateOf(false) }947 var showRecovery by remember { mutableStateOf(false) }948949 SubLabel("ADD A DEVICE", fg, top = 0.dp)950 RowLine(fg) {951 Column(Modifier.weight(1f)) {952 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)953 Text(954 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",955 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,956 modifier = Modifier.padding(top = 2.dp),957 )958 }959 960 961 962 963 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }964 Spacer(Modifier.width(PeachSpacing.xs))965 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }966 }967968 SubLabel("SYNC STATUS", fg)969 val indicator = syncIndicator(syncStatus, pairedDevices.size)970 RowLine(fg) {971 SyncIndicatorDot(972 indicator = indicator,973 label = syncIndicatorLabel(indicator, syncStatus),974 modifier = Modifier.padding(end = PeachSpacing.sm),975 size = 8.dp,976 )977 Text(978 describeSyncOutcome(syncStatus),979 color = fg.copy(alpha = 0.7f),980 fontFamily = PeachType.Body,981 fontSize = 11.sp,982 lineHeight = 16.sp,983 modifier = Modifier.weight(1f),984 )985 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {986 if (!syncStatus.syncing) syncVm.syncNow()987 }988 }989990 SubLabel("PAIRED DEVICES", fg)991 if (pairedDevices.isEmpty()) {992 PeachEmptyOrEndState(993 message = "No paired devices yet.",994 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",995 modifier = Modifier.padding(top = PeachSpacing.xs),996 )997 } else {998 pairedDevices.forEach { d ->999 RowLine(fg) {1000 Column(Modifier.weight(1f)) {1001 if (renamingDeviceId == d.deviceId) {1002 Box(1003 Modifier.fillMaxWidth()1004 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1005 .padding(horizontal = 10.dp, vertical = 8.dp),1006 ) {1007 BasicTextField(1008 value = renameDraft,1009 onValueChange = { renameDraft = it.take(64) },1010 singleLine = true,1011 textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1012 cursorBrush = SolidColor(fg),1013 modifier = Modifier.fillMaxWidth(),1014 )1015 }1016 } else {1017 Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1018 }1019 Text(1020 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1021 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1022 modifier = Modifier.padding(top = 2.dp),1023 )1024 }1025 if (confirmUnpair != d.deviceId) {1026 if (renamingDeviceId == d.deviceId) {1027 SubtleButton("cancel", fg) { renamingDeviceId = null }1028 Spacer(Modifier.width(PeachSpacing.xs))1029 SubtleButton("save", fg) {1030 if (renameDraft.isNotBlank()) {1031 syncVm.renameDevice(d.deviceId, renameDraft)1032 renamingDeviceId = null1033 }1034 }1035 } else {1036 SubtleButton("rename", fg) {1037 renameDraft = d.localAlias ?: d.deviceName ?: ""1038 renamingDeviceId = d.deviceId1039 }1040 }1041 if (renamingDeviceId != d.deviceId) {1042 Spacer(Modifier.width(PeachSpacing.xs))1043 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1044 }1045 }1046 }1047 1048 1049 1050 1051 1052 if (confirmUnpair == d.deviceId) {1053 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1054 Text(1055 "Stops this device receiving future changes. It doesn't lock it " +1056 "out of what it already has - if it's lost or stolen, also " +1057 "change your master password and generate new recovery words.",1058 color = fg.copy(alpha = 0.7f),1059 fontFamily = PeachType.Body,1060 fontSize = 11.sp,1061 lineHeight = 16.sp,1062 )1063 Row(1064 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1065 horizontalArrangement = Arrangement.End,1066 ) {1067 SubtleButton("cancel", fg) { confirmUnpair = null }1068 Spacer(Modifier.width(PeachSpacing.sm))1069 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1070 }1071 }1072 }1073 }1074 }10751076 SyncDisclosure(1077 title = "Connection",1078 summary = "Background sync, relay, and privacy choices",1079 expanded = showConnection,1080 fg = fg,1081 onToggle = { showConnection = !showConnection },1082 )1083 if (showConnection) {1084 RowLine(fg) {1085 Column(Modifier.weight(1f)) {1086 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1087 Text(1088 when {1089 backgroundSyncEnabled && !notificationsAvailable ->1090 "Action needed — Android notifications are off, so background sync is not visible."1091 backgroundSyncEnabled ->1092 "On — keeps this phone reachable for paired devices."1093 else ->1094 "Off — Peach syncs only while you are using the app."1095 },1096 color = fg.copy(alpha = 0.7f),1097 fontFamily = PeachType.Body,1098 fontSize = 11.sp,1099 lineHeight = 16.sp,1100 modifier = Modifier.padding(top = 2.dp),1101 )1102 }1103 SubtleButton(1104 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1105 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1106 ) { showBackgroundSyncPrompt = true }1107 }1108 SyncModeRow(1109 title = "Public relay",1110 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1111 selected = syncMode == SyncMode.PUBLIC,1112 fg = fg,1113 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1114 SyncModeRow(1115 title = "Private relay",1116 detail = "Use your own peach-relay on your own domain.",1117 selected = syncMode == SyncMode.PRIVATE,1118 fg = fg,1119 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }11201121 if (syncMode == SyncMode.PRIVATE) {1122 Text(1123 "RELAY URL",1124 color = fg.copy(alpha = 0.6f),1125 fontFamily = PeachType.Mono,1126 fontSize = 8.sp,1127 letterSpacing = 0.22.em,1128 modifier = Modifier.padding(top = 8.dp),1129 )1130 Box(1131 Modifier.fillMaxWidth().padding(top = 6.dp)1132 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1133 .padding(horizontal = 12.dp, vertical = 10.dp),1134 ) {1135 BasicTextField(1136 value = relayUrlDraft,1137 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1138 singleLine = true,1139 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1140 cursorBrush = SolidColor(fg),1141 modifier = Modifier.fillMaxWidth(),1142 )1143 }1144 val needsRelayUrl = configuredRelayUrl.isEmpty()1145 RowLine(fg) {1146 val message = relayUrlStatus1147 ?: if (needsRelayUrl) {1148 "Sync is paused until you enter your relay URL."1149 } else {1150 "Only secure wss:// relays are accepted."1151 }1152 val warn = relayUrlStatus?.startsWith("Use") == true ||1153 (relayUrlStatus == null && needsRelayUrl)1154 ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1155 Spacer(Modifier.width(PeachSpacing.xs))1156 SubtleButton("save", fg) {1157 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1158 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1159 "Saved"1160 } else {1161 "Use a valid wss:// relay URL"1162 }1163 }1164 }1165 }11661167 SyncModeRow(1168 title = "No sync",1169 detail = "Keep this device separate and move backups by hand.",1170 selected = syncMode == SyncMode.OFF,1171 fg = fg,1172 ) { syncVm.setSyncMode(SyncMode.OFF) }1173 Hint("Pairing authorizes a device, not a cloud account.", fg)1174 }11751176 if (pendingVouched.isNotEmpty()) {1177 SubLabel("PENDING DEVICES", fg)1178 pendingVouched.forEach { dev ->1179 RowLine(fg) {1180 Column(Modifier.weight(1f)) {1181 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1182 Text(1183 "Was added by another device. Allow it to sync?",1184 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1185 modifier = Modifier.padding(top = 2.dp),1186 )1187 }1188 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1189 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1190 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1191 }1192 }1193 }1194 }11951196 SyncDisclosure(1197 title = "Recovery & security",1198 summary = "For a lost or stolen device",1199 expanded = showRecovery,1200 fg = fg,1201 onToggle = { showRecovery = !showRecovery },1202 )1203 if (showRecovery) {1204 RowLine(fg) {1205 Column(Modifier.weight(1f)) {1206 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1207 Text(1208 "Scan only when this populated vault previously trusted the source device.",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 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1214 }1215 if (rotateConfirmOpen) {1216 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1217 rotateError?.let {1218 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1219 }1220 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1221 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1222 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1223 if (rotating) return@SubtleButton1224 rotating = true1225 rotateError = null1226 scope.launch {1227 val ok = syncVm.rotateSyncIdentity()1228 rotating = false1229 if (ok) {1230 rotateConfirmOpen = false1231 onNavigate(PeachNav.RecoverySetup)1232 } else {1233 rotateError = "Rotation failed. Unlock your real vault and try again."1234 }1235 }1236 }1237 }1238 } else {1239 RowLine(fg) {1240 Column(Modifier.weight(1f)) {1241 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1242 Text(1243 "Disconnects every device and replaces the keys used for sync.",1244 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1245 modifier = Modifier.padding(top = 2.dp),1246 )1247 }1248 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1249 }1250 }1251 }12521253 if (showBackgroundSyncPrompt) {1254 BackgroundSyncDialog { enabled ->1255 showBackgroundSyncPrompt = false1256 notificationsAvailable = canShowBackgroundSyncNotification(context)1257 syncVm.setBackgroundSyncEnabled(enabled)1258 }1259 }1260}12611262private fun deviceTypeLabel(type: String?): String = when (type) {1263 "android" -> "Android"1264 "phone" -> "Phone"1265 "tablet" -> "Tablet"1266 "browser" -> "Browser"1267 else -> "Device"1268}12691270private fun syncTimeLabel(lastSyncedAt: Long?): String {1271 if (lastSyncedAt == null) return "never synced"1272 val diff = System.currentTimeMillis() - lastSyncedAt1273 if (diff < 60_000) return "just now"1274 if (diff < 3_600_000) return "${diff / 60_000}m ago"1275 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1276 return "${diff / 86_400_000}d ago"1277}12781279private fun isSyncStale(lastSyncedAt: Long?): Boolean =1280 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1281128212831284@Composable1285private fun ProBody(fg: Color, vm: SettingsViewModel) {1286 val ctx = LocalContext.current1287 val isPro by vm.isPro.collectAsState()1288 if (isPro) {1289 ProActiveBody(fg, vm)1290 return1291 }1292 val features = listOf(1293 "Peach Sync" to "End-to-end encrypted",1294 "Peach Codex" to "Restore from paper",1295 "Passkeys" to "Zero-knowledge WebAuthn",1296 "Advanced Vault Health" to "Reuse · age · exposure",1297 "Canary entries" to "Detects vault compromise",1298 "Duress vault" to "Decoy under coercion",1299 )1300 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1301 Text(1302 "Everything, everywhere.",1303 modifier = Modifier.padding(top = 8.dp),1304 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1305 )1306 Text(1307 "One key unlocks every Pro feature — for the lifetime of the app.",1308 modifier = Modifier.padding(top = 6.dp),1309 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1310 )1311 Spacer(Modifier.height(10.dp))1312 features.forEachIndexed { i, (name, desc) ->1313 1314 1315 1316 1317 RowLine(fg, verticalPadding = 6.dp) {1318 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1319 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1320 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1321 }1322 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1323 }1324 }1325 Spacer(Modifier.height(12.dp))1326 Text(1327 "$39",1328 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1329 )1330 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1331 Spacer(Modifier.height(10.dp))1332 Box(1333 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1334 .background(fg.copy(alpha = 0.16f))1335 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1336 .clickable {1337 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1338 }1339 .padding(vertical = 12.dp),1340 contentAlignment = Alignment.Center,1341 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }13421343 1344 1345 1346 1347 Spacer(Modifier.height(44.dp))13481349 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1350 var key by remember { mutableStateOf("") }1351 val activating by vm.activating.collectAsState()1352 val activationError by vm.activationError.collectAsState()1353 Box(1354 Modifier.fillMaxWidth().padding(top = 6.dp)1355 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1356 .padding(horizontal = 12.dp, vertical = 10.dp),1357 ) {1358 BasicTextField(1359 value = key,1360 1361 1362 1363 1364 onValueChange = { key = it; vm.clearActivationError() },1365 singleLine = true,1366 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1367 cursorBrush = SolidColor(fg),1368 modifier = Modifier.fillMaxWidth(),1369 decorationBox = { inner ->1370 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1371 inner()1372 },1373 )1374 }1375 if (activationError != null) {1376 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1377 }1378 Spacer(Modifier.height(8.dp))1379 Box(1380 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1381 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1382 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1383 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1384 .padding(vertical = 12.dp),1385 contentAlignment = Alignment.Center,1386 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1387}13881389@Composable1390private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1391 val ctx = LocalContext.current1392 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1393 val licenseKey by vm.licenseKey.collectAsState()1394 val issuedAt by vm.licenseIssuedAt.collectAsState()1395 var showKey by remember { mutableStateOf(false) }1396 var copied by remember { mutableStateOf(false) }1397 val scope = rememberCoroutineScope()1398 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1399 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1400 }1401 Text(1402 "Peach Pro",1403 modifier = Modifier.fillMaxWidth(),1404 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1405 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1406 )1407 Text(1408 formatLicenseActiveSince(issuedAt),1409 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1410 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1411 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1412 )1413 licenseKey?.let { key ->1414 Spacer(Modifier.height(12.dp))1415 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1416 RowLine(fg) {1417 Text(1418 if (showKey) key else "************************",1419 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1420 fontFamily = PeachType.Mono,1421 fontSize = 10.sp,1422 modifier = Modifier.weight(1f),1423 )1424 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1425 SubtleButton(if (copied) "copied" else "copy", fg) {1426 PeachClipboard.copy(ctx, "peach license", key)1427 copied = true1428 scope.launch {1429 delay(1_500)1430 copied = false1431 }1432 }1433 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1434 }1435 }1436 }1437 Spacer(Modifier.height(12.dp))1438 features.forEachIndexed { i, name ->1439 RowLine(fg) {1440 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1441 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1442 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1443 }1444 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1445 }1446 }1447 Spacer(Modifier.height(12.dp))1448 Box(1449 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1450 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1451 .clickable { vm.deactivate() }1452 .padding(vertical = 12.dp),1453 contentAlignment = Alignment.Center,1454 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1455}14561457private fun formatLicenseActiveSince(issuedAt: Long?): String {1458 if (issuedAt == null) return "Active"1459 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1460 return "Active since $monthYear"1461}1462146314641465@OptIn(ExperimentalLayoutApi::class)1466@Composable1467private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1468 val categories = listOf("Bug report", "Feature request", "Question", "Other")1469 var category by remember { mutableStateOf(categories[0]) }1470 var message by remember { mutableStateOf("") }1471 val state by vm.feedbackState.collectAsState()14721473 1474 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }14751476 if (state is FeedbackState.Sent) {1477 Box(1478 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1479 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1480 ) {1481 Column {1482 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1483 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))1484 }1485 }1486 return1487 }14881489 val sending = state is FeedbackState.Sending14901491 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)1492 SubLabel("TYPE", fg)1493 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1494 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1495 }1496 SubLabel("MESSAGE", fg)1497 Box(1498 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1499 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1500 .padding(10.dp),1501 ) {1502 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1503 BasicTextField(1504 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1505 enabled = !sending,1506 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1507 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1508 )1509 }1510 Text(1511 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1512 modifier = Modifier.padding(top = 6.dp),1513 color = fg.copy(alpha = 0.52f),1514 fontFamily = PeachType.Mono,1515 fontSize = 10.sp,1516 lineHeight = 14.sp,1517 )1518 Spacer(Modifier.height(8.dp))15191520 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1521 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1522 Box(1523 Modifier.clip(RoundedCornerShape(8.dp))1524 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1525 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1526 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1527 .padding(horizontal = 12.dp, vertical = 6.dp),1528 ) {1529 Text(1530 feedbackSubmitLabel(state),1531 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1532 fontFamily = PeachType.Mono, fontSize = 11.sp,1533 )1534 }1535 if (sending) {1536 CircularProgressIndicator(1537 color = LocalPeachAccent.current,1538 strokeWidth = 2.dp,1539 modifier = Modifier.size(16.dp),1540 )1541 }1542 }15431544 (state as? FeedbackState.Error)?.let { err ->1545 Text(1546 err.message,1547 modifier = Modifier.padding(top = 8.dp),1548 color = peachColors.Breach,1549 fontFamily = PeachType.Mono,1550 fontSize = 11.sp,1551 lineHeight = 15.sp,1552 )1553 }15541555 Hint("Sent anonymously · no account required", fg)1556}1557155815591560@Composable1561internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1562 val configured by vm.duressConfigured.collectAsState()1563 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1564}15651566@Composable1567private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1568 Text(1569 "Duress password is active",1570 color = peachColors.HealthOk,1571 fontFamily = PeachType.Mono,1572 fontSize = 11.sp,1573 modifier = Modifier.padding(top = 6.dp),1574 )1575 Spacer(Modifier.height(8.dp))1576 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1577 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1578}15791580@Composable1581private fun DuressForm(fg: Color, vm: SettingsViewModel) {1582 var enabled by remember { mutableStateOf(false) }1583 var password by remember { mutableStateOf("") }1584 var confirm by remember { mutableStateOf("") }1585 var action by remember { mutableStateOf(DuressAction.DECOY) }1586 var error by remember { mutableStateOf<String?>(null) }1587 val scope = rememberCoroutineScope()15881589 RowLine(fg) {1590 Name("Duress password", fg)1591 Toggle(enabled, fg) { on ->1592 enabled = on1593 if (!on) { password = ""; confirm = ""; error = null }1594 }1595 }1596 Text(1597 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1598 color = fg.copy(alpha = 0.7f),1599 fontFamily = PeachType.Mono,1600 fontSize = 10.sp,1601 modifier = Modifier.padding(top = 6.dp),1602 )16031604 AnimatedVisibility(enabled) {1605 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1606 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1607 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }16081609 Text(1610 "When your duress password is entered at the lock screen, Peach will:",1611 color = fg.copy(alpha = 0.7f),1612 fontFamily = PeachType.Mono,1613 fontSize = 10.sp,1614 modifier = Modifier.padding(top = 4.dp),1615 )16161617 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1618 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }16191620 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }16211622 val canSave = password.isNotBlank() && confirm.isNotBlank()1623 DuressSaveButton("Save duress password", fg, canSave) {1624 error = when {1625 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1626 password != confirm -> "Passwords do not match"1627 else -> null1628 }1629 if (error == null) scope.launch {1630 if (vm.saveDuress(password, action)) {1631 password = ""; confirm = ""; enabled = false1632 } else {1633 error = "Couldn't save — the duress passphrase must differ from your real password"1634 }1635 }1636 }1637 }1638 }1639}16401641@Composable1642private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1643 val accent = LocalPeachAccent.current1644 Row(1645 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1646 verticalAlignment = Alignment.CenterVertically,1647 horizontalArrangement = Arrangement.spacedBy(10.dp),1648 ) {1649 Box(1650 Modifier.size(16.dp).clip(CircleShape)1651 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1652 contentAlignment = Alignment.Center,1653 ) {1654 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1655 }1656 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1657 }1658}16591660@Composable1661private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1662 val accent = LocalPeachAccent.current1663 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1664 Box(1665 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1666 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1667 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1668 .let { if (enabled) it.clickable(onClick = onClick) else it }1669 .padding(vertical = 11.dp),1670 contentAlignment = Alignment.Center,1671 ) {1672 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1673 }1674}16751676@Composable1677private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1678 Box(1679 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1680 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1681 .clickable(onClick = onClick)1682 .padding(vertical = 11.dp),1683 contentAlignment = Alignment.Center,1684 ) {1685 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1686 }1687}168816891690@Composable1691private fun SyncModeRow(1692 title: String,1693 detail: String,1694 selected: Boolean,1695 fg: Color,1696 onSelect: () -> Unit,1697) {1698 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1699 Column(Modifier.weight(1f)) {1700 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1701 Text(1702 detail,1703 color = fg.copy(alpha = 0.7f),1704 fontFamily = PeachType.Body,1705 fontSize = 11.sp,1706 lineHeight = 16.sp,1707 modifier = Modifier.padding(top = 2.dp),1708 )1709 }1710 Text(1711 if (selected) "●" else "○",1712 color = if (selected) fg else fg.copy(alpha = 0.45f),1713 fontFamily = PeachType.Mono,1714 fontSize = 14.sp,1715 modifier = Modifier.padding(start = 10.dp),1716 )1717 }1718}