bc64fde8cf75

feat: Recompose Codex surfaces with design primitives (v2.3.66)

time
2026-07-08 21:52 UTC · 3 months ago
machine
koh2
parent
605e3de62e10
4 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt+70 −80app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreChooseAndCamera.kt+23 −59app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreScreen.kt+36 −30
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23650020-        versionName "2.3.65"19+        versionCode 23660020+        versionName "2.3.66"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt+70 −80
@@ -14,10 +14,7 @@1414 import androidx.compose.foundation.layout.fillMaxWidth1515 import androidx.compose.foundation.layout.height1616 import androidx.compose.foundation.layout.padding17-import androidx.compose.foundation.layout.size18-import androidx.compose.foundation.shape.CircleShape1917 import androidx.compose.foundation.shape.RoundedCornerShape20-import androidx.compose.foundation.text.BasicTextField2118 import androidx.compose.material3.LinearProgressIndicator2219 import androidx.compose.material3.Text2320 import androidx.compose.runtime.Composable@@ -29,7 +26,6 @@2926 import androidx.compose.ui.Modifier3027 import androidx.compose.ui.draw.clip3128 import androidx.compose.ui.graphics.Color32-import androidx.compose.ui.graphics.SolidColor3329 import androidx.compose.ui.platform.LocalContext3430 import androidx.compose.ui.text.font.FontStyle3531 import androidx.compose.ui.unit.dp@@ -37,11 +33,15 @@3733 import androidx.hilt.navigation.compose.hiltViewModel3834 import androidx.lifecycle.compose.collectAsStateWithLifecycle3935 import com.peachpasswords.codex.CodexStep36+import com.peachpasswords.ui.components.PeachEmptyOrEndState37+import com.peachpasswords.ui.components.PeachField38+import com.peachpasswords.ui.components.PeachTopBar4039 import com.peachpasswords.ui.components.SecureFlagEffect4140 import com.peachpasswords.ui.theme.LocalPeachAccent42-import com.peachpasswords.ui.theme.peachColors41+import com.peachpasswords.ui.theme.PeachSpacing4342 import com.peachpasswords.ui.theme.PeachType4443 import com.peachpasswords.ui.theme.ShellBackground44+import com.peachpasswords.ui.theme.peachColors4545 import java.time.LocalDate4646 4747 @Composable@@ -61,63 +61,64 @@6161     SecureFlagEffect() // MN-3: never screenshot the codex generation / verify code6262 6363     ShellBackground {64-        Column(Modifier.fillMaxSize().padding(horizontal = 24.dp)) {65-            CodexHeader("Codex backup", onBack)66-            Spacer(Modifier.height(8.dp))64+        Column(Modifier.fillMaxSize().padding(horizontal = PeachSpacing.xl)) {65+            Spacer(Modifier.height(PeachSpacing.md))66+            CodexTopBar("Codex backup", onBack)67+            Spacer(Modifier.height(PeachSpacing.sm))6768             Text(6869                 "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.",6970                 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,7071             )71-            Spacer(Modifier.height(28.dp))72+            Spacer(Modifier.height(PeachSpacing.xxl))7273 7374             when (val s = state) {7475                 is GenerateState.Idle -> {7576                     when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) {7677                         CodexGenerateGate.PRO_REQUIRED -> {77-                            Text("Codex generation is a Pro feature", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)78-                            Spacer(Modifier.height(6.dp))79-                            Text(80-                                "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",81-                                color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,78+                            PeachEmptyOrEndState(79+                                message = "Codex generation is a Pro feature",80+                                detail = "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",81+                                action = {82+                                    CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)83+                                },8284                             )83-                            Spacer(Modifier.height(20.dp))84-                            CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)8585                         }8686                         CodexGenerateGate.RECOVERY_REQUIRED -> {87-                        Text("Set up recovery words first", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)88-                        Spacer(Modifier.height(6.dp))89-                        Text(90-                            "A Codex backup only restores alongside your recovery words. Set them up before generating one.",91-                            color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,92-                        )93-                        Spacer(Modifier.height(20.dp))94-                        CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)87+                            PeachEmptyOrEndState(88+                                message = "Set up recovery words first",89+                                detail = "A Codex backup only restores alongside your recovery words. Set them up before generating one.",90+                                action = {91+                                    CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)92+                                },93+                            )9594                         }9695                         CodexGenerateGate.READY -> {97-                        var w3 by remember { mutableStateOf("") }98-                        var w7 by remember { mutableStateOf("") }99-                        var w11 by remember { mutableStateOf("") }100-                        val ok = vm.gateMatches(w3, w7, w11)101-                        Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)102-                        Spacer(Modifier.height(6.dp))103-                        Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)104-                        Spacer(Modifier.height(16.dp))105-                        GateField("Word #3", w3, accent) { w3 = it }106-                        GateField("Word #7", w7, accent) { w7 = it }107-                        GateField("Word #11", w11, accent) { w11 = it }108-                        Spacer(Modifier.height(20.dp))109-                        CodexButton(110-                            "Generate Codex PDF",111-                            if (ok) accent else peachColors.T2,112-                            filled = ok,113-                        ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }96+                            var w3 by remember { mutableStateOf("") }97+                            var w7 by remember { mutableStateOf("") }98+                            var w11 by remember { mutableStateOf("") }99+                            val ok = vm.gateMatches(w3, w7, w11)100+                            Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)101+                            Spacer(Modifier.height(PeachSpacing.xs))102+                            Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)103+                            Spacer(Modifier.height(PeachSpacing.lg))104+                            PeachField("Word #3", w3, { w3 = it })105+                            Spacer(Modifier.height(PeachSpacing.md))106+                            PeachField("Word #7", w7, { w7 = it })107+                            Spacer(Modifier.height(PeachSpacing.md))108+                            PeachField("Word #11", w11, { w11 = it })109+                            Spacer(Modifier.height(PeachSpacing.xl))110+                            CodexButton(111+                                "Generate Codex PDF",112+                                if (ok) accent else peachColors.T2,113+                                filled = ok,114+                            ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }114115                         }115116                     }116117                 }117118 118119                 is GenerateState.Running -> Column {119120                     Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp)120-                    Spacer(Modifier.height(10.dp))121+                    Spacer(Modifier.height(PeachSpacing.sm))121122                     val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f122123                     LinearProgressIndicator(123124                         progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f },@@ -125,7 +126,7 @@125126                         color = accent, trackColor = peachColors.S2,126127                     )127128                     if (s.step == CodexStep.AZTEC) {128-                        Spacer(Modifier.height(6.dp))129+                        Spacer(Modifier.height(PeachSpacing.xs))129130                         Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp)130131                     }131132                 }@@ -135,9 +136,9 @@135136                     SummaryRow("entries", s.manifest.entryCount.toString(), accent)136137                     SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)137138                     SummaryRow("pages", s.manifest.pageCount.toString(), accent)138-                    Spacer(Modifier.height(24.dp))139+                    Spacer(Modifier.height(PeachSpacing.xl))139140                     CodexButton("Save PDF…", accent, filled = true) { saveLauncher.launch(codexPdfFileName()) }140-                    Spacer(Modifier.height(10.dp))141+                    Spacer(Modifier.height(PeachSpacing.sm))141142                     CodexButton("Share PDF…", peachColors.T2, filled = false) {142143                         val uri = vm.sharePdfUri() ?: return@CodexButton143144                         val intent = Intent(Intent.ACTION_SEND).apply {@@ -147,17 +148,17 @@147148                         }148149                         context.startActivity(Intent.createChooser(intent, "Share Codex PDF"))149150                     }150-                    Spacer(Modifier.height(10.dp))151+                    Spacer(Modifier.height(PeachSpacing.sm))151152                     CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) }152153                 }153154 154-                is GenerateState.Error -> Column {155-                    Text("Couldn't generate", color = peachColors.Breach, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)156-                    Spacer(Modifier.height(6.dp))157-                    Text(s.message, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)158-                    Spacer(Modifier.height(20.dp))159-                    CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }160-                }155+                is GenerateState.Error -> PeachEmptyOrEndState(156+                    message = "Couldn't generate",157+                    detail = s.message,158+                    action = {159+                        CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }160+                    },161+                )161162             }162163         }163164     }@@ -183,39 +184,28 @@183184 }184185 185186 @Composable186-internal fun CodexHeader(title: String, onBack: () -> Unit) {187-    Row(188-        modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(48.dp),189-        verticalAlignment = Alignment.CenterVertically,190-    ) {191-        Text("‹", color = peachColors.T1, fontSize = 26.sp,192-            modifier = Modifier.clip(CircleShape).clickable(onClick = onBack).padding(horizontal = 8.dp))193-        Spacer(Modifier.size(8.dp))194-        Text(title, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)195-    }187+internal fun CodexTopBar(title: String, onBack: () -> Unit) {188+    PeachTopBar(189+        title = title,190+        leading = {191+            Text(192+                "‹",193+                color = peachColors.T1,194+                fontSize = 26.sp,195+                modifier = Modifier196+                    .clip(RoundedCornerShape(999.dp))197+                    .clickable(onClick = onBack)198+                    .padding(horizontal = PeachSpacing.sm),199+            )200+        },201+    )196202 }197203 198204 @Composable199205 private fun SummaryRow(label: String, value: String, accent: Color) {200-    Row(Modifier.fillMaxWidth().padding(vertical = 7.dp), horizontalArrangement = Arrangement.SpaceBetween) {206+    Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {201207         Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)202208         Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)203-    }204-}205-206-@Composable207-private fun GateField(label: String, value: String, accent: Color, onChange: (String) -> Unit) {208-    Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {209-        Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)210-        BasicTextField(211-            value = value,212-            onValueChange = { onChange(it) },213-            singleLine = true,214-            textStyle = PeachType.bodyMono.copy(color = peachColors.T0),215-            cursorBrush = SolidColor(accent),216-            modifier = Modifier.fillMaxWidth().padding(top = 4.dp),217-        )218-        Box(Modifier.fillMaxWidth().padding(top = 4.dp).height(1.dp).background(peachColors.T3))219209     }220210 }221211 

