a175832dabe6

feat: Complete Notes parity flow (v2.3.19)

- Add browser-style notes TOC/search/new-note phase before the editor - Keep editor back inside Notes mode and expand toolbar block actions - Verify Notes tests and full Gradle gate

time
2026-06-26 07:50 UTC · 4 months ago
machine
koh2
parent
eb3db0cf92c2
4 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt+199 −5app/src/main/java/com/peachpasswords/ui/vault/NotesModeViewModel.kt+43 −3app/src/test/java/com/peachpasswords/ui/vault/NotesModeTest.kt+54 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23180020-        versionName "2.3.18"19+        versionCode 23190020+        versionName "2.3.19"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt+199 −5
@@ -17,12 +17,17 @@1717 import androidx.compose.foundation.layout.size1818 import androidx.compose.foundation.layout.statusBarsPadding1919 import androidx.compose.foundation.layout.width20+import androidx.compose.foundation.lazy.LazyColumn21+import androidx.compose.foundation.lazy.items2022 import androidx.compose.foundation.shape.CircleShape2123 import androidx.compose.foundation.shape.RoundedCornerShape2224 import androidx.compose.foundation.text.BasicTextField2325 import androidx.compose.material.icons.Icons2426 import androidx.compose.material.icons.automirrored.rounded.ArrowBack27+import androidx.compose.material.icons.outlined.Add28+import androidx.compose.material.icons.outlined.Close2529 import androidx.compose.material.icons.outlined.DeleteOutline30+import androidx.compose.material.icons.outlined.Search2631 import androidx.compose.material3.Icon2732 import androidx.compose.material3.Text2833 import androidx.compose.runtime.Composable@@ -40,6 +45,7 @@4045 import androidx.compose.ui.text.AnnotatedString4146 import androidx.compose.ui.text.SpanStyle4247 import androidx.compose.ui.text.TextStyle48+import androidx.compose.ui.text.TextRange4349 import androidx.compose.ui.text.buildAnnotatedString4450 import androidx.compose.ui.text.font.FontStyle4551 import androidx.compose.ui.text.font.FontWeight@@ -51,6 +57,7 @@5157 import androidx.compose.runtime.collectAsState5258 import androidx.fragment.app.FragmentActivity5359 import androidx.hilt.navigation.compose.hiltViewModel60+import com.peachpasswords.vault.VaultEntry5461 import com.peachpasswords.ui.theme.LocalPeachAccent5562 import com.peachpasswords.ui.theme.peachColors5663 import com.peachpasswords.ui.theme.PeachType@@ -72,6 +79,8 @@7279     vm: NotesModeViewModel = hiltViewModel(),7380 ) {7481     val entry by vm.entry.collectAsState()82+    val notes by vm.notes.collectAsState()83+    val activeNoteId by vm.activeNoteId.collectAsState()7584     val savedPulse by vm.savedPulse.collectAsState()7685     val accent = LocalPeachAccent.current7786     val context = LocalContext.current@@ -83,8 +92,6 @@8392         onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }8493     }8594 86-    val e = entry87-8895     // "saved." indicator fades in on each save pulse, holds, then fades out.8996     var showSaved by remember { mutableStateOf(false) }9097     LaunchedEffect(savedPulse) {@@ -99,6 +106,17 @@99106         label = "saved-fade",100107     )101108 109+    if (activeNoteId == null) {110+        NotesToc(111+            notes = notes,112+            onBack = onBack,113+            onOpenNote = vm::openNote,114+            onNewNote = vm::createNote,115+        )116+        return117+    }118+119+    val e = entry102120     var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }103121     var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) }104122 @@ -112,7 +130,7 @@112130                 Icon(113131                     Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,114132                     modifier = Modifier.size(40.dp).clip(CircleShape)115-                        .clickable { vm.saveNow(title, body.text); onBack() }.padding(8.dp),133+                        .clickable { vm.saveNow(title, body.text); vm.showToc() }.padding(8.dp),116134                 )117135                 Box(Modifier.weight(1f).padding(horizontal = 4.dp)) {118136                     if (title.isEmpty()) {@@ -147,7 +165,7 @@147165                     contentDescription = "delete note",148166                     tint = peachColors.T2,149167                     modifier = Modifier.size(36.dp).clip(CircleShape)150-                        .clickable { vm.delete(onBack) }168+                        .clickable { vm.delete {} }151169                         .padding(8.dp),152170                 )153171             }@@ -159,7 +177,7 @@159177                 return@Column160178             }161179 162-            // Formatting toolbar: B / I / U — apply span to current selection.180+            // Formatting toolbar: browser-compatible inline and block actions.163181             Row(164182                 Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),165183                 horizontalArrangement = Arrangement.spacedBy(6.dp),@@ -168,6 +186,11 @@168186                 FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) }169187                 FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) }170188                 FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) }189+                Spacer(Modifier.width(4.dp))190+                FormatButton("H") { body = applyLinePrefix(body, "## ") }191+                FormatButton("\"") { body = applyLinePrefix(body, "> ") }192+                FormatButton("•") { body = applyLinePrefix(body, "- ") }193+                FormatButton("1.") { body = applyLinePrefix(body, "1. ") }171194             }172195 173196             // Content area.@@ -191,6 +214,142 @@191214     }192215 }193216 217+@Composable218+private fun NotesToc(219+    notes: List<VaultEntry>,220+    onBack: () -> Unit,221+    onOpenNote: (String) -> Unit,222+    onNewNote: () -> Unit,223+) {224+    var searchVisible by remember { mutableStateOf(false) }225+    var query by remember { mutableStateOf("") }226+    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }227+    val accent = LocalPeachAccent.current228+229+    ShellBackground {230+        Column(Modifier.fillMaxSize().statusBarsPadding()) {231+            Row(232+                Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),233+                verticalAlignment = Alignment.CenterVertically,234+            ) {235+                if (searchVisible) {236+                    BasicTextField(237+                        value = query,238+                        onValueChange = { query = it },239+                        singleLine = true,240+                        textStyle = TextStyle(241+                            color = peachColors.T0,242+                            fontFamily = PeachType.Mono,243+                            fontSize = 14.sp,244+                        ),245+                        cursorBrush = SolidColor(accent),246+                        modifier = Modifier.weight(1f).padding(horizontal = 12.dp),247+                        decorationBox = { inner ->248+                            Box {249+                                if (query.isEmpty()) {250+                                    Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)251+                                }252+                                inner()253+                            }254+                        },255+                    )256+                    Icon(257+                        Icons.Outlined.Close,258+                        contentDescription = "clear search",259+                        tint = peachColors.T0,260+                        modifier = Modifier.size(40.dp).clip(CircleShape)261+                            .clickable {262+                                query = ""263+                                searchVisible = false264+                            }265+                            .padding(9.dp),266+                    )267+                } else {268+                    Icon(269+                        Icons.AutoMirrored.Rounded.ArrowBack,270+                        contentDescription = "back to vault",271+                        tint = peachColors.T0,272+                        modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onBack).padding(8.dp),273+                    )274+                    Text(275+                        "notes.",276+                        color = peachColors.T0,277+                        style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp),278+                        modifier = Modifier.weight(1f).padding(horizontal = 8.dp),279+                    )280+                    Icon(281+                        Icons.Outlined.Search,282+                        contentDescription = "search notes",283+                        tint = peachColors.T0,284+                        modifier = Modifier.size(40.dp).clip(CircleShape)285+                            .clickable { searchVisible = true }286+                            .padding(9.dp),287+                    )288+                }289+            }290+291+            LazyColumn(292+                Modifier.weight(1f).padding(horizontal = 16.dp),293+                verticalArrangement = Arrangement.spacedBy(8.dp),294+            ) {295+                if (filtered.isEmpty()) {296+                    item {297+                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {298+                            Text(299+                                if (query.isBlank()) "No notes yet.\nStart writing." else "No notes match.",300+                                color = peachColors.T1,301+                                style = PeachType.bodyMono,302+                            )303+                        }304+                    }305+                } else {306+                    items(filtered, key = { it.id }) { note ->307+                        NoteTocRow(note = note, onClick = { onOpenNote(note.id) })308+                    }309+                }310+            }311+312+            Row(313+                Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp)314+                    .clip(RoundedCornerShape(8.dp))315+                    .background(peachColors.S2)316+                    .clickable(onClick = onNewNote)317+                    .padding(horizontal = 14.dp, vertical = 12.dp),318+                verticalAlignment = Alignment.CenterVertically,319+                horizontalArrangement = Arrangement.spacedBy(10.dp),320+            ) {321+                Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))322+                Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)323+            }324+        }325+    }326+}327+328+@Composable329+private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {330+    Column(331+        Modifier.fillMaxWidth()332+            .clip(RoundedCornerShape(8.dp))333+            .background(peachColors.S1)334+            .clickable(onClick = onClick)335+            .padding(horizontal = 14.dp, vertical = 12.dp),336+        verticalArrangement = Arrangement.spacedBy(5.dp),337+    ) {338+        Text(339+            note.title.ifBlank { "Untitled" },340+            color = peachColors.T0,341+            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),342+        )343+        val preview = note.notes?.lineSequence()?.firstOrNull { it.isNotBlank() }?.trim().orEmpty()344+        Text(345+            preview.ifBlank { "No body text" },346+            color = peachColors.T2,347+            style = PeachType.bodyMono,348+            maxLines = 2,349+        )350+    }351+}352+194353 /**195354  * Apply [style] to the current selection of [value], preserving the text and196355  * existing spans. If the selection is collapsed, the value is returned unchanged@@ -207,6 +366,41 @@207366         addStyle(style, start, end)208367     }209368     return value.copy(annotatedString = merged)369+}370+371+internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {372+    val text = value.text373+    val sel = value.selection374+    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }375+    val endExclusive = if (sel.collapsed) {376+        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }377+    } else {378+        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }379+    }380+    val block = text.substring(start, endExclusive)381+    val transformed = block382+        .split('\n')383+        .joinToString("\n") { line ->384+            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line385+        }386+    val nextText = text.replaceRange(start, endExclusive, transformed)387+    val delta = nextText.length - text.length388+    return value.copy(389+        text = nextText,390+        selection = TextRange(391+            sel.start.coerceIn(0, nextText.length),392+            (sel.end + delta).coerceIn(0, nextText.length),393+        ),394+    )395+}396+397+internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {398+    val q = query.trim().lowercase()399+    if (q.isBlank()) return notes400+    return notes.filter { note ->401+        note.title.lowercase().contains(q) ||402+            note.notes.orEmpty().lowercase().contains(q)403+    }210404 }211405 212406 @Composable

