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("ICONS", fg)335 RowLine(fg) {336 Name("Site icons", fg)337 SubtleButton(if (s.faviconsEnabled) "On" else "Off", fg) { vm.setFaviconsEnabled(!s.faviconsEnabled) }338 }339 SubLabel("MOTION", fg)340 RowLine(fg) {341 Name("Reduce motion", fg)342 SubtleButton(if (s.reduceMotion) "On" else "Off", fg) { vm.setReduceMotion(!s.reduceMotion) }343 }344}345346347348@Composable349private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {350 val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")351 val current = s.autoLockMinutes ?: 0352 val pinEnabled by vm.pinEnabled.collectAsState()353 var pinOpen by remember { mutableStateOf(false) }354 var pinRemovalOpen by remember { mutableStateOf(false) }355 var pinMaster by remember { mutableStateOf("") }356 var pin by remember { mutableStateOf("") }357 var pinConfirm by remember { mutableStateOf("") }358 var pinRemovalMaster by remember { mutableStateOf("") }359 var pinError by remember { mutableStateOf<String?>(null) }360 val pinScope = rememberCoroutineScope()361362 SubLabel("LOCKING", fg, top = 0.dp)363 RowLine(fg) {364 Name("Allow screenshots", fg)365 Toggle(s.allowScreenshots, fg) { vm.setAllowScreenshots(it) }366 }367 RowLine(fg) {368 Name("Auto-lock after", fg)369 val idx = lockOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)370 SubtleButton(lockOptions[idx].second, fg) {371 val next = lockOptions[(idx + 1) % lockOptions.size].first372 vm.setAutoLockMinutes(if (next == 0) 0 else next)373 }374 }375 SubLabel("PIN UNLOCK", fg)376 RowLine(fg) {377 Name("Unlock with PIN", fg)378 Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {379 ValueText(if (pinEnabled) "enabled" else "off", fg)380 SubtleButton(if (pinEnabled) "change" else "set", fg) {381 pin = ""382 pinConfirm = ""383 pinMaster = ""384 pinError = null385 pinOpen = !pinOpen386 }387 }388 }389 if (pinEnabled) {390 RowLine(fg) {391 Name("Remove PIN unlock", fg)392 SubtleButton("turn off", fg) {393 pinRemovalMaster = ""394 pinError = null395 pinRemovalOpen = !pinRemovalOpen396 }397 }398 }399 AnimatedVisibility(pinRemovalOpen) {400 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {401 PeachField(402 label = "master password",403 value = pinRemovalMaster,404 onValueChange = { pinRemovalMaster = it; pinError = null },405 keyboardType = KeyboardType.Password,406 visualTransformation = PasswordVisualTransformation(),407 )408 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }409 SubtleButton("remove PIN", fg) {410 pinScope.launch {411 val error = vm.disablePin(pinRemovalMaster)412 if (error == null) {413 pinRemovalMaster = ""414 pinRemovalOpen = false415 } else {416 pinError = error417 }418 }419 }420 }421 }422 AnimatedVisibility(pinOpen) {423 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {424 PeachField(425 label = "master password",426 value = pinMaster,427 onValueChange = { pinMaster = it; pinError = null },428 keyboardType = KeyboardType.Password,429 visualTransformation = PasswordVisualTransformation(),430 )431 PeachField(432 label = "new PIN (4–12 digits)",433 value = pin,434 onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },435 keyboardType = KeyboardType.NumberPassword,436 visualTransformation = PasswordVisualTransformation(),437 )438 PeachField(439 label = "confirm PIN",440 value = pinConfirm,441 onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },442 keyboardType = KeyboardType.NumberPassword,443 visualTransformation = PasswordVisualTransformation(),444 )445 pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }446 SubtleButton("save PIN", fg) {447 pinError = if (pin != pinConfirm) "PINs do not match" else null448 if (pinError == null) pinScope.launch {449 val error = vm.savePin(pinMaster, pin)450 if (error == null) {451 pinMaster = ""452 pin = ""453 pinConfirm = ""454 pinOpen = false455 } else {456 pinError = error457 }458 }459 }460 }461 }462 SubLabel("BIOMETRICS", fg)463 val bioActivity = LocalContext.current as? androidx.fragment.app.FragmentActivity464 RowLine(fg) {465 Name("Unlock with biometrics", fg)466 Toggle(s.biometricsEnabled, fg) { on ->467 if (on && bioActivity != null) vm.enableBiometrics(bioActivity) else vm.disableBiometrics()468 }469 }470 val bioError = com.peachpasswords.ui.lock.Biometrics.statusMessage(LocalContext.current)471 if (bioError != null) {472 Text(473 bioError,474 color = com.peachpasswords.ui.theme.peachColors.Breach,475 fontFamily = PeachType.Mono, fontSize = 10.sp,476 modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 4.dp),477 )478 }479 SubLabel("AUTOFILL", fg)480 run {481 482 483 val ctx = LocalContext.current484 val enabled = AutofillEnablement.isEnabled(ctx)485 RowLine(fg) {486 Column(Modifier.weight(1f)) {487 Text("Fill passwords in other apps", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)488 Text(489 if (enabled) {490 "Peach is your autofill service."491 } else {492 "Android decides which app fills passwords. Turn Peach on in system settings."493 },494 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,495 modifier = Modifier.padding(top = 2.dp),496 )497 }498 if (!enabled) {499 SubtleButton("enable", fg) {500 501 502 503 runCatching { ctx.startActivity(AutofillEnablement.requestIntent(ctx)) }504 .onFailure { runCatching { ctx.startActivity(AutofillEnablement.fallbackIntent()) } }505 }506 } else {507 ValueText("on", fg)508 }509 }510 }511512 RowLine(fg) {513 Column(Modifier.weight(1f)) {514 Text("Hide autofill previews", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)515 Text(516 "Keyboard chips and suggestion rows show only Peach until tapped — no entry names or usernames over your shoulder.",517 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,518 modifier = Modifier.padding(top = 2.dp),519 )520 }521 Toggle(s.hideAutofillPreviews, fg) { vm.setHideAutofillPreviews(it) }522 }523524 SubLabel("PASSKEYS", fg)525 RowLine(fg) {526 Column(Modifier.weight(1f)) {527 Text("Credential provider", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)528 Text(529 "Peach can handle passkey sign-in and creation through Android Credential Manager.",530 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,531 modifier = Modifier.padding(top = 2.dp),532 )533 }534 Toggle(s.passkeyProviderEnabled, fg) { vm.setPasskeyProviderEnabled(it) }535 }536 RowLine(fg) {537 Name("Status", fg)538 ValueText(if (s.passkeyProviderEnabled) "enabled" else "off", fg)539 }540 SubLabel("RECOVERY", fg)541 RowLine(fg) {542 Column(Modifier.weight(1f)) {543 Text("View recovery words", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)544 Text(545 "Keep the paper copy current before rotating sync identity or replacing devices.",546 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,547 modifier = Modifier.padding(top = 2.dp),548 )549 }550 SubtleButton("view", fg) {551 if (bioActivity != null) {552 com.peachpasswords.ui.lock.Biometrics.authenticate(553 activity = bioActivity,554 title = "View recovery words",555 subtitle = "Confirm your identity to view your recovery words",556 onSuccess = { onNavigate(PeachNav.RecoveryView) },557 )558 } else onNavigate(PeachNav.RecoveryView)559 }560 }561 SubLabel("BREACH PROTECTION", fg)562 val checkingBreaches by vm.checkingBreaches.collectAsState()563 val breachResult by vm.breachResult.collectAsState()564 RowLine(fg) {565 Name("Breach monitoring", fg)566 Toggle(s.breachMonitoring, fg) { vm.setBreachMonitoring(it) }567 }568 RowLine(fg) {569 Name("Check all passwords", fg)570 SubtleButton(if (checkingBreaches) "checking..." else "run", fg) { vm.checkAllBreaches() }571 }572 breachResult?.let { result ->573 RowLine(fg) {574 Name("Last check", fg)575 ValueText(result, fg)576 }577 }578579 SubLabel("MASTER PASSWORD", fg)580 ChangePasswordRow(fg, vm)581582 SubLabel("DURESS PASSWORD", fg)583 val duressIsPro by vm.isPro.collectAsState()584 if (!duressIsPro) {585 RowLine(fg) {586 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {587 Name("Duress password", fg); ProBadge(fg)588 }589 ValueText("off", fg)590 }591 Hint("If entered under coercion, a decoy empty vault opens; your real vault stays hidden. Pro feature.", fg)592 } else {593 DuressSection(fg, vm)594 }595}596597@Composable598private fun ChangePasswordRow(fg: Color, vm: SettingsViewModel) {599 var open by remember { mutableStateOf(false) }600 var current by remember { mutableStateOf("") }601 var next by remember { mutableStateOf("") }602 var confirm by remember { mutableStateOf("") }603 var error by remember { mutableStateOf<String?>(null) }604 var done by remember { mutableStateOf(false) }605 val scope = rememberCoroutineScope()606607 RowLine(fg) {608 Name("Master password", fg)609 SubtleButton(if (open) "cancel" else "change", fg) { open = !open; error = null; done = false }610 }611 AnimatedVisibility(open) {612 Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {613 PwField("current password", current, fg) { current = it }614 PwField("new password (min 12 chars)", next, fg) { next = it }615 PwField("confirm new password", confirm, fg) { confirm = it }616 error?.let { Text(it, color = Color(0xFFE5805C), fontFamily = PeachType.Mono, fontSize = 10.sp) }617 if (done) Text("password changed ✓", color = fg, fontFamily = PeachType.Mono, fontSize = 10.sp)618 SubtleButton("save new password", fg) {619 error = when {620 current.isBlank() -> "enter your current password"621 next.length < 12 -> "new password must be at least 12 characters"622 next != confirm -> "new passwords do not match"623 next == current -> "new password must differ from current"624 else -> null625 }626 if (error == null) scope.launch {627 if (vm.changePassword(current, next)) {628 current = ""; next = ""; confirm = ""; open = false; done = true629 } else error = "incorrect current password"630 }631 }632 }633 }634}635636@Composable637private fun PwField(placeholder: String, value: String, @Suppress("UNUSED_PARAMETER") fg: Color, onChange: (String) -> Unit) {638 PeachField(639 label = placeholder,640 value = value,641 onValueChange = onChange,642 keyboardType = androidx.compose.ui.text.input.KeyboardType.Password,643 visualTransformation = PasswordVisualTransformation(),644 )645}646647648649@Composable650private fun UpdatesRows(fg: Color, onNavigate: (PeachNav) -> Unit) {651 val vm: UpdateViewModel = hiltViewModel()652 val available by vm.available.collectAsState()653 val state by vm.state.collectAsState()654 val changelog by vm.changelog.collectAsState()655 var changelogOpen by remember { mutableStateOf(false) }656657 RowLine(fg) {658 Name("Version", fg)659 ValueText(BuildConfig.VERSION_NAME, fg)660 }661 RowLine(fg) {662 Column(Modifier.weight(1f)) {663 Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)664 Text(665 when {666 available != null -> "v${available?.version} is available"667 state.phase == UpdatePhase.Checking -> "Checking…"668 state.phase == UpdatePhase.UpToDate -> "You're on the latest version"669 state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"670 else -> "Peach checks once a day and tells you when there's a new version."671 },672 color = fg.copy(alpha = 0.7f),673 fontFamily = PeachType.Body,674 fontSize = 11.sp,675 lineHeight = 16.sp,676 modifier = Modifier.padding(top = 2.dp),677 )678 }679 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {680 SubtleButton("changelog", fg) {681 changelogOpen = !changelogOpen682 if (changelogOpen) vm.loadChangelog()683 }684 if (available != null) {685 SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }686 } else {687 SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {688 vm.check()689 }690 }691 }692 }693 if (changelogOpen) {694 when (changelog.phase) {695 ChangelogPhase.Idle -> {}696 ChangelogPhase.Loading -> Text(697 "Loading changelog…",698 color = fg.copy(alpha = 0.68f),699 fontFamily = PeachType.Body,700 fontSize = 11.sp,701 modifier = Modifier.padding(vertical = 8.dp),702 )703 ChangelogPhase.Failed -> RowLine(fg) {704 Name("Couldn't reach the update server", fg)705 SubtleButton("retry", fg) { vm.loadChangelog() }706 }707 ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {708 changelog.entries.forEachIndexed { index, entry ->709 Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {710 Row(verticalAlignment = Alignment.CenterVertically) {711 Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)712 if (compareVersions(entry.version, vm.currentVersion) == 0) {713 Text(" current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)714 }715 }716 Text(717 entry.notes ?: "No notes for this release.",718 color = fg.copy(alpha = 0.72f),719 fontFamily = PeachType.Body,720 fontSize = 11.sp,721 lineHeight = 16.sp,722 modifier = Modifier.padding(top = 3.dp),723 )724 }725 if (index < changelog.entries.lastIndex) {726 Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))727 }728 }729 }730 }731 }732}733734@Composable735private fun VaultBody(736 fg: Color,737 vm: SettingsViewModel,738 s: VaultSettings,739 entriesCount: Int,740 onNavigate: (PeachNav) -> Unit,741) {742 val trashOptions = listOf(7 to "7 days", 30 to "30 days", 90 to "90 days", 0 to "Never")743 val current = s.trashRetentionDays ?: 0744 val isPro by vm.isPro.collectAsState()745 val dedupCount by vm.dedupCount.collectAsState()746 val dedupRunning by vm.dedupRunning.collectAsState()747 val dedupDone by vm.dedupDone.collectAsState()748 val history by vm.vaultHistory.collectAsState()749 val restoringSnapshotId by vm.restoringSnapshotId.collectAsState()750751 SettingsGroup("ENTRIES", fg, top = 0.dp) {752 RowLine(fg) { Name("Entries", fg); ValueText("$entriesCount", fg) }753 RowLine(fg) {754 Name("Duplicates", fg)755 val done = dedupDone756 if (done != null) {757 ValueText(if (done == 0) "already clean" else "removed $done", com.peachpasswords.ui.theme.peachColors.SyncOk)758 } else {759 val count = dedupCount760 val label = when {761 dedupRunning -> "scanning..."762 count == null -> "scan"763 count == 0 -> "none found"764 else -> "$count found"765 }766 SubtleButton(label, fg) {767 if (count == null || count == 0) vm.scanDuplicates()768 }769 }770 }771 if ((dedupCount ?: 0) > 0 && dedupDone == null) {772 Column(773 Modifier.fillMaxWidth()774 .padding(top = 2.dp, bottom = 8.dp)775 .clip(RoundedCornerShape(8.dp))776 .background(fg.copy(alpha = 0.08f))777 .border(1.dp, fg.copy(alpha = 0.18f), RoundedCornerShape(8.dp))778 .padding(10.dp),779 verticalArrangement = Arrangement.spacedBy(8.dp),780 ) {781 val count = dedupCount ?: 0782 Text(783 "$count duplicate ${if (count == 1) "entry" else "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.",784 color = fg.copy(alpha = 0.82f),785 fontFamily = PeachType.Body,786 fontSize = 11.sp,787 lineHeight = 16.sp,788 )789 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {790 SubtleButton("cancel", fg) { vm.cancelDuplicateRemoval() }791 SubtleButton(if (dedupRunning) "removing..." else "remove duplicates", fg) { vm.removeDuplicates() }792 }793 }794 }795 }796797 SettingsGroup("MAINTENANCE", fg) {798 RowLine(fg) {799 Name("Trash auto-delete", fg)800 val idx = trashOptions.indexOfFirst { it.first == current }.coerceAtLeast(0)801 SubtleButton(trashOptions[idx].second, fg) {802 vm.setTrashRetentionDays(trashOptions[(idx + 1) % trashOptions.size].first)803 }804 }805 RowLine(fg) { Name("Advanced Vault Health", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Health) } }806 RowLine(fg) { Name("Trash", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Trash) } }807 }808809 SettingsGroup("DATA", fg) {810 RowLine(fg) { Name("Import passwords", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Import) } }811 RowLine(fg) { Name("Export backup", fg); SubtleButton("open", fg) { onNavigate(PeachNav.Export) } }812 RowLine(fg) {813 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Name("Peach Codex", fg); if (!isPro) ProBadge(fg) }814 SubtleButton(if (isPro) "open" else "Pro", fg) { if (isPro) onNavigate(PeachNav.CodexGenerate) }815 }816 RowLine(fg) { Name("Restore from Codex", fg); SubtleButton("open", fg) { onNavigate(PeachNav.CodexRestore) } }817 }818819 SettingsGroup("UPDATES", fg) {820 UpdatesRows(fg, onNavigate)821 }822823 SettingsGroup("VAULT HISTORY", fg, initiallyOpen = false) {824 var historyShowAll by remember { mutableStateOf(false) }825 if (history.isEmpty()) {826 Text(827 "No snapshots yet. Peach saves automatically before every change — saves, syncs, and imports.",828 color = fg.copy(alpha = 0.68f),829 fontFamily = PeachType.Body,830 fontSize = 11.sp,831 lineHeight = 16.sp,832 modifier = Modifier.padding(bottom = 6.dp),833 )834 } else {835 Text(836 "Restoring locks your vault. Unlock with the password that encrypted that snapshot to confirm.",837 color = fg.copy(alpha = 0.68f),838 fontFamily = PeachType.Body,839 fontSize = 11.sp,840 lineHeight = 16.sp,841 modifier = Modifier.padding(bottom = 4.dp),842 )843 history.take(if (historyShowAll) history.size else 3).forEach { snap ->844 RowLine(fg) {845 Column(Modifier.weight(1f)) {846 Text(snapshotTimeLabel(snap.savedAt), color = fg, fontFamily = PeachType.Body, fontSize = 12.sp)847 Text(snapshotSubLabel(snap), color = fg.copy(alpha = 0.62f), fontFamily = PeachType.Mono, fontSize = 10.sp)848 }849 SubtleButton(if (restoringSnapshotId == snap.id) "..." else "restore", fg) {850 vm.restoreVaultSnapshot(snap.id)851 }852 }853 }854 if (history.size > 3) {855 RowLine(fg) {856 Name(if (historyShowAll) "show less" else "${history.size - 3} older", fg)857 SubtleButton(if (historyShowAll) "hide" else "show", fg) { historyShowAll = !historyShowAll }858 }859 }860 Text(861 "${history.size} of 5 snapshots",862 color = fg.copy(alpha = 0.58f),863 fontFamily = PeachType.Mono,864 fontSize = 10.sp,865 modifier = Modifier.padding(top = 2.dp, bottom = 6.dp),866 )867 }868 }869870 SettingsGroup("ADVANCED", fg, initiallyOpen = false) {871 RowLine(fg) {872 Name("Replace vault from backup", com.peachpasswords.ui.theme.peachColors.SyncBusy)873 SubtleButton("open", com.peachpasswords.ui.theme.peachColors.SyncBusy) { onNavigate(PeachNav.Import) }874 }875 RowLine(fg) {876 Name("Destroy vault", com.peachpasswords.ui.theme.peachColors.Breach)877 SubtleButton("destroy", com.peachpasswords.ui.theme.peachColors.Breach) { onNavigate(PeachNav.Panic) }878 }879 }880}881882private fun snapshotSubLabel(snapshot: VaultSnapshot): String {883 val count = snapshot.entryCount?.let { "up to $it ${if (it == 1) "entry" else "entries"}" }884 return listOfNotNull(count, snapshotReasonLabel(snapshot.reason)).joinToString(" · ")885}886887private fun snapshotReasonLabel(reason: String): String = when (reason) {888 "local_save" -> "saved"889 "sync_received" -> "synced"890 "import" -> "imported"891 "restore" -> "restored"892 else -> reason893}894895private fun snapshotTimeLabel(savedAt: Long): String {896 val diff = System.currentTimeMillis() - savedAt897 if (diff < 60_000) return "Just now"898 if (diff < 3_600_000) return "${diff / 60_000}m ago"899 val date = Date(savedAt)900 val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())901 val thenDay = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(date)902 val time = SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)903 if (today == thenDay) return "Today $time"904 val yesterday = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date(System.currentTimeMillis() - 86_400_000))905 if (yesterday == thenDay) return "Yesterday $time"906 return SimpleDateFormat("MMM d h:mm a", Locale.getDefault()).format(date)907}908909910911@Composable912private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {913 val context = LocalContext.current914 val syncVm: SyncViewModel = hiltViewModel()915 val pairedDevices by syncVm.pairedDevices.collectAsState()916 val pendingVouched by syncVm.pendingVouched.collectAsState()917 val syncStatus by syncVm.status.collectAsState()918 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()919 val syncMode by syncVm.syncMode.collectAsState()920 val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()921 var notificationsAvailable by remember {922 mutableStateOf(canShowBackgroundSyncNotification(context))923 }924 var showBackgroundSyncPrompt by remember { mutableStateOf(false) }925 val scope = rememberCoroutineScope()926 var confirmUnpair by remember { mutableStateOf<String?>(null) }927 var renamingDeviceId by remember { mutableStateOf<String?>(null) }928 var renameDraft by remember { mutableStateOf("") }929 var rotateConfirmOpen by remember { mutableStateOf(false) }930 var rotating by remember { mutableStateOf(false) }931 var rotateError by remember { mutableStateOf<String?>(null) }932 var relayUrlDraft by remember(configuredRelayUrl) {933 mutableStateOf(privateRelayDraftOf(configuredRelayUrl))934 }935 var relayUrlStatus by remember { mutableStateOf<String?>(null) }936 var showConnection by remember { mutableStateOf(false) }937 var showRecovery by remember { mutableStateOf(false) }938939 SubLabel("ADD A DEVICE", fg, top = 0.dp)940 RowLine(fg) {941 Column(Modifier.weight(1f)) {942 Text("Pair a device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)943 Text(944 "Either scan a QR shown on the other device, or show this device's QR for the other one to scan.",945 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,946 modifier = Modifier.padding(top = 2.dp),947 )948 }949 950 951 952 953 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }954 Spacer(Modifier.width(PeachSpacing.xs))955 SubtleButton("show code", fg) { onNavigate(PeachNav.CodePair) }956 }957958 SubLabel("SYNC STATUS", fg)959 val indicator = syncIndicator(syncStatus, pairedDevices.size)960 RowLine(fg) {961 SyncIndicatorDot(962 indicator = indicator,963 label = syncIndicatorLabel(indicator, syncStatus),964 modifier = Modifier.padding(end = PeachSpacing.sm),965 size = 8.dp,966 )967 Text(968 describeSyncOutcome(syncStatus),969 color = fg.copy(alpha = 0.7f),970 fontFamily = PeachType.Body,971 fontSize = 11.sp,972 lineHeight = 16.sp,973 modifier = Modifier.weight(1f),974 )975 SubtleButton(if (syncStatus.syncing) "syncing..." else "sync now", fg) {976 if (!syncStatus.syncing) syncVm.syncNow()977 }978 }979980 SubLabel("PAIRED DEVICES", fg)981 if (pairedDevices.isEmpty()) {982 PeachEmptyOrEndState(983 message = "No paired devices yet.",984 detail = "Pair a nearby phone, tablet, or browser when you're ready to sync.",985 modifier = Modifier.padding(top = PeachSpacing.xs),986 )987 } else {988 pairedDevices.forEach { d ->989 RowLine(fg) {990 Column(Modifier.weight(1f)) {991 if (renamingDeviceId == d.deviceId) {992 Box(993 Modifier.fillMaxWidth()994 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))995 .padding(horizontal = 10.dp, vertical = 8.dp),996 ) {997 BasicTextField(998 value = renameDraft,999 onValueChange = { renameDraft = it.take(64) },1000 singleLine = true,1001 textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),1002 cursorBrush = SolidColor(fg),1003 modifier = Modifier.fillMaxWidth(),1004 )1005 }1006 } else {1007 Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1008 }1009 Text(1010 "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",1011 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1012 modifier = Modifier.padding(top = 2.dp),1013 )1014 }1015 if (confirmUnpair != d.deviceId) {1016 if (renamingDeviceId == d.deviceId) {1017 SubtleButton("cancel", fg) { renamingDeviceId = null }1018 Spacer(Modifier.width(PeachSpacing.xs))1019 SubtleButton("save", fg) {1020 if (renameDraft.isNotBlank()) {1021 syncVm.renameDevice(d.deviceId, renameDraft)1022 renamingDeviceId = null1023 }1024 }1025 } else {1026 SubtleButton("rename", fg) {1027 renameDraft = d.localAlias ?: d.deviceName ?: ""1028 renamingDeviceId = d.deviceId1029 }1030 }1031 if (renamingDeviceId != d.deviceId) {1032 Spacer(Modifier.width(PeachSpacing.xs))1033 SubtleButton("remove", fg) { confirmUnpair = d.deviceId }1034 }1035 }1036 }1037 1038 1039 1040 1041 1042 if (confirmUnpair == d.deviceId) {1043 Column(Modifier.fillMaxWidth().padding(bottom = PeachSpacing.sm)) {1044 Text(1045 "Stops this device receiving future changes. It doesn't lock it " +1046 "out of what it already has - if it's lost or stolen, also " +1047 "change your master password and generate new recovery words.",1048 color = fg.copy(alpha = 0.7f),1049 fontFamily = PeachType.Body,1050 fontSize = 11.sp,1051 lineHeight = 16.sp,1052 )1053 Row(1054 modifier = Modifier.fillMaxWidth().padding(top = PeachSpacing.sm),1055 horizontalArrangement = Arrangement.End,1056 ) {1057 SubtleButton("cancel", fg) { confirmUnpair = null }1058 Spacer(Modifier.width(PeachSpacing.sm))1059 SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }1060 }1061 }1062 }1063 }1064 }10651066 SyncDisclosure(1067 title = "Connection",1068 summary = "Background sync, relay, and privacy choices",1069 expanded = showConnection,1070 fg = fg,1071 onToggle = { showConnection = !showConnection },1072 )1073 if (showConnection) {1074 RowLine(fg) {1075 Column(Modifier.weight(1f)) {1076 Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1077 Text(1078 when {1079 backgroundSyncEnabled && !notificationsAvailable ->1080 "Action needed — Android notifications are off, so background sync is not visible."1081 backgroundSyncEnabled ->1082 "On — keeps this phone reachable for paired devices."1083 else ->1084 "Off — Peach syncs only while you are using the app."1085 },1086 color = fg.copy(alpha = 0.7f),1087 fontFamily = PeachType.Body,1088 fontSize = 11.sp,1089 lineHeight = 16.sp,1090 modifier = Modifier.padding(top = 2.dp),1091 )1092 }1093 SubtleButton(1094 if (backgroundSyncEnabled && !notificationsAvailable) "allow" else "change",1095 if (backgroundSyncEnabled && !notificationsAvailable) peachColors.Breach else fg,1096 ) { showBackgroundSyncPrompt = true }1097 }1098 SyncModeRow(1099 title = "Public relay",1100 detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",1101 selected = syncMode == SyncMode.PUBLIC,1102 fg = fg,1103 ) { syncVm.setSyncMode(SyncMode.PUBLIC) }1104 SyncModeRow(1105 title = "Private relay",1106 detail = "Use your own peach-relay on your own domain.",1107 selected = syncMode == SyncMode.PRIVATE,1108 fg = fg,1109 ) { syncVm.setSyncMode(SyncMode.PRIVATE) }11101111 if (syncMode == SyncMode.PRIVATE) {1112 Text(1113 "RELAY URL",1114 color = fg.copy(alpha = 0.6f),1115 fontFamily = PeachType.Mono,1116 fontSize = 8.sp,1117 letterSpacing = 0.22.em,1118 modifier = Modifier.padding(top = 8.dp),1119 )1120 Box(1121 Modifier.fillMaxWidth().padding(top = 6.dp)1122 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1123 .padding(horizontal = 12.dp, vertical = 10.dp),1124 ) {1125 BasicTextField(1126 value = relayUrlDraft,1127 onValueChange = { relayUrlDraft = it; relayUrlStatus = null },1128 singleLine = true,1129 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),1130 cursorBrush = SolidColor(fg),1131 modifier = Modifier.fillMaxWidth(),1132 )1133 }1134 val needsRelayUrl = configuredRelayUrl.isEmpty()1135 RowLine(fg) {1136 val message = relayUrlStatus1137 ?: if (needsRelayUrl) {1138 "Sync is paused until you enter your relay URL."1139 } else {1140 "Only secure wss:// relays are accepted."1141 }1142 val warn = relayUrlStatus?.startsWith("Use") == true ||1143 (relayUrlStatus == null && needsRelayUrl)1144 ValueText(message, if (warn) peachColors.Breach else fg, Modifier.weight(1f))1145 Spacer(Modifier.width(PeachSpacing.xs))1146 SubtleButton("save", fg) {1147 relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {1148 relayUrlDraft = privateRelayDraftOf(syncVm.privateRelayUrl.value)1149 "Saved"1150 } else {1151 "Use a valid wss:// relay URL"1152 }1153 }1154 }1155 }11561157 SyncModeRow(1158 title = "No sync",1159 detail = "Keep this device separate and move backups by hand.",1160 selected = syncMode == SyncMode.OFF,1161 fg = fg,1162 ) { syncVm.setSyncMode(SyncMode.OFF) }1163 Hint("Pairing authorizes a device, not a cloud account.", fg)1164 }11651166 if (pendingVouched.isNotEmpty()) {1167 SubLabel("PENDING DEVICES", fg)1168 pendingVouched.forEach { dev ->1169 RowLine(fg) {1170 Column(Modifier.weight(1f)) {1171 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1172 Text(1173 "Was added by another device. Allow it to sync?",1174 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1175 modifier = Modifier.padding(top = 2.dp),1176 )1177 }1178 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1179 SubtleButton("Allow", fg) { syncVm.confirmVouchedDevice(dev.deviceId) }1180 SubtleButton("Reject", fg) { syncVm.rejectVouchedDevice(dev.deviceId) }1181 }1182 }1183 }1184 }11851186 SyncDisclosure(1187 title = "Recovery & security",1188 summary = "For a lost or stolen device",1189 expanded = showRecovery,1190 fg = fg,1191 onToggle = { showRecovery = !showRecovery },1192 )1193 if (showRecovery) {1194 RowLine(fg) {1195 Column(Modifier.weight(1f)) {1196 Text("Re-pair this device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1197 Text(1198 "Scan only when this populated vault previously trusted the source device.",1199 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1200 modifier = Modifier.padding(top = 2.dp),1201 )1202 }1203 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }1204 }1205 if (rotateConfirmOpen) {1206 Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)1207 rotateError?.let {1208 Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))1209 }1210 Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {1211 SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }1212 SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {1213 if (rotating) return@SubtleButton1214 rotating = true1215 rotateError = null1216 scope.launch {1217 val ok = syncVm.rotateSyncIdentity()1218 rotating = false1219 if (ok) {1220 rotateConfirmOpen = false1221 onNavigate(PeachNav.RecoverySetup)1222 } else {1223 rotateError = "Rotation failed. Unlock your real vault and try again."1224 }1225 }1226 }1227 }1228 } else {1229 RowLine(fg) {1230 Column(Modifier.weight(1f)) {1231 Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1232 Text(1233 "Disconnects every device and replaces the keys used for sync.",1234 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,1235 modifier = Modifier.padding(top = 2.dp),1236 )1237 }1238 SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }1239 }1240 }1241 }12421243 if (showBackgroundSyncPrompt) {1244 BackgroundSyncDialog { enabled ->1245 showBackgroundSyncPrompt = false1246 notificationsAvailable = canShowBackgroundSyncNotification(context)1247 syncVm.setBackgroundSyncEnabled(enabled)1248 }1249 }1250}12511252private fun deviceTypeLabel(type: String?): String = when (type) {1253 "android" -> "Android"1254 "phone" -> "Phone"1255 "tablet" -> "Tablet"1256 "browser" -> "Browser"1257 else -> "Device"1258}12591260private fun syncTimeLabel(lastSyncedAt: Long?): String {1261 if (lastSyncedAt == null) return "never synced"1262 val diff = System.currentTimeMillis() - lastSyncedAt1263 if (diff < 60_000) return "just now"1264 if (diff < 3_600_000) return "${diff / 60_000}m ago"1265 if (diff < 86_400_000) return "${diff / 3_600_000}h ago"1266 return "${diff / 86_400_000}d ago"1267}12681269private fun isSyncStale(lastSyncedAt: Long?): Boolean =1270 lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L1271127212731274@Composable1275private fun ProBody(fg: Color, vm: SettingsViewModel) {1276 val ctx = LocalContext.current1277 val isPro by vm.isPro.collectAsState()1278 if (isPro) {1279 ProActiveBody(fg, vm)1280 return1281 }1282 val features = listOf(1283 "Peach Sync" to "End-to-end encrypted",1284 "Peach Codex" to "Restore from paper",1285 "Passkeys" to "Zero-knowledge WebAuthn",1286 "Advanced Vault Health" to "Reuse · age · exposure",1287 "Canary entries" to "Detects vault compromise",1288 "Duress vault" to "Decoy under coercion",1289 )1290 Text("PEACH PRO · ONE-TIME", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1291 Text(1292 "Everything, everywhere.",1293 modifier = Modifier.padding(top = 8.dp),1294 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp,1295 )1296 Text(1297 "One key unlocks every Pro feature — for the lifetime of the app.",1298 modifier = Modifier.padding(top = 6.dp),1299 color = fg.copy(alpha = 0.72f), fontFamily = PeachType.Body, fontSize = 12.sp, lineHeight = 17.sp,1300 )1301 Spacer(Modifier.height(10.dp))1302 features.forEachIndexed { i, (name, desc) ->1303 1304 1305 1306 1307 RowLine(fg, verticalPadding = 6.dp) {1308 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1309 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1310 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1311 }1312 Text(desc, color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1313 }1314 }1315 Spacer(Modifier.height(12.dp))1316 Text(1317 "$39",1318 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 28.sp,1319 )1320 Text("once · yours forever", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em)1321 Spacer(Modifier.height(10.dp))1322 Box(1323 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1324 .background(fg.copy(alpha = 0.16f))1325 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1326 .clickable {1327 runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRO_URL))) }1328 }1329 .padding(vertical = 12.dp),1330 contentAlignment = Alignment.Center,1331 ) { Text("Get Peach Pro", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }13321333 1334 1335 1336 1337 Spacer(Modifier.height(44.dp))13381339 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1340 var key by remember { mutableStateOf("") }1341 val activating by vm.activating.collectAsState()1342 val activationError by vm.activationError.collectAsState()1343 Box(1344 Modifier.fillMaxWidth().padding(top = 6.dp)1345 .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))1346 .padding(horizontal = 12.dp, vertical = 10.dp),1347 ) {1348 BasicTextField(1349 value = key,1350 1351 1352 1353 1354 onValueChange = { key = it; vm.clearActivationError() },1355 singleLine = true,1356 textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em, color = fg),1357 cursorBrush = SolidColor(fg),1358 modifier = Modifier.fillMaxWidth(),1359 decorationBox = { inner ->1360 if (key.isEmpty()) Text("PPK-XXXX-XXXX-XXXX", color = fg.copy(alpha = 0.3f), fontFamily = PeachType.Mono, fontSize = 13.sp, letterSpacing = 0.12.em)1361 inner()1362 },1363 )1364 }1365 if (activationError != null) {1366 Text(activationError!!, color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp))1367 }1368 Spacer(Modifier.height(8.dp))1369 Box(1370 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1371 .background(fg.copy(alpha = if (activating || key.isBlank()) 0.08f else 0.16f))1372 .border(1.dp, fg.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1373 .clickable(enabled = !activating && key.isNotBlank()) { vm.activateLicense(key) }1374 .padding(vertical = 12.dp),1375 contentAlignment = Alignment.Center,1376 ) { Text(if (activating) "Activating…" else "Activate", color = fg, fontFamily = PeachType.Mono, fontSize = 12.sp) }1377}13781379@Composable1380private fun ProActiveBody(fg: Color, vm: SettingsViewModel) {1381 val ctx = LocalContext.current1382 val features = listOf("Peach Sync", "Peach Codex", "Passkeys", "Advanced Vault Health", "Canary entries", "Duress vault")1383 val licenseKey by vm.licenseKey.collectAsState()1384 val issuedAt by vm.licenseIssuedAt.collectAsState()1385 var showKey by remember { mutableStateOf(false) }1386 var copied by remember { mutableStateOf(false) }1387 val scope = rememberCoroutineScope()1388 Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {1389 com.peachpasswords.ui.components.PeachMark(size = 60.dp)1390 }1391 Text(1392 "Peach Pro",1393 modifier = Modifier.fillMaxWidth(),1394 color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp,1395 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1396 )1397 Text(1398 formatLicenseActiveSince(issuedAt),1399 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),1400 color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.18.em,1401 textAlign = androidx.compose.ui.text.style.TextAlign.Center,1402 )1403 licenseKey?.let { key ->1404 Spacer(Modifier.height(12.dp))1405 Text("LICENSE KEY", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 8.sp, letterSpacing = 0.22.em)1406 RowLine(fg) {1407 Text(1408 if (showKey) key else "************************",1409 color = fg.copy(alpha = if (showKey) 0.82f else 0.58f),1410 fontFamily = PeachType.Mono,1411 fontSize = 10.sp,1412 modifier = Modifier.weight(1f),1413 )1414 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {1415 SubtleButton(if (copied) "copied" else "copy", fg) {1416 PeachClipboard.copy(ctx, "peach license", key)1417 copied = true1418 scope.launch {1419 delay(1_500)1420 copied = false1421 }1422 }1423 SubtleButton(if (showKey) "hide" else "reveal", fg) { showKey = !showKey }1424 }1425 }1426 }1427 Spacer(Modifier.height(12.dp))1428 features.forEachIndexed { i, name ->1429 RowLine(fg) {1430 Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {1431 Text("%02d".format(i + 1), color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 10.sp, modifier = Modifier.padding(end = 10.dp))1432 Text(name, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1433 }1434 Text("unlocked", color = com.peachpasswords.ui.theme.peachColors.HealthOk, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 11.sp)1435 }1436 }1437 Spacer(Modifier.height(12.dp))1438 Box(1439 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))1440 .border(1.dp, com.peachpasswords.ui.theme.peachColors.Breach.copy(alpha = 0.4f), RoundedCornerShape(10.dp))1441 .clickable { vm.deactivate() }1442 .padding(vertical = 12.dp),1443 contentAlignment = Alignment.Center,1444 ) { Text("Deactivate", color = com.peachpasswords.ui.theme.peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 12.sp) }1445}14461447private fun formatLicenseActiveSince(issuedAt: Long?): String {1448 if (issuedAt == null) return "Active"1449 val monthYear = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(issuedAt))1450 return "Active since $monthYear"1451}1452145314541455@OptIn(ExperimentalLayoutApi::class)1456@Composable1457private fun FeedbackBody(fg: Color, vm: SettingsViewModel) {1458 val categories = listOf("Bug report", "Feature request", "Question", "Other")1459 var category by remember { mutableStateOf(categories[0]) }1460 var message by remember { mutableStateOf("") }1461 val state by vm.feedbackState.collectAsState()14621463 1464 DisposableEffect(Unit) { onDispose { vm.resetFeedback() } }14651466 if (state is FeedbackState.Sent) {1467 Box(1468 Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(fg.copy(alpha = 0.08f))1469 .border(1.dp, fg.copy(alpha = 0.32f), RoundedCornerShape(10.dp)).padding(16.dp),1470 ) {1471 Column {1472 Text("Thank you", color = fg, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp)1473 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))1474 }1475 }1476 return1477 }14781479 val sending = state is FeedbackState.Sending14801481 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)1482 SubLabel("TYPE", fg)1483 FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1484 categories.forEach { cat -> Pill(cat, category == cat, fg) { if (!sending) category = cat } }1485 }1486 SubLabel("MESSAGE", fg)1487 Box(1488 Modifier.fillMaxWidth().height(110.dp).clip(RoundedCornerShape(8.dp))1489 .background(Color(0x2E000000)).border(1.dp, fg.copy(alpha = 0.22f), RoundedCornerShape(8.dp))1490 .padding(10.dp),1491 ) {1492 if (message.isEmpty()) Text("Describe the issue or idea...", color = fg.copy(alpha = 0.42f), fontFamily = PeachType.Body, fontSize = 12.sp)1493 BasicTextField(1494 value = message, onValueChange = { message = it.take(MAX_FEEDBACK_MESSAGE_LENGTH) },1495 enabled = !sending,1496 textStyle = TextStyle(color = fg, fontFamily = PeachType.Body, fontSize = 12.sp),1497 cursorBrush = SolidColor(fg), modifier = Modifier.fillMaxWidth(),1498 )1499 }1500 Text(1501 "Version ${BuildConfig.VERSION_NAME} · will be included automatically",1502 modifier = Modifier.padding(top = 6.dp),1503 color = fg.copy(alpha = 0.52f),1504 fontFamily = PeachType.Mono,1505 fontSize = 10.sp,1506 lineHeight = 14.sp,1507 )1508 Spacer(Modifier.height(8.dp))15091510 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {1511 val canSend = !sending && sanitizeFeedbackMessage(message).isNotBlank()1512 Box(1513 Modifier.clip(RoundedCornerShape(8.dp))1514 .border(1.dp, fg.copy(alpha = if (canSend) 0.28f else 0.14f), RoundedCornerShape(8.dp))1515 .background(fg.copy(alpha = if (canSend) 0.08f else 0.04f))1516 .clickable(enabled = canSend) { vm.sendFeedback(category, message) }1517 .padding(horizontal = 12.dp, vertical = 6.dp),1518 ) {1519 Text(1520 feedbackSubmitLabel(state),1521 color = fg.copy(alpha = if (canSend) 1f else 0.4f),1522 fontFamily = PeachType.Mono, fontSize = 11.sp,1523 )1524 }1525 if (sending) {1526 CircularProgressIndicator(1527 color = LocalPeachAccent.current,1528 strokeWidth = 2.dp,1529 modifier = Modifier.size(16.dp),1530 )1531 }1532 }15331534 (state as? FeedbackState.Error)?.let { err ->1535 Text(1536 err.message,1537 modifier = Modifier.padding(top = 8.dp),1538 color = peachColors.Breach,1539 fontFamily = PeachType.Mono,1540 fontSize = 11.sp,1541 lineHeight = 15.sp,1542 )1543 }15441545 Hint("Sent anonymously · no account required", fg)1546}1547154815491550@Composable1551internal fun DuressSection(fg: Color, vm: SettingsViewModel) {1552 val configured by vm.duressConfigured.collectAsState()1553 if (configured) DuressActiveState(fg, vm) else DuressForm(fg, vm)1554}15551556@Composable1557private fun DuressActiveState(fg: Color, vm: SettingsViewModel) {1558 Text(1559 "Duress password is active",1560 color = peachColors.HealthOk,1561 fontFamily = PeachType.Mono,1562 fontSize = 11.sp,1563 modifier = Modifier.padding(top = 6.dp),1564 )1565 Spacer(Modifier.height(8.dp))1566 DuressDangerGhostButton("Disable duress password", fg) { vm.disableDuress() }1567 Hint("Entering your duress password instead of your real one triggers the action you chose.", fg)1568}15691570@Composable1571private fun DuressForm(fg: Color, vm: SettingsViewModel) {1572 var enabled by remember { mutableStateOf(false) }1573 var password by remember { mutableStateOf("") }1574 var confirm by remember { mutableStateOf("") }1575 var action by remember { mutableStateOf(DuressAction.DECOY) }1576 var error by remember { mutableStateOf<String?>(null) }1577 val scope = rememberCoroutineScope()15781579 RowLine(fg) {1580 Name("Duress password", fg)1581 Toggle(enabled, fg) { on ->1582 enabled = on1583 if (!on) { password = ""; confirm = ""; error = null }1584 }1585 }1586 Text(1587 "Entering your duress password instead of your real one opens a decoy vault or wipes the real one.",1588 color = fg.copy(alpha = 0.7f),1589 fontFamily = PeachType.Mono,1590 fontSize = 10.sp,1591 modifier = Modifier.padding(top = 6.dp),1592 )15931594 AnimatedVisibility(enabled) {1595 Column(Modifier.fillMaxWidth().padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {1596 PwField("DURESS PASSPHRASE", password, fg) { password = it; error = null }1597 PwField("CONFIRM DURESS PASSPHRASE", confirm, fg) { confirm = it; error = null }15981599 Text(1600 "When your duress password is entered at the lock screen, Peach will:",1601 color = fg.copy(alpha = 0.7f),1602 fontFamily = PeachType.Mono,1603 fontSize = 10.sp,1604 modifier = Modifier.padding(top = 4.dp),1605 )16061607 DuressRadioOption("Show a decoy vault", action == DuressAction.DECOY, fg) { action = DuressAction.DECOY }1608 DuressRadioOption("Destroy the real vault", action == DuressAction.DESTROY, fg) { action = DuressAction.DESTROY }16091610 error?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }16111612 val canSave = password.isNotBlank() && confirm.isNotBlank()1613 DuressSaveButton("Save duress password", fg, canSave) {1614 error = when {1615 password.isBlank() || confirm.isBlank() -> "Enter and confirm a duress password"1616 password != confirm -> "Passwords do not match"1617 else -> null1618 }1619 if (error == null) scope.launch {1620 if (vm.saveDuress(password, action)) {1621 password = ""; confirm = ""; enabled = false1622 } else {1623 error = "Couldn't save — the duress passphrase must differ from your real password"1624 }1625 }1626 }1627 }1628 }1629}16301631@Composable1632private fun DuressRadioOption(label: String, selected: Boolean, fg: Color, onClick: () -> Unit) {1633 val accent = LocalPeachAccent.current1634 Row(1635 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick).padding(vertical = 7.dp),1636 verticalAlignment = Alignment.CenterVertically,1637 horizontalArrangement = Arrangement.spacedBy(10.dp),1638 ) {1639 Box(1640 Modifier.size(16.dp).clip(CircleShape)1641 .border(1.dp, if (selected) accent else fg.copy(alpha = 0.4f), CircleShape),1642 contentAlignment = Alignment.Center,1643 ) {1644 if (selected) Box(Modifier.size(8.dp).clip(CircleShape).background(accent))1645 }1646 Text(label, color = if (selected) accent else fg, fontFamily = PeachType.Mono, fontSize = 11.sp)1647 }1648}16491650@Composable1651private fun DuressSaveButton(label: String, fg: Color, enabled: Boolean, onClick: () -> Unit) {1652 val accent = LocalPeachAccent.current1653 val tint = if (enabled) accent else fg.copy(alpha = 0.3f)1654 Box(1655 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1656 .border(1.dp, tint.copy(alpha = if (enabled) 0.6f else 0.3f), RoundedCornerShape(8.dp))1657 .background(tint.copy(alpha = if (enabled) 0.16f else 0.06f))1658 .let { if (enabled) it.clickable(onClick = onClick) else it }1659 .padding(vertical = 11.dp),1660 contentAlignment = Alignment.Center,1661 ) {1662 Text(label, color = if (enabled) accent else fg.copy(alpha = 0.4f), fontFamily = PeachType.Mono, fontSize = 11.sp)1663 }1664}16651666@Composable1667private fun DuressDangerGhostButton(label: String, fg: Color, onClick: () -> Unit) {1668 Box(1669 Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))1670 .border(1.dp, peachColors.Breach.copy(alpha = 0.5f), RoundedCornerShape(8.dp))1671 .clickable(onClick = onClick)1672 .padding(vertical = 11.dp),1673 contentAlignment = Alignment.Center,1674 ) {1675 Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1676 }1677}167816791680@Composable1681private fun SyncModeRow(1682 title: String,1683 detail: String,1684 selected: Boolean,1685 fg: Color,1686 onSelect: () -> Unit,1687) {1688 RowLine(fg, Modifier.clickable(onClick = onSelect)) {1689 Column(Modifier.weight(1f)) {1690 Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1691 Text(1692 detail,1693 color = fg.copy(alpha = 0.7f),1694 fontFamily = PeachType.Body,1695 fontSize = 11.sp,1696 lineHeight = 16.sp,1697 modifier = Modifier.padding(top = 2.dp),1698 )1699 }1700 Text(1701 if (selected) "●" else "○",1702 color = if (selected) fg else fg.copy(alpha = 0.45f),1703 fontFamily = PeachType.Mono,1704 fontSize = 14.sp,1705 modifier = Modifier.padding(start = 10.dp),1706 )1707 }1708}