view file

app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreChooseAndCamera.kt+23 −59
@@ -43,7 +43,6 @@4343 import androidx.compose.foundation.shape.RoundedCornerShape4444 import androidx.compose.material.icons.Icons4545 import androidx.compose.material.icons.outlined.Description46-import androidx.compose.material.icons.outlined.NoPhotography4746 import androidx.compose.material.icons.outlined.PhotoCamera4847 import androidx.compose.material3.Icon4948 import androidx.compose.material3.Text@@ -68,6 +67,8 @@6867 import androidx.compose.ui.viewinterop.AndroidView6968 import androidx.core.content.ContextCompat7069 import androidx.lifecycle.compose.LocalLifecycleOwner70+import com.peachpasswords.ui.components.PeachEmptyOrEndState71+import com.peachpasswords.ui.theme.PeachSpacing7172 import com.peachpasswords.ui.theme.peachColors7273 import com.peachpasswords.ui.theme.PeachType7374 import java.util.concurrent.Executors@@ -85,13 +86,12 @@8586     onScanCamera: () -> Unit,8687     onImportPdf: () -> Unit,8788 ) {88-    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {89+    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(PeachSpacing.md)) {8990         ChooseCard(9091             icon = Icons.Outlined.PhotoCamera,9192             iconTint = accent,9293             title = "Scan with camera",9394             body = "Point your camera at the printed Aztec grid",94-            accent = accent,9595             onClick = onScanCamera,9696         )9797         ChooseCard(@@ -99,7 +99,6 @@9999             iconTint = peachColors.T2,100100             title = "Import PDF",101101             body = "Import the PDF you saved when generating your Codex",102-            accent = accent,103102             onClick = onImportPdf,104103         )105104     }@@ -111,7 +110,6 @@111110     iconTint: Color,112111     title: String,113112     body: String,114-    accent: Color,115113     onClick: () -> Unit,116114 ) {117115     Row(@@ -121,9 +119,9 @@121119             .background(peachColors.SurfaceElevated)122120             .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))123121             .clickable(onClick = onClick)124-            .padding(horizontal = 18.dp, vertical = 18.dp),122+            .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),125123         verticalAlignment = Alignment.CenterVertically,126-        horizontalArrangement = Arrangement.spacedBy(16.dp),124+        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.lg),127125     ) {128126         Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.height(24.dp))129127         Column(Modifier.fillMaxWidth()) {@@ -134,7 +132,7 @@134132                 fontStyle = FontStyle.Italic,135133                 fontSize = 19.sp,136134             )137-            Spacer(Modifier.height(4.dp))135+            Spacer(Modifier.height(PeachSpacing.xs))138136             Text(body, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)139137         }140138     }@@ -269,7 +267,7 @@269267     Box(270268         Modifier271269             .fillMaxSize()272-            .padding(10.dp)270+            .padding(PeachSpacing.sm)273271             .drawBehind {274272                 val arm = 20.dp.toPx()275273                 val w = 2.dp.toPx()@@ -291,7 +289,7 @@291289         Modifier292290             .fillMaxSize()293291             .background(Color(0xCC0A0703))294-            .padding(24.dp),292+            .padding(PeachSpacing.xl),295293         verticalArrangement = Arrangement.Center,296294         horizontalAlignment = Alignment.CenterHorizontally,297295     ) {@@ -301,13 +299,13 @@301299             fontFamily = PeachType.Mono,302300             fontSize = 11.sp,303301         )304-        Spacer(Modifier.height(16.dp))302+        Spacer(Modifier.height(PeachSpacing.lg))305303         Box(306304             Modifier307305                 .clip(RoundedCornerShape(8.dp))308306                 .border(BorderStroke(1.dp, accent), RoundedCornerShape(8.dp))309307                 .clickable(onClick = onRetry)310-                .padding(horizontal = 18.dp, vertical = 10.dp),308+                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),311309         ) {312310             Text("Try again", color = accent, fontFamily = PeachType.Mono, fontSize = 11.sp)313311         }@@ -317,51 +315,17 @@317315 @Composable318316 private fun CameraDeniedCard(accent: Color) {319317     val context = LocalContext.current320-    Column(321-        Modifier322-            .fillMaxWidth()323-            .clip(RoundedCornerShape(18.dp))324-            .background(peachColors.SurfaceElevated)325-            .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))326-            .padding(20.dp),327-        horizontalAlignment = Alignment.CenterHorizontally,328-    ) {329-        Icon(330-            Icons.Outlined.NoPhotography,331-            contentDescription = null,332-            tint = peachColors.T2,333-            modifier = Modifier.height(24.dp),334-        )335-        Spacer(Modifier.height(12.dp))336-        Text(337-            "Camera access needed",338-            color = peachColors.T0,339-            fontFamily = PeachType.Display,340-            fontStyle = FontStyle.Italic,341-            fontSize = 19.sp,342-        )343-        Spacer(Modifier.height(6.dp))344-        Text(345-            "Grant camera permission in Settings to scan your Codex",346-            color = peachColors.T2,347-            fontFamily = PeachType.Mono,348-            fontSize = 10.sp,349-        )350-        Spacer(Modifier.height(18.dp))351-        Box(352-            Modifier353-                .clip(RoundedCornerShape(8.dp))354-                .border(BorderStroke(1.dp, accent), RoundedCornerShape(8.dp))355-                .clickable {356-                    val intent = Intent(357-                        Settings.ACTION_APPLICATION_DETAILS_SETTINGS,358-                        Uri.fromParts("package", context.packageName, null),359-                    ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)360-                    context.startActivity(intent)361-                }362-                .padding(horizontal = 18.dp, vertical = 10.dp),363-        ) {364-            Text("Open Settings", color = accent, fontFamily = PeachType.Mono, fontSize = 11.sp)365-        }366-    }318+    PeachEmptyOrEndState(319+        message = "Camera access needed",320+        detail = "Grant camera permission in Settings to scan your Codex.",321+        action = {322+            CodexButton("Open Settings", accent, filled = true) {323+                val intent = Intent(324+                    Settings.ACTION_APPLICATION_DETAILS_SETTINGS,325+                    Uri.fromParts("package", context.packageName, null),326+                ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)327+                context.startActivity(intent)328+            }329+        },330+    )367331 }

view file

app/src/main/java/com/peachpasswords/ui/codex/CodexRestoreScreen.kt+36 −30
@@ -10,8 +10,6 @@1010 import androidx.compose.foundation.layout.fillMaxWidth1111 import androidx.compose.foundation.layout.height1212 import androidx.compose.foundation.layout.padding13-import androidx.compose.foundation.text.KeyboardOptions14-import androidx.compose.material3.OutlinedTextField1513 import androidx.compose.material3.Text1614 import androidx.compose.runtime.Composable1715 import androidx.compose.runtime.getValue@@ -21,15 +19,18 @@2119 import androidx.compose.ui.Modifier2220 import androidx.compose.ui.graphics.Color2321 import androidx.compose.ui.text.font.FontStyle22+import androidx.compose.ui.text.input.KeyboardType2423 import androidx.compose.ui.text.input.PasswordVisualTransformation25-import androidx.compose.ui.unit.dp2624 import androidx.compose.ui.unit.sp2725 import androidx.hilt.navigation.compose.hiltViewModel2826 import androidx.lifecycle.compose.collectAsStateWithLifecycle27+import com.peachpasswords.ui.components.PeachEmptyOrEndState28+import com.peachpasswords.ui.components.PeachField2929 import com.peachpasswords.ui.theme.LocalPeachAccent30-import com.peachpasswords.ui.theme.peachColors30+import com.peachpasswords.ui.theme.PeachSpacing3131 import com.peachpasswords.ui.theme.PeachType3232 import com.peachpasswords.ui.theme.ShellBackground33+import com.peachpasswords.ui.theme.peachColors3334 3435 @Composable3536 fun CodexRestoreScreen(onBack: () -> Unit, onRestored: () -> Unit, vm: CodexViewModel = hiltViewModel()) {@@ -41,14 +42,15 @@4142     ) { uri -> uri?.let { vm.scan(it) } }4243 4344     ShellBackground {44-        Column(Modifier.fillMaxSize().padding(horizontal = 24.dp)) {45-            CodexHeader("Codex restore", onBack)46-            Spacer(Modifier.height(8.dp))45+        Column(Modifier.fillMaxSize().padding(horizontal = PeachSpacing.xl)) {46+            Spacer(Modifier.height(PeachSpacing.md))47+            CodexTopBar("Codex restore", onBack)48+            Spacer(Modifier.height(PeachSpacing.sm))4749             Text(4850                 "Choose a Codex PDF. Peach reads its Aztec codes — even if some pages are missing or smudged — and rebuilds your encrypted vault. You'll need the master password.",4951                 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,5052             )51-            Spacer(Modifier.height(28.dp))53+            Spacer(Modifier.height(PeachSpacing.xxl))5254 5355             when (val s = state) {5456                 is RestoreState.Idle -> when (val cs = cameraState) {@@ -92,16 +94,21 @@9294                 is RestoreState.Restoring ->9395                     Text("Restoring vault…", color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 12.sp)9496 95-                is RestoreState.Restored ->96-                    Text("Vault restored.", color = accent, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)97+                is RestoreState.Restored -> PeachEmptyOrEndState(98+                    message = "Vault restored.",99+                    detail = "Your restored vault is ready.",100+                )97101 98-                is RestoreState.Error -> Column {99-                    Text("Couldn't restore", color = peachColors.Breach, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)100-                    Spacer(Modifier.height(6.dp))101-                    Text(s.message, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)102-                    Spacer(Modifier.height(20.dp))103-                    CodexButton("Choose another file", accent, filled = true) { vm.resetRestore(); pickLauncher.launch("application/pdf") }104-                }102+                is RestoreState.Error -> PeachEmptyOrEndState(103+                    message = "Couldn't restore",104+                    detail = s.message,105+                    action = {106+                        CodexButton("Choose another file", accent, filled = true) {107+                            vm.resetRestore()108+                            pickLauncher.launch("application/pdf")109+                        }110+                    },111+                )105112             }106113         }107114     }@@ -122,7 +129,7 @@122129             fontStyle = FontStyle.Italic,123130             fontSize = 19.sp,124131         )125-        Spacer(Modifier.height(6.dp))132+        Spacer(Modifier.height(PeachSpacing.xs))126133         Text(127134             "The PDF decoded ${s.found} codes, but ${s.missing} remained unreadable. " +128135                 (if (s.ready == s.total) {@@ -134,16 +141,16 @@134141             fontFamily = PeachType.Body,135142             fontSize = 13.sp,136143         )137-        Spacer(Modifier.height(8.dp))144+        Spacer(Modifier.height(PeachSpacing.sm))138145         Text(139146             "Try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.",140147             color = peachColors.T2,141148             fontFamily = PeachType.Body,142149             fontSize = 13.sp,143150         )144-        Spacer(Modifier.height(20.dp))151+        Spacer(Modifier.height(PeachSpacing.xl))145152         CodexButton("Try PDF again", accent, filled = true, onClick = onRetryPdf)146-        Spacer(Modifier.height(10.dp))153+        Spacer(Modifier.height(PeachSpacing.sm))147154         CodexButton("Use camera", peachColors.T2, filled = false, onClick = onUseCamera)148155     }149156 }@@ -152,29 +159,28 @@152159 private fun DecodedBody(s: RestoreState.Decoded, accent: Color, vm: CodexViewModel, onRestored: () -> Unit) {153160     var password by remember { mutableStateOf("") }154161     Column {155-        Row(Modifier.fillMaxWidth().padding(vertical = 7.dp), horizontalArrangement = Arrangement.SpaceBetween) {162+        Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {156163             Text("verify code", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)157164             Text(s.payload.verifyCode, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)158165         }159-        Row(Modifier.fillMaxWidth().padding(vertical = 7.dp), horizontalArrangement = Arrangement.SpaceBetween) {166+        Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {160167             Text("entries", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)161168             Text(s.payload.entryCount.toString(), color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)162169         }163-        Row(Modifier.fillMaxWidth().padding(vertical = 7.dp), horizontalArrangement = Arrangement.SpaceBetween) {170+        Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {164171             Text("codes read", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)165172             Text(s.codesFound.toString(), color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)166173         }167-        Spacer(Modifier.height(20.dp))168-        OutlinedTextField(174+        Spacer(Modifier.height(PeachSpacing.xl))175+        PeachField(176+            label = "Master password",169177             value = password,170178             onValueChange = { password = it },171-            placeholder = { Text("master password", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 13.sp) },172179             visualTransformation = PasswordVisualTransformation(),173-            keyboardOptions = KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Password),180+            keyboardType = KeyboardType.Password,174181             singleLine = true,175-            modifier = Modifier.fillMaxWidth(),176182         )177-        Spacer(Modifier.height(16.dp))183+        Spacer(Modifier.height(PeachSpacing.lg))178184         CodexButton("Restore this vault", accent, filled = true) {179185             if (password.isNotEmpty()) vm.confirmRestore(password, onRestored)180186         }

view file