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
↓ peach-android-c6e41d31.tar.gzatlas at this save
files changed11 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 −7app/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+}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(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,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 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 }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>()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 = Modifierapp/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,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())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 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)