view file

app/src/main/java/com/peachpasswords/ui/vault/NotesModeViewModel.kt+43 −3
@@ -5,6 +5,7 @@55 import androidx.lifecycle.viewModelScope66 import com.peachpasswords.vault.VaultEntry77 import com.peachpasswords.vault.VaultRepository8+import com.peachpasswords.vault.isNote89 import dagger.hilt.android.lifecycle.HiltViewModel910 import kotlinx.coroutines.Job1011 import kotlinx.coroutines.delay@@ -12,6 +13,7 @@1213 import kotlinx.coroutines.flow.SharingStarted1314 import kotlinx.coroutines.flow.StateFlow1415 import kotlinx.coroutines.flow.asStateFlow16+import kotlinx.coroutines.flow.combine1517 import kotlinx.coroutines.flow.map1618 import kotlinx.coroutines.flow.stateIn1719 import kotlinx.coroutines.launch@@ -32,11 +34,21 @@3234     savedStateHandle: SavedStateHandle,3335 ) : ViewModel() {3436     private val id: String = savedStateHandle.get<String>("id").orEmpty()37+    private val _activeNoteId = MutableStateFlow(id.takeIf { it.isNotBlank() })3538 3639     val entry: StateFlow<VaultEntry?> =37-        vault.entries.map { list -> list.firstOrNull { it.id == id } }38-            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id))40+        combine(vault.entries, _activeNoteId) { list, activeId ->41+            activeId?.let { id -> list.firstOrNull { it.id == id && it.isNote() } }42+        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id)?.takeIf { it.isNote() })3943 44+    val notes: StateFlow<List<VaultEntry>> =45+        vault.entries.map { list ->46+            list.filter { it.isNote() }47+                .sortedByDescending { it.lastUsedAt ?: it.modifiedAt }48+        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntries().filter { it.isNote() })49+50+    val activeNoteId: StateFlow<String?> = _activeNoteId.asStateFlow()51+4052     private val _savedPulse = MutableStateFlow(0L)4153     /** Increments on each successful auto-save so the screen can re-trigger its fade. */4254     val savedPulse: StateFlow<Long> = _savedPulse.asStateFlow()@@ -60,6 +72,33 @@6072         commit { it.copy(title = title) }6173     }6274 75+    fun openNote(noteId: String) {76+        saveJob?.cancel()77+        _activeNoteId.value = noteId78+    }79+80+    fun showToc() {81+        saveJob?.cancel()82+        _activeNoteId.value = null83+    }84+85+    fun createNote() {86+        saveJob?.cancel()87+        viewModelScope.launch {88+            val created = vault.addEntry(89+                VaultEntry(90+                    id = "",91+                    type = "note",92+                    title = "Untitled",93+                    notes = null,94+                    createdAt = 0L,95+                    modifiedAt = 0L,96+                ),97+            )98+            _activeNoteId.value = created.id99+        }100+    }101+63102     /** Browser parity: back navigation commits both title and body before leaving. */64103     fun saveNow(title: String, plainText: String) {65104         saveJob?.cancel()@@ -73,7 +112,8 @@73112 74113     fun delete(onDone: () -> Unit) {75114         viewModelScope.launch {76-            runCatching { vault.deleteEntry(id) }115+            _activeNoteId.value?.let { runCatching { vault.deleteEntry(it) } }116+            _activeNoteId.value = null77117             onDone()78118         }79119     }

view file

app/src/test/java/com/peachpasswords/ui/vault/NotesModeTest.kt+54 −0
@@ -7,6 +7,8 @@77 import com.peachpasswords.vault.VaultRepository88 import com.peachpasswords.vault.VaultSettings99 import com.peachpasswords.vault.VaultStorage10+import androidx.compose.ui.text.TextRange11+import androidx.compose.ui.text.input.TextFieldValue1012 import kotlinx.coroutines.Dispatchers1113 import kotlinx.coroutines.ExperimentalCoroutinesApi1214 import kotlinx.coroutines.test.StandardTestDispatcher@@ -78,6 +80,28 @@7880         assertEquals(listOf("a", "b", "c", "d"), filterNotesOnly(list, noteOnly = false).map { it.title })7981     }8082 83+    @Test fun notes_toc_search_matches_title_and_body() {84+        val list = listOf(85+            note("Trip", "hotel ideas"),86+            note("Garden", "seed inventory"),87+            note("Work", "standup"),88+        )89+90+        assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title })91+        assertEquals(listOf("Garden"), filterNotesForToc(list, "gard").map { it.title })92+        assertEquals(listOf("Trip", "Garden", "Work"), filterNotesForToc(list, "  ").map { it.title })93+    }94+95+    @Test fun note_toolbar_block_prefix_toggles_selected_lines() {96+        val value = TextFieldValue("alpha\nbeta", selection = TextRange(0, 10))97+98+        val bulleted = applyLinePrefix(value, "- ")99+        assertEquals("- alpha\n- beta", bulleted.text)100+101+        val unbulleted = applyLinePrefix(bulleted.copy(selection = TextRange(0, bulleted.text.length)), "- ")102+        assertEquals("alpha\nbeta", unbulleted.text)103+    }104+81105     // ── Auto-save debounce (NotesModeViewModel) ───────────────────────────────82106 83107     @Test fun notes_auto_save_debounces_800ms() = runTest(dispatcher) {@@ -137,6 +161,36 @@137161 138162         assertEquals("Trip plans", repo.getEntry(saved.id)?.title)139163         assertEquals("final body", repo.getEntry(saved.id)?.notes)164+    }165+166+    @Test fun notes_editor_back_switches_to_toc_without_leaving_route() = runTest(dispatcher) {167+        val repo = VaultRepository(FakeStorage())168+        repo.createVault("correct horse battery")169+        val saved = repo.addEntry(note("open me"))170+        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))171+172+        assertEquals(saved.id, vm.activeNoteId.value)173+174+        vm.showToc()175+        advanceUntilIdle()176+        assertNull(vm.activeNoteId.value)177+178+        vm.openNote(saved.id)179+        advanceUntilIdle()180+        assertEquals(saved.id, vm.activeNoteId.value)181+    }182+183+    @Test fun notes_toc_new_note_creates_and_opens_note() = runTest(dispatcher) {184+        val repo = VaultRepository(FakeStorage())185+        repo.createVault("correct horse battery")186+        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))187+188+        vm.createNote()189+        advanceUntilIdle()190+191+        val opened = vm.activeNoteId.value192+        assertEquals("Untitled", repo.getEntry(opened!!)?.title)193+        assertEquals("note", repo.getEntry(opened)?.type)140194     }141195 142196     @Test fun notes_delete_removes_note_from_active_lookup() = runTest(dispatcher) {

view file