a175832dabe6
feat: Complete Notes parity flow (v2.3.19)
- time
- 2026-06-26 07:50 UTC · 4 months ago
- machine
- koh2
- parent
- eb3db0cf92c2
↓ peach-android-a175832d.tar.gzatlas at this save
files changed4 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 −0app/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 {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 @Composableapp/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 }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) {