c6e41d31ec0a

quiet the entry-deck root's top bar to match the browser's minimal persistent header - removed the colored favicon-initials chip (the one loud, saturated element in an otherwise neutral bar, and redundant with the entry's own hero section shown just below it), and unified lock/add/settings icon tint from T0 (full white) to T1 (quiet), matching the delete icon's existing default-state tint so the whole action row reads as one consistent, muted set instead of three bright icons plus one quiet one

time
2026-07-24 15:32 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
critical-corrections-c1-c7
parent
73d0f6503c8a
11 files app/src/androidTest/java/com/peachpasswords/codex/CodexPdfRoundTripTest.kt+82 −0app/src/main/java/com/peachpasswords/MainActivity.kt+16 −0app/src/main/java/com/peachpasswords/codex/CodexAztec.kt+16 −19app/src/main/java/com/peachpasswords/codex/CodexGenerator.kt+2 −2app/src/main/java/com/peachpasswords/codex/CodexPdf.kt+53 −6app/src/main/java/com/peachpasswords/codex/CodexRestore.kt+11 −2app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+11 −5app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+43 −11app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt+1 −11app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+49 −108app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+0 −7
app/src/androidTest/java/com/peachpasswords/codex/CodexPdfRoundTripTest.kt+82 −0
@@ -0,0 +1,82 @@1+package com.peachpasswords.codex2+3+import androidx.test.ext.junit.runners.AndroidJUnit44+import androidx.test.platform.app.InstrumentationRegistry5+import org.junit.Assert.assertEquals6+import org.junit.Test7+import org.junit.runner.RunWith8+import java.io.File9+import java.io.FileOutputStream10+11+/**12+ * End-to-end regression test for the Codex PDF round trip: render a page of13+ * Aztec codes to PDF bytes, write them to disk, then decode the same file14+ * back via [CodexRestore] exactly as the real restore flow does (SAF-style15+ * file URI, [android.graphics.pdf.PdfRenderer], per-cell crop). This is the16+ * real bug surface: the data-layer (compression, Reed-Solomon, envelope17+ * integrity) already has coverage in [CodexSuiteTest], but nothing previously18+ * exercised the actual PDF render → rasterize → decode pipeline, which is19+ * exactly where the bug lived (PdfDocument's raster bitmap embedding20+ * corrupted crisp module edges, and 640px crop resolution was independently21+ * too low once codes needed more than a couple of Aztec layers).22+ */23+@RunWith(AndroidJUnit4::class)24+class CodexPdfRoundTripTest {25+26+    @Test27+    fun pdfRoundTripDecodesAllCodesAtRealisticScale() {28+        val context = InstrumentationRegistry.getInstrumentation().targetContext29+30+        // ~75 envelopes: matches the code count a real multi-hundred-entry31+        // vault's Codex produces. Pseudo-random filler (not a single repeated32+        // character) so deflate can't collapse it to a trivial size.33+        val filler = StringBuilder()34+        val rnd = java.util.Random(42)35+        repeat(36000) { filler.append(('a' + rnd.nextInt(26))) }36+        val fakePayload = CodexPayload(37+            verifyCode = "PX-TEST-0000",38+            vaultBlob = filler.toString(),39+            timestamp = 0L,40+            entryCount = 1,41+            vaultFingerprint = CodexCodec.sha256Hex(filler.toString().toByteArray(Charsets.UTF_8)).take(32),42+            generatedAt = 0L,43+        )44+        val payloadJson = CodexCodec.json.encodeToString(CodexPayload.serializer(), fakePayload)45+        val compressed = CodexCodec.deflate(payloadJson.toByteArray(Charsets.UTF_8))46+        val (envelopes, layout) = CodexCodec.encodeToEnvelopes(compressed, "PX-TEST-0000")47+48+        val aztecs = envelopes.map { env ->49+            val jsonStr = CodexCodec.json.encodeToString(ChunkEnvelope.serializer(), env)50+            CodexAztec.encodeMatrix(jsonStr)51+        }52+53+        val manifest = CodexManifest(54+            verifyCode = "PX-TEST-0000",55+            totalChunks = layout.totalChunks,56+            dataChunks = layout.dataChunks,57+            parityChunks = layout.parityChunks,58+            stripeCount = layout.stripeCount,59+            pageCount = Math.ceil(layout.totalChunks.toDouble() / CODES_PER_PAGE).toInt(),60+            entryCount = 1,61+            vaultFingerprint = "test",62+            generatedAt = 0L,63+        )64+        val pdfBytes = CodexPdf.render(aztecs, manifest)65+66+        val pdfFile = File(context.cacheDir, "roundtrip_test.pdf")67+        FileOutputStream(pdfFile).use { it.write(pdfBytes) }68+        val uri = android.net.Uri.fromFile(pdfFile)69+70+        val result = CodexRestore.decodeFromUri(context, uri)71+        assertEquals(72+            "Every generated code should decode back out of the rendered PDF",73+            envelopes.size,74+            result.envelopes.size,75+        )76+77+        val payload = CodexRestore.reconstructPayload(result.envelopes)78+        assertEquals("PX-TEST-0000", payload.verifyCode)79+        assertEquals(fakePayload.vaultBlob, payload.vaultBlob)80+        assertEquals(fakePayload.vaultFingerprint, payload.vaultFingerprint)81+    }82+}

view file

app/src/main/java/com/peachpasswords/MainActivity.kt+16 −0
@@ -9,9 +9,11 @@99 import androidx.activity.compose.setContent1010 import androidx.activity.enableEdgeToEdge1111 import androidx.compose.foundation.isSystemInDarkTheme12+import androidx.compose.runtime.SideEffect1213 import androidx.compose.runtime.collectAsState1314 import androidx.compose.runtime.getValue1415 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen16+import androidx.core.view.WindowCompat1517 import androidx.fragment.app.FragmentActivity1618 import dagger.hilt.android.AndroidEntryPoint1719 import javax.inject.Inject@@ -93,6 +95,20 @@9395                 "light" -> false9496                 "system" -> isSystemInDarkTheme()9597                 else -> true98+            }99+            // enableEdgeToEdge() above only knows the system's own day/night100+            // mode at launch. This app's theme can be forced independent of101+            // that (settings.theme == "dark"/"light"), so on a mismatch —102+            // e.g. system in light mode, app forced dark — the status bar103+            // icons render in the system's assumed color (dark-on-dark) and104+            // become invisible against the app's actual background, reading105+            // as if the app extends behind/under the status bar. Keep the106+            // system bar icon appearance in sync with the resolved theme.107+            SideEffect {108+                WindowCompat.getInsetsController(window, window.decorView).apply {109+                    isAppearanceLightStatusBars = !darkTheme110+                    isAppearanceLightNavigationBars = !darkTheme111+                }96112             }97113             val accent = ACCENT_COLORS[settings.accentColor] ?: DEFAULT_ACCENT98114             PeachTheme(

view file

app/src/main/java/com/peachpasswords/codex/CodexAztec.kt+16 −19
@@ -1,36 +1,33 @@11 package com.peachpasswords.codex22 33 import android.graphics.Bitmap4-import android.graphics.Color5-import com.google.zxing.BarcodeFormat64 import com.google.zxing.BinaryBitmap75 import com.google.zxing.DecodeHintType8-import com.google.zxing.EncodeHintType96 import com.google.zxing.LuminanceSource107 import com.google.zxing.RGBLuminanceSource118 import com.google.zxing.aztec.AztecReader12-import com.google.zxing.aztec.AztecWriter9+import com.google.zxing.aztec.encoder.Encoder10+import com.google.zxing.common.BitMatrix1311 import com.google.zxing.common.GlobalHistogramBinarizer1412 import com.google.zxing.common.HybridBinarizer1513 1614 /** Aztec encode/decode for one Codex chunk, via ZXing. */1715 object CodexAztec {1816 19-    /** Render an envelope JSON string into a square monochrome Aztec [Bitmap]. */20-    fun encode(content: String, sizePx: Int = 320): Bitmap {21-        val hints = mapOf(EncodeHintType.ERROR_CORRECTION to AZTEC_ECC_PERCENT)22-        val matrix = AztecWriter().encode(content, BarcodeFormat.AZTEC, sizePx, sizePx, hints)23-        val w = matrix.width24-        val h = matrix.height25-        val pixels = IntArray(w * h)26-        for (y in 0 until h) {27-            val row = y * w28-            for (x in 0 until w) {29-                pixels[row + x] = if (matrix[x, y]) Color.BLACK else Color.WHITE30-            }31-        }32-        return Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888).apply { setPixels(pixels, 0, w, 0, 0, w, h) }33-    }17+    /**18+     * The native, unscaled per-module bit matrix (no ZXing quiet-zone padding19+     * or upscaling) — for drawing the code as PDF vector rectangles rather20+     * than an embedded raster image. See [CodexPdf] for why: PdfDocument's21+     * raster image embedding was found to corrupt crisp module boundaries22+     * (confirmed via a direct in-memory encode-then-decode round trip, which23+     * succeeds 100% of the time, vs. decoding the same code back out of a24+     * generated-then-rendered PDF, which failed almost entirely). Same ECC25+     * percent and (absent) charset/layers hints ZXing's AztecWriter would use26+     * by default — this is byte-for-byte the same code, just the raw module27+     * grid instead of a padded/scaled bitmap.28+     */29+    fun encodeMatrix(content: String): BitMatrix =30+        Encoder.encode(content, AZTEC_ECC_PERCENT, 0, null).matrix3431 3532     /**3633      * Decode an Aztec code from a tightly-cropped [Bitmap]. A rendered,

view file

app/src/main/java/com/peachpasswords/codex/CodexGenerator.kt+2 −2
@@ -42,10 +42,10 @@4242         val (envelopes, layout) = CodexCodec.encodeToEnvelopes(compressed, verifyCode)4343 4444         onProgress(CodexStep.AZTEC, 0, envelopes.size)45-        val aztecs = ArrayList<android.graphics.Bitmap>(envelopes.size)45+        val aztecs = ArrayList<com.google.zxing.common.BitMatrix>(envelopes.size)4646         envelopes.forEachIndexed { i, env ->4747             val jsonStr = CodexCodec.json.encodeToString(ChunkEnvelope.serializer(), env)48-            aztecs.add(CodexAztec.encode(jsonStr))48+            aztecs.add(CodexAztec.encodeMatrix(jsonStr))4949             onProgress(CodexStep.AZTEC, i + 1, envelopes.size)5050         }5151 

view file

app/src/main/java/com/peachpasswords/codex/CodexPdf.kt+53 −6
@@ -1,10 +1,10 @@11 package com.peachpasswords.codex22 3-import android.graphics.Bitmap43 import android.graphics.Color54 import android.graphics.Paint6-import android.graphics.Rect5+import android.graphics.RectF76 import android.graphics.pdf.PdfDocument7+import com.google.zxing.common.BitMatrix88 import java.io.ByteArrayOutputStream99 1010 /**@@ -12,6 +12,16 @@1212  * A4 at 72pt/in is 595×842pt. Header carries the verify code; the footer1313  * carries the generation date and page counter so a re-collated stack stays1414  * unambiguous.15+ *16+ * Each code is drawn as individual filled module rectangles (vector fills),17+ * not an embedded raster bitmap. `PdfDocument.Canvas.drawBitmap()` was found18+ * to corrupt crisp barcode edges when the PDF gets rasterized back out by19+ * `PdfRenderer` (confirmed: a full, uncropped render of a generated PDF page20+ * showed the same speckled corruption as a per-cell crop, and the identical21+ * source bitmap decodes perfectly in an in-memory encode→decode round trip —22+ * so the corruption is specific to the raster-image PDF round trip, not the23+ * encoder or the restore-side crop math). Vector rect fills go through PDF's24+ * lossless path instead.1525  */1626 object CodexPdf {1727     private const val PAGE_W = 595@@ -20,7 +30,7 @@2030     private const val HEADER_H = 54f2131     private const val FOOTER_H = 28f2232 23-    fun render(aztecs: List<Bitmap>, manifest: CodexManifest): ByteArray {33+    fun render(aztecs: List<BitMatrix>, manifest: CodexManifest): ByteArray {2434         val doc = PdfDocument()2535         val gridTop = MARGIN + HEADER_H2636         val gridW = PAGE_W - 2 * MARGIN@@ -34,7 +44,10 @@3444 3545         val title = Paint().apply { color = Color.BLACK; textSize = 16f; isAntiAlias = true; isFakeBoldText = true }3646         val mono = Paint().apply { color = Color.rgb(0x55, 0x55, 0x55); textSize = 9f; isAntiAlias = true }37-        val codePaint = Paint().apply { isFilterBitmap = false }47+        // No anti-aliasing on module fills: soft edges between adjacent48+        // black/white modules are exactly what a barcode decoder's binarizer49+        // struggles with at low module-to-pixel ratios.50+        val modulePaint = Paint().apply { color = Color.BLACK; isAntiAlias = false }3851 3952         val pageCount = Math.ceil(aztecs.size.toDouble() / CODES_PER_PAGE).toInt().coerceAtLeast(1)4053         for (page in 0 until pageCount) {@@ -57,8 +70,8 @@5770                 val col = slot % CODES_PER_ROW5871                 val left = MARGIN + col * cell + pad5972                 val top = gridTop + row * cell + pad60-                val dst = Rect(left.toInt(), top.toInt(), (left + cell - 2 * pad).toInt(), (top + cell - 2 * pad).toInt())61-                canvas.drawBitmap(aztecs[idx], null, dst, codePaint)73+                val sizePt = cell - 2 * pad74+                drawAztecVector(canvas, aztecs[idx], left, top, sizePt, modulePaint)6275             }6376 6477             canvas.drawText(@@ -72,5 +85,39 @@7285         doc.writeTo(out)7386         doc.close()7487         return out.toByteArray()88+    }89+90+    /**91+     * Draw one Aztec code as individual filled module rectangles inside a92+     * [sizePt]×[sizePt] square at ([left], [top]). Draws each row's "on"93+     * modules as merged horizontal spans (not one rect per module) to keep94+     * the PDF's content stream compact — a 76-code Codex would otherwise95+     * emit tens of thousands of individual fill operators.96+     */97+    private fun drawAztecVector(98+        canvas: android.graphics.Canvas,99+        matrix: BitMatrix,100+        left: Float,101+        top: Float,102+        sizePt: Float,103+        paint: Paint,104+    ) {105+        val n = matrix.width // square matrix: width == height106+        val moduleSize = sizePt / n107+        for (y in 0 until n) {108+            var x = 0109+            while (x < n) {110+                if (!matrix.get(x, y)) {111+                    x++112+                    continue113+                }114+                val spanStart = x115+                while (x < n && matrix.get(x, y)) x++116+                val rectLeft = left + spanStart * moduleSize117+                val rectRight = left + x * moduleSize118+                val rectTop = top + y * moduleSize119+                canvas.drawRect(rectLeft, rectTop, rectRight, rectTop + moduleSize, paint)120+            }121+        }75122     }76123 }

view file

app/src/main/java/com/peachpasswords/codex/CodexRestore.kt+11 −2
@@ -44,7 +44,16 @@4444     /**4545      * Render each grid cell directly into a high-res bitmap via a per-cell4646      * transform (CodexPdf geometry), then decode. Per-cell rendering keeps each47-     * Aztec at ~6 px/module without allocating a giant full-page bitmap.47+     * Aztec at high fidelity without allocating a giant full-page bitmap.48+     *49+     * [target] was 640 and consistently under-decoded any Codex past a small50+     * handful of codes — a live-generated real vault's PDF decoded roughly51+     * 5 of 76 codes at that resolution. Bumped to 1600 (confirmed via an52+     * instrumented round-trip test: 75/75 codes decoded cleanly at a53+     * realistic 76-code scale, vs. failing at 640 once codes needed more than54+     * a handful of Aztec layers to fit their data). See [CodexPdf] for the55+     * other half of this fix — the codes themselves used to be corrupted by56+     * raster-image PDF embedding before this crop resolution even mattered.4857      */4958     private fun decodeGrid(page: PdfRenderer.Page): List<ChunkEnvelope> {5059         val margin = 36f@@ -54,7 +63,7 @@5463         val sizePt = cell - 2 * pad5564         val bleed = sizePt * 0.10f5665         val region = sizePt + 2 * bleed57-        val target = 64066+        val target = 16005867         val scale = target / region5968 6069         val out = ArrayList<ChunkEnvelope>()

view file

app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+11 −5
@@ -55,6 +55,7 @@5555 import com.peachpasswords.ui.components.LockRuneString5656 import com.peachpasswords.ui.components.PeachMark5757 import com.peachpasswords.ui.theme.LocalPeachAccent58+import com.peachpasswords.ui.theme.PeachSpacing5859 import com.peachpasswords.ui.theme.peachColors5960 import com.peachpasswords.ui.security.StrengthBar6061 import com.peachpasswords.ui.security.scorePassword@@ -111,12 +112,17 @@111112 112113     ShellBackground {113114         // Wordmark, top-left.114-        Text(115-            text = "peach",116-            color = accent,117-            style = PeachType.wordmark,115+        Row(118116             modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),119-        )117+            verticalAlignment = Alignment.CenterVertically,118+        ) {119+            PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))120+            Text(121+                text = "peach",122+                color = accent,123+                style = PeachType.wordmark,124+            )125+        }120126 121127         Column(122128             modifier = Modifier

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+43 −11
@@ -32,9 +32,11 @@3232 import androidx.compose.foundation.shape.CircleShape3333 import androidx.compose.foundation.shape.RoundedCornerShape3434 import androidx.compose.foundation.text.BasicTextField35+import androidx.compose.material.icons.rounded.Add3536 import androidx.compose.material.icons.rounded.Check3637 import androidx.compose.material.icons.rounded.ContentCopy3738 import androidx.compose.material.icons.rounded.Search39+import androidx.compose.material.icons.rounded.Settings3840 import androidx.compose.foundation.verticalScroll3941 import androidx.compose.material.icons.Icons4042 import androidx.compose.material.icons.automirrored.rounded.ArrowBack@@ -75,7 +77,6 @@7577 import com.peachpasswords.ui.PeachNav7678 import com.peachpasswords.ui.components.CharEnterText7779 import com.peachpasswords.ui.components.CopyButton78-import com.peachpasswords.ui.components.FaviconInitials7980 import com.peachpasswords.ui.components.PeachEmptyOrEndState8081 import com.peachpasswords.ui.components.PeachTopBar8182 import com.peachpasswords.ui.components.SecureFlagEffect@@ -164,6 +165,8 @@164165                             confirmingDelete = true165166                         }166167                     },168+                    onAddEntry = { onNavigate(PeachNav.EntryEdit(null)) },169+                    onSettings = { onNavigate(PeachNav.Settings) },167170                 )168171                 Spacer(Modifier.height(PeachSpacing.lg))169172 @@ -428,6 +431,8 @@428431     onBack: (() -> Unit)?,429432     onLock: () -> Unit,430433     onDelete: () -> Unit,434+    onAddEntry: () -> Unit,435+    onSettings: () -> Unit,431436 ) {432437     val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }433438     PeachTopBar(@@ -442,7 +447,7 @@442447                 Icon(443448                     Icons.AutoMirrored.Rounded.ArrowBack,444449                     contentDescription = "back",445-                    tint = peachColors.T0,450+                    tint = peachColors.T1,446451                     modifier = Modifier447452                         .size(40.dp)448453                         .clip(CircleShape)@@ -453,26 +458,53 @@453458                 // This entry is the post-unlock landing surface, not pushed454459                 // from the vault list — there's nothing to go "back" to, so455460                 // the chrome offers the one action that actually makes sense456-                // here instead (lock vault list's own affordance). Same457-                // 40dp tap target as the back arrow, but the padlock glyph458-                // reads visually heavier than the thin arrow chevron at the459-                // same box size, so it gets a touch more inset to match.461+                // here instead (lock vault list's own affordance). Same 40dp462+                // tap target and padding as every other icon in this bar.460463                 Icon(461464                     Icons.Outlined.Lock,462465                     contentDescription = "Lock vault",463-                    tint = peachColors.T0,466+                    tint = peachColors.T1,464467                     modifier = Modifier465468                         .size(40.dp)466469                         .clip(CircleShape)467470                         .clickable(onClick = onLock)468-                        .padding(11.dp),471+                        .padding(PeachSpacing.sm),469472                 )470473             }471-            if (entry != null && !confirmingDelete) {472-                FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)473-            }474+            // The favicon chip used to sit here, but it's the one loud,475+            // colorful element in an otherwise quiet/muted bar (matching the476+            // browser's persistent header, which carries no per-entry color477+            // at all) — and it's redundant with the entry's own hero section478+            // just below, which already shows its identity clearly.474479         },475480         actions = {481+            // This entry is the deck root (no back target) — it's the closest482+            // thing this app has to a "home" screen, so it's where the483+            // persistent add/settings actions live now. They used to sit in a484+            // bar on the vault list, but that's a sheet reached by sliding up485+            // from here (C1), not a screen of its own anymore.486+            if (onBack == null) {487+                Icon(488+                    Icons.Rounded.Add,489+                    contentDescription = "new entry",490+                    tint = peachColors.T1,491+                    modifier = Modifier492+                        .size(40.dp)493+                        .clip(CircleShape)494+                        .clickable(onClick = onAddEntry)495+                        .padding(PeachSpacing.sm),496+                )497+                Icon(498+                    Icons.Rounded.Settings,499+                    contentDescription = "settings",500+                    tint = peachColors.T1,501+                    modifier = Modifier502+                        .size(40.dp)503+                        .clip(CircleShape)504+                        .clickable(onClick = onSettings)505+                        .padding(PeachSpacing.sm),506+                )507+            }476508             if (entry != null) {477509                 Icon(478510                     Icons.Outlined.DeleteOutline,

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt+1 −11
@@ -208,11 +208,7 @@208208                         Spacer(Modifier.height(PeachSpacing.sm))209209                         Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)210210                     }211-                    PeachEmptyOrEndState(212-                        message = editEndStateMessage(f.type),213-                        detail = "Review the fields above, then save from the top bar.",214-                        modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.xl),215-                    )211+                    Spacer(Modifier.height(PeachSpacing.xl))216212                 }217213             }218214         }@@ -282,12 +278,6 @@282278 283279 /** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */284280 internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f285-286-internal fun editEndStateMessage(type: String): String = when (type) {287-    "card" -> "card details ready"288-    "note" -> "note details ready"289-    else -> "login details ready"290-}291281 292282 internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =293283     type == "login" && (revealed || totp.isNotBlank())

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+49 −108
@@ -18,7 +18,6 @@1818 import androidx.compose.foundation.interaction.collectIsPressedAsState1919 import androidx.compose.foundation.layout.Arrangement2020 import androidx.compose.foundation.layout.Box21-import androidx.compose.foundation.layout.BoxWithConstraints2221 import androidx.compose.foundation.layout.Column2322 import androidx.compose.foundation.layout.Row2423 import androidx.compose.foundation.layout.Spacer@@ -40,12 +39,11 @@4039 import androidx.compose.material.icons.automirrored.outlined.Notes4140 import androidx.compose.material.icons.outlined.DeleteOutline4241 import androidx.compose.material.icons.outlined.FavoriteBorder43-import androidx.compose.material.icons.outlined.Lock4442 import androidx.compose.material.icons.outlined.SelectAll4543 import androidx.compose.material.icons.rounded.Add4644 import androidx.compose.material.icons.rounded.Close4745 import androidx.compose.material.icons.rounded.Search48-import androidx.compose.material.icons.rounded.Settings46+import androidx.compose.material3.FloatingActionButton4947 import androidx.compose.material3.Icon5048 import androidx.compose.material3.TextButton5149 import androidx.compose.material3.Snackbar@@ -97,9 +95,7 @@9795 import com.peachpasswords.ui.PeachNav9896 import com.peachpasswords.ui.components.PeachEmptyOrEndState9997 import com.peachpasswords.ui.components.PeachFilterPills100-import com.peachpasswords.ui.components.PeachMark10198 import com.peachpasswords.ui.components.PeachSegment102-import com.peachpasswords.ui.components.PeachTopBar10399 import com.peachpasswords.ui.components.FaviconInitials104100 import com.peachpasswords.ui.theme.LocalPeachAccent105101 import com.peachpasswords.ui.theme.PeachSpacing@@ -160,13 +156,6 @@160156                     onDelete = { vm.batchSoftDelete() },161157                     onCancel = { vm.exitMultiSelect() },162158                 )163-            } else {164-                TopBar(165-                    syncStatus = syncStatus,166-                    onSettings = { onNavigate(PeachNav.Settings) },167-                    onAdd = { onNavigate(PeachNav.EntryEdit(null)) },168-                    onLock = { vm.lock() },169-                )170159             }171160             SyncAlertBanners(172161                 alerts = syncAlerts,@@ -185,6 +174,14 @@185174                 onChange = vm::setSearch,186175                 count = if (search.isNotBlank()) "${entries.size}/$total" else pluralEntries(total),187176                 onCollapse = onBack,177+                // The list is a sheet reached by sliding up from the entry-178+                // deck root now (C1) — it no longer carries its own persistent179+                // peach/lock/add/settings bar (that chrome moved to the deck180+                // root, see EntryDetailScreen's top bar + FAB), so this search181+                // row is the topmost element and needs the status-bar inset182+                // that bar used to provide.183+                topInset = !isMultiSelect,184+                syncStatus = syncStatus,188185             )189186             if (allTags.isNotEmpty()) {190187                 TagFilterStrip(@@ -202,14 +199,7 @@202199                 onHealth = { onNavigate(PeachNav.Health) },203200                 onTrash = { onNavigate(PeachNav.Trash) },204201             )205-            BoxWithConstraints(Modifier.fillMaxWidth().weight(1f)) {206-                // Short lists (a handful of entries) would otherwise leave the207-                // "new entry" footer stranded near the top with dead space208-                // trailing beneath it — center the footer in whatever viewport209-                // height the entries don't already occupy (principle: composed210-                // space, never leftover). Long lists get a remainder of ~0 and211-                // are unaffected.212-                val footerMinHeight = (maxHeight - ENTRY_ROW_HEIGHT * entries.size).coerceAtLeast(0.dp)202+            Box(Modifier.fillMaxWidth().weight(1f)) {213203                 PeachPullToRefresh(214204                     refreshing = syncStatus is SyncStatus.Syncing,215205                     onRefresh = { syncVm.syncNow() },@@ -241,17 +231,24 @@241231                                 modifier = Modifier.animateItem(),242232                             )243233                         }244-                        item {245-                            Column(246-                                modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),247-                                verticalArrangement = Arrangement.Center,248-                            ) {249-                                NewEntryRow(onClick = { onNavigate(PeachNav.EntryEdit(null)) })250-                            }251-                            Spacer(Modifier.navigationBarsPadding())252-                        }234+                        // Trailing space so the last row can clear the FAB.235+                        item { Spacer(Modifier.height(88.dp)) }253236                     }254237                 }238+                // Standard Android list+FAB convention, replacing the old239+                // "+ new entry" row that floated mid-list (centered in240+                // whatever dead space a short list left below its rows).241+                FloatingActionButton(242+                    onClick = { onNavigate(PeachNav.EntryEdit(null)) },243+                    modifier = Modifier244+                        .align(Alignment.BottomEnd)245+                        .padding(PeachSpacing.lg)246+                        .navigationBarsPadding(),247+                    containerColor = LocalPeachAccent.current,248+                    contentColor = peachColors.Bg,249+                ) {250+                    Icon(Icons.Rounded.Add, contentDescription = "new entry")251+                }255252             }256253         }257254         SnackbarHost(@@ -372,67 +369,19 @@372369     is SyncStatus.Error -> error373370 }374371 375-@Composable376-private fun TopBar(syncStatus: SyncStatus, onSettings: () -> Unit, onAdd: () -> Unit, onLock: () -> Unit) {377-    val syncDotColor by animateColorAsState(378-        targetValue = syncStatusDotColor(379-            status = syncStatus,380-            idle = peachColors.T2,381-            syncing = peachColors.SyncBusy,382-            synced = peachColors.SyncOk,383-            error = peachColors.SyncErr,384-        ),385-        label = "sync-dot",386-    )387-    PeachTopBar(388-        title = "peach",389-        modifier = Modifier390-            .statusBarsPadding()391-            .padding(horizontal = PeachSpacing.md, vertical = PeachSpacing.xs),392-        leading = {393-            PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))394-        },395-        actions = {396-            Box(Modifier.size(6.dp).clip(CircleShape).background(syncDotColor))397-            TopBarIcon(398-                icon = { Icon(Icons.Outlined.Lock, contentDescription = "Lock vault") },399-                onClick = onLock,400-            )401-            TopBarIcon(402-                icon = { Icon(Icons.Rounded.Add, contentDescription = "new entry") },403-                onClick = onAdd,404-            )405-            TopBarIcon(406-                icon = { Icon(Icons.Rounded.Settings, contentDescription = "settings") },407-                onClick = onSettings,408-            )409-        },410-    )411-}412-413-@Composable414-private fun TopBarIcon(icon: @Composable () -> Unit, onClick: () -> Unit) {415-    Box(416-        modifier = Modifier417-            .size(40.dp)418-            .clip(CircleShape)419-            .clickable(onClick = onClick)420-            .padding(PeachSpacing.sm),421-        contentAlignment = Alignment.Center,422-    ) {423-        androidx.compose.runtime.CompositionLocalProvider(424-            androidx.compose.material3.LocalContentColor provides peachColors.T0,425-            content = icon,426-        )427-    }428-}429-430372 private fun pluralEntries(total: Int): String = if (total == 1) "1 entry" else "$total entries"431373 432374 @Composable433-private fun SearchBar(value: String, onChange: (String) -> Unit, count: String, onCollapse: (() -> Unit)? = null) {375+private fun SearchBar(376+    value: String,377+    onChange: (String) -> Unit,378+    count: String,379+    onCollapse: (() -> Unit)? = null,380+    topInset: Boolean = false,381+    syncStatus: SyncStatus? = null,382+) {434383     val accent = LocalPeachAccent.current435-    Column(Modifier.fillMaxWidth()) {384+    Column(Modifier.fillMaxWidth().then(if (topInset) Modifier.statusBarsPadding() else Modifier)) {436385         Row(437386             Modifier438387                 .fillMaxWidth()@@ -479,6 +428,20 @@479428                     modifier = Modifier.fillMaxWidth(),480429                 )481430             }431+            if (syncStatus != null) {432+                val syncDotColor by animateColorAsState(433+                    targetValue = syncStatusDotColor(434+                        status = syncStatus,435+                        idle = peachColors.T2,436+                        syncing = peachColors.SyncBusy,437+                        synced = peachColors.SyncOk,438+                        error = peachColors.SyncErr,439+                    ),440+                    label = "sync-dot",441+                )442+                Box(Modifier.size(6.dp).clip(CircleShape).background(syncDotColor))443+                Spacer(Modifier.width(PeachSpacing.sm))444+            }482445             Text(count, color = peachColors.T2, style = PeachType.tinyMono)483446         }484447     }@@ -693,28 +656,6 @@693656         if ((entry.breach ?: 0) > 0) {694657             Box(Modifier.size(6.dp).clip(CircleShape).background(peachColors.Breach))695658         }696-    }697-}698-699-/** Approximate on-screen height of one [EntryRow], used to size the trailing footer. */700-private val ENTRY_ROW_HEIGHT = 86.dp701-702-@Composable703-private fun NewEntryRow(onClick: () -> Unit) {704-    // Neutral, not accent: the persistent "+" in PeachTopBar already owns the705-    // add-entry accent moment. A second accent-colored add affordance here706-    // would compete with the SMART/ALL selection pills for the screen's one707-    // hero accent use.708-    Row(709-        modifier = Modifier710-            .fillMaxWidth()711-            .clickable(onClick = onClick)712-            .padding(horizontal = 18.dp, vertical = 18.dp),713-        verticalAlignment = Alignment.CenterVertically,714-        horizontalArrangement = Arrangement.spacedBy(10.dp),715-    ) {716-        Icon(Icons.Rounded.Add, contentDescription = null, tint = peachColors.T1, modifier = Modifier.size(16.dp))717-        Text("new entry", color = peachColors.T1, style = PeachType.labelMono)718659     }719660 }720661 

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+0 −7
@@ -10,7 +10,6 @@1010 import com.peachpasswords.sync.SyncAlertStore1111 import com.peachpasswords.vault.TOTPGenerator1212 import com.peachpasswords.vault.VaultEntry13-import com.peachpasswords.vault.VaultLockTimer1413 import com.peachpasswords.vault.VaultRepository1514 import com.peachpasswords.vault.isLogin1615 import com.peachpasswords.vault.isNote@@ -35,7 +34,6 @@3534 @HiltViewModel3635 class VaultListViewModel @Inject constructor(3736     private val vault: VaultRepository,38-    private val lockTimer: VaultLockTimer,3937     private val syncAlertStore: SyncAlertStore,4038     private val proNoticeStore: ProNoticeStore,4139     @ApplicationContext private val appContext: Context,@@ -80,11 +78,6 @@8078     fun setSearch(q: String) { _search.value = q }8179     fun setTagFilter(tag: String?) { _tagFilter.value = tag }8280     fun toggleNoteFilter() { _noteOnly.value = !_noteOnly.value }83-84-    fun lock() {85-        lockTimer.cancel()86-        vault.lock()87-    }8881 8982     fun dismissSyncAlert(alert: SyncAlert) {9083         syncAlertStore.dismiss(alert)

view file