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
2 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+174 −143
app/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 {

view file

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",

view file