ccf5e6f8b042
feat: Simplify Sync Settings with progressive disclosure (v2.6.0)
- time
- 2026-07-27 19:57 UTC · 2 months ago
- machine
- koh2
- parent
- d7406e5e9d9d
↓ peach-android-ccf5e6f8.tar.gzatlas at this save
files changed2 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+174 −143app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24012320- versionName "2.5.23"19+ versionCode 24012420+ versionName "2.6.0"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+174 −143
@@ -17,6 +17,7 @@1717 import androidx.compose.foundation.layout.Spacer1818 import androidx.compose.foundation.layout.fillMaxWidth1919 import androidx.compose.foundation.layout.height20+import androidx.compose.foundation.layout.heightIn2021 import androidx.compose.foundation.layout.padding2122 import androidx.compose.foundation.layout.size2223 import androidx.compose.foundation.layout.width@@ -205,6 +206,48 @@205206 }206207 207208 @Composable209+private fun SyncDisclosure(210+ title: String,211+ summary: String,212+ expanded: Boolean,213+ fg: Color,214+ onToggle: () -> Unit,215+) {216+ Row(217+ modifier = Modifier218+ .fillMaxWidth()219+ .heightIn(min = 48.dp)220+ .clip(RoundedCornerShape(8.dp))221+ .clickable(onClick = onToggle)222+ .padding(vertical = 8.dp),223+ verticalAlignment = Alignment.CenterVertically,224+ ) {225+ Column(Modifier.weight(1f)) {226+ Text(227+ title.uppercase(),228+ color = fg.copy(alpha = 0.58f),229+ fontFamily = PeachType.Mono,230+ fontSize = 8.sp,231+ letterSpacing = 0.22.em,232+ )233+ Text(234+ summary,235+ color = fg.copy(alpha = 0.7f),236+ fontFamily = PeachType.Body,237+ fontSize = 11.sp,238+ modifier = Modifier.padding(top = 3.dp),239+ )240+ }241+ Text(242+ if (expanded) "hide" else "show",243+ color = fg,244+ fontFamily = PeachType.Mono,245+ fontSize = 10.sp,246+ )247+ }248+}249+250+@Composable208251 private fun Toggle(on: Boolean, fg: Color, onToggle: (Boolean) -> Unit) {209252 val knob by animateDpAsState(if (on) 16.dp else 2.dp, label = "knob")210253 Box(@@ -658,28 +701,15 @@658701 var rotateError by remember { mutableStateOf<String?>(null) }659702 var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(configuredRelayUrl) }660703 var relayUrlStatus by remember { mutableStateOf<String?>(null) }704+ var showConnection by remember { mutableStateOf(false) }705+ var showRecovery by remember { mutableStateOf(false) }661706 662- // One pairing block, two buttons. The old "Sync hub" row went to the same663- // place as "show", and the description described a LAN-code-only design664- // that no longer exists.665- SubLabel("PAIR A DEVICE", fg, top = 0.dp)707+ SubLabel("ADD A DEVICE", fg, top = 0.dp)666708 RowLine(fg) {667709 Column(Modifier.weight(1f)) {668- Row(verticalAlignment = Alignment.CenterVertically) {669- Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)670- }671- Text(672- "End-to-end encrypted, through a relay that can read none of it.",673- color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,674- modifier = Modifier.padding(top = 2.dp),675- )676- }677- }678- RowLine(fg) {679- Column(Modifier.weight(1f)) {680710 Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)681711 Text(682- "For the other device to scan. Always use this one to pair a browser.",712+ "Let another Peach device scan this phone.",683713 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,684714 modifier = Modifier.padding(top = 2.dp),685715 )@@ -690,8 +720,7 @@690720 Column(Modifier.weight(1f)) {691721 Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)692722 Text(693- "Joins that vault. Your entries are kept and the two merge; this phone's " +694- "recovery words are replaced, and shown to you first.",723+ "For a new empty phone, or to re-pair a device this vault already knows.",695724 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,696725 modifier = Modifier.padding(top = 2.dp),697726 )@@ -699,29 +728,7 @@699728 SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }700729 }701730 702- // The indicator light and the same claim in words, in the browser's place:703- // directly above PAIRED DEVICES. Outside the "has devices" branch on704- // purpose — a phone with nothing paired is exactly the phone whose owner705- // needs to be told why nothing is syncing.706731 SubLabel("SYNC STATUS", fg)707- RowLine(fg) {708- Column(Modifier.weight(1f)) {709- Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)710- Text(711- if (backgroundSyncEnabled) {712- "Background sync is on. Tap to change."713- } else {714- "Background sync is off. Tap to enable."715- },716- color = fg.copy(alpha = 0.7f),717- fontFamily = PeachType.Body,718- fontSize = 11.sp,719- lineHeight = 16.sp,720- modifier = Modifier.padding(top = 2.dp),721- )722- }723- SubtleButton("change", fg) { showBackgroundSyncPrompt = true }724- }725732 val indicator = syncIndicator(syncStatus, pairedDevices.size)726733 RowLine(fg) {727734 SyncIndicatorDot(@@ -794,81 +801,99 @@794801 }795802 }796803 797- // Three named choices, not a toggle with an implied default. LAN sync is798- // retired (docs/sync-lan-retirement.md), so "no relay" now means no sync799- // rather than a peer-to-peer mode that never reliably worked. The800- // phone-as-relay-for-two-browsers mechanism went with it: browsers dial the801- // real relay directly now, so this handset has no forwarding role.802- SubLabel("HOW THIS VAULT SYNCS", fg)803- SyncModeRow(804- title = "Public relay",805- detail = "Peach's own relay. Works anywhere, nothing to set up. It never sees " +806- "your vault — everything is encrypted before it leaves this device.",807- selected = syncMode == SyncMode.PUBLIC,804+ SyncDisclosure(805+ title = "Connection",806+ summary = "Background sync, relay, and privacy choices",807+ expanded = showConnection,808808 fg = fg,809- ) { syncVm.setSyncMode(SyncMode.PUBLIC) }810- SyncModeRow(811- title = "Private relay",812- detail = "Your own peach-relay, on your own domain. Same open-source code.",813- selected = syncMode == SyncMode.PRIVATE,814- fg = fg,815- ) { syncVm.setSyncMode(SyncMode.PRIVATE) }809+ onToggle = { showConnection = !showConnection },810+ )811+ if (showConnection) {812+ RowLine(fg) {813+ Column(Modifier.weight(1f)) {814+ Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)815+ Text(816+ if (backgroundSyncEnabled) {817+ "On — keeps this phone reachable for paired devices."818+ } else {819+ "Off — Peach syncs only while you are using the app."820+ },821+ color = fg.copy(alpha = 0.7f),822+ fontFamily = PeachType.Body,823+ fontSize = 11.sp,824+ lineHeight = 16.sp,825+ modifier = Modifier.padding(top = 2.dp),826+ )827+ }828+ SubtleButton("change", fg) { showBackgroundSyncPrompt = true }829+ }830+ SyncModeRow(831+ title = "Public relay",832+ detail = "Works anywhere with nothing to set up. The relay cannot read your vault.",833+ selected = syncMode == SyncMode.PUBLIC,834+ fg = fg,835+ ) { syncVm.setSyncMode(SyncMode.PUBLIC) }836+ SyncModeRow(837+ title = "Private relay",838+ detail = "Use your own peach-relay on your own domain.",839+ selected = syncMode == SyncMode.PRIVATE,840+ fg = fg,841+ ) { syncVm.setSyncMode(SyncMode.PRIVATE) }816842 817- if (syncMode == SyncMode.PRIVATE) {818- Text(819- "RELAY URL",820- color = fg.copy(alpha = 0.6f),821- fontFamily = PeachType.Mono,822- fontSize = 8.sp,823- letterSpacing = 0.22.em,824- modifier = Modifier.padding(top = 8.dp),825- )826- Box(827- Modifier.fillMaxWidth().padding(top = 6.dp)828- .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))829- .padding(horizontal = 12.dp, vertical = 10.dp),830- ) {831- BasicTextField(832- value = relayUrlDraft,833- onValueChange = { relayUrlDraft = it; relayUrlStatus = null },834- singleLine = true,835- textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),836- cursorBrush = SolidColor(fg),837- modifier = Modifier.fillMaxWidth(),843+ if (syncMode == SyncMode.PRIVATE) {844+ Text(845+ "RELAY URL",846+ color = fg.copy(alpha = 0.6f),847+ fontFamily = PeachType.Mono,848+ fontSize = 8.sp,849+ letterSpacing = 0.22.em,850+ modifier = Modifier.padding(top = 8.dp),838851 )839- }840- // Private with no address is not the public relay. Say so here rather841- // than letting the phone sync through Peach under a private label.842- val needsRelayUrl = configuredRelayUrl.isEmpty()843- RowLine(fg) {844- val message = relayUrlStatus845- ?: if (needsRelayUrl) {846- "Sync is paused until you enter your relay URL."847- } else {848- "Only secure wss:// relays are accepted."852+ Box(853+ Modifier.fillMaxWidth().padding(top = 6.dp)854+ .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))855+ .padding(horizontal = 12.dp, vertical = 10.dp),856+ ) {857+ BasicTextField(858+ value = relayUrlDraft,859+ onValueChange = { relayUrlDraft = it; relayUrlStatus = null },860+ singleLine = true,861+ textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),862+ cursorBrush = SolidColor(fg),863+ modifier = Modifier.fillMaxWidth(),864+ )865+ }866+ val needsRelayUrl = configuredRelayUrl.isEmpty()867+ RowLine(fg) {868+ val message = relayUrlStatus869+ ?: if (needsRelayUrl) {870+ "Sync is paused until you enter your relay URL."871+ } else {872+ "Only secure wss:// relays are accepted."873+ }874+ val warn = relayUrlStatus?.startsWith("Use") == true ||875+ (relayUrlStatus == null && needsRelayUrl)876+ ValueText(message, if (warn) peachColors.Breach else fg)877+ SubtleButton("save", fg) {878+ relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {879+ relayUrlDraft = syncVm.privateRelayUrl.value880+ "Saved"881+ } else {882+ "Use a valid wss:// relay URL"883+ }849884 }850- val warn = relayUrlStatus?.startsWith("Use") == true ||851- (relayUrlStatus == null && needsRelayUrl)852- ValueText(message, if (warn) peachColors.Breach else fg)853- SubtleButton("save", fg) {854- relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {855- relayUrlDraft = syncVm.privateRelayUrl.value856- "Saved"857- } else {858- "Use a valid wss:// relay URL"859- }860885 }861886 }887+888+ SyncModeRow(889+ title = "No sync",890+ detail = "Keep this device separate and move backups by hand.",891+ selected = syncMode == SyncMode.OFF,892+ fg = fg,893+ ) { syncVm.setSyncMode(SyncMode.OFF) }894+ Hint("Pairing authorizes a device, not a cloud account.", fg)862895 }863896 864- SyncModeRow(865- title = "No sync",866- detail = "This device keeps to itself. Move your vault by hand with Codex or " +867- "CSV export and import.",868- selected = syncMode == SyncMode.OFF,869- fg = fg,870- ) { syncVm.setSyncMode(SyncMode.OFF) }871-872897 if (pendingVouched.isNotEmpty()) {873898 SubLabel("PENDING DEVICES", fg)874899 pendingVouched.forEach { dev ->@@ -876,7 +901,7 @@876901 Column(Modifier.weight(1f)) {877902 Text(dev.deviceName, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)878903 Text(879- "Was added by another of your devices. Allow it to sync with this vault?",904+ "Was added by another device. Allow it to sync?",880905 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,881906 modifier = Modifier.padding(top = 2.dp),882907 )@@ -889,46 +914,52 @@889914 }890915 }891916 892- SubLabel("RECOVERY", fg)893- if (rotateConfirmOpen) {894- Hint("This will disconnect all paired devices and your 12 recovery words will change. You'll be shown the new words next.", peachColors.Breach)895- rotateError?.let {896- Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))897- }898- Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {899- SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }900- SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {901- if (rotating) return@SubtleButton902- rotating = true903- rotateError = null904- scope.launch {905- val ok = syncVm.rotateSyncIdentity()906- rotating = false907- if (ok) {908- rotateConfirmOpen = false909- onNavigate(PeachNav.RecoverySetup)910- } else {911- rotateError = "Rotation failed. Unlock your real vault and try again."917+ SyncDisclosure(918+ title = "Recovery & security",919+ summary = "For a lost or stolen device",920+ expanded = showRecovery,921+ fg = fg,922+ onToggle = { showRecovery = !showRecovery },923+ )924+ if (showRecovery) {925+ if (rotateConfirmOpen) {926+ Hint("This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.", peachColors.Breach)927+ rotateError?.let {928+ Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))929+ }930+ Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {931+ SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }932+ SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {933+ if (rotating) return@SubtleButton934+ rotating = true935+ rotateError = null936+ scope.launch {937+ val ok = syncVm.rotateSyncIdentity()938+ rotating = false939+ if (ok) {940+ rotateConfirmOpen = false941+ onNavigate(PeachNav.RecoverySetup)942+ } else {943+ rotateError = "Rotation failed. Unlock your real vault and try again."944+ }912945 }913946 }914947 }915- }916- } else {917- RowLine(fg) {918- Column(Modifier.weight(1f)) {919- Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)920- Text(921- "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.",922- color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,923- modifier = Modifier.padding(top = 2.dp),924- )948+ } else {949+ RowLine(fg) {950+ Column(Modifier.weight(1f)) {951+ Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)952+ Text(953+ "Disconnects every device and replaces the keys used for sync.",954+ color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,955+ modifier = Modifier.padding(top = 2.dp),956+ )957+ }958+ SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }925959 }926- SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }927960 }928961 }929962 930- Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)931-932963 if (showBackgroundSyncPrompt) {933964 BackgroundSyncDialog { enabled ->934965 showBackgroundSyncPrompt = false@@ -968,7 +999,7 @@968999 return9691000 }9701001 val features = listOf(971- "Peach Sync" to "Encrypted · peer-to-peer",1002+ "Peach Sync" to "End-to-end encrypted",9721003 "Peach Codex" to "Restore from paper",9731004 "Passkeys" to "Zero-knowledge WebAuthn",9741005 "Advanced Vault Health" to "Reuse · age · exposure",