Pocket Notes 6 — search and final polish
A search box that filters as you type, a second empty state for "found nothing", a live word count, and a keyboard that stops covering the sentence you are writing. Pocket Notes is finished.
The chapter where forty notes becomes a problem
Everything works. You can write, edit, delete and undo. Use the app for a fortnight and it will hold somewhere between thirty and a hundred notes, and at about that point something quietly stops working: finding one.
Scrolling is fine for twelve notes. At forty it is a chore. At a hundred it is faster to give up and write the thing down again, which is how notes apps end up with four copies of the same shopping list.
This chapter adds the box that fixes it, and then four small things that finish the app.
Think about a reference library with a desk at the front.
You do not walk the stacks squinting at spines. You say "anything on beekeeping", and somebody behind the desk hands you a much shorter shelf. You then read that shelf exactly the way you would have read any other shelf — you do not need a special beekeeping-shaped pair of eyes.
That is the architecture of this chapter in one image. The desk is NotesViewModel: it holds the request, does the narrowing, and hands out a list. The shelf is the LazyColumn, and it is completely unchanged from chapter 5, because a shorter list is still just a list.
The alternative — the list itself checking every note against a word as it draws — is the version where you walk the stacks.
One more field in the state
1data class NotesUiState(
2 val notes: List<Note> = emptyList(),
3 val query: String = "",
4 val total: Int = 0,
5 val loading: Boolean = true
6)notes is now the filtered list you can see. total is how many notes exist at all.
Those two numbers are what let the screen tell apart the two ways a list can be empty. A brand-new user with nothing saved needs "No notes yet — tap +". Somebody with two hundred notes who typed "beekeping" needs "Nothing found". Show the first message to the second person and the app looks like it just lost everything they own.
One extra Int in one data class, and that whole class of confusion disappears.
Where the filtering happens
And the filter itself, at the bottom of the same file, outside the class:
1/** Case-insensitive match on title or body. */
2private fun List<Note>.search(q: String): List<Note> {
3 if (q.isBlank()) return this
4 return filter {
5 it.title.contains(q, ignoreCase = true) ||
6 it.body.contains(q, ignoreCase = true)
7 }
8}An on List<Note>, so it reads as notes.search(q) at the call site. private means it belongs to this file and nothing else can call it.
Three details worth naming. if (q.isBlank()) return this short-circuits the common case — no typing means no work, and it returns the same list object rather than a copy. || searches the title or the body, because you will remember one or the other and rarely both. And ignoreCase = true is the difference between an app that finds your note and an app that technically works.
Why not do it in SQL?
You could. SELECT * FROM notes WHERE title LIKE :q is a real option, and for some apps it is the right one.
Pocket Notes filters in Kotlin because a phone's worth of notes is a small list — a few hundred short strings, already in memory, already delivered by Room's . Scanning that costs less than the round trip to would, and it keeps the DAO to five obvious functions with no escaping, no wildcards and no % concatenation.
The line to watch for is size. If this were a chat app with fifty thousand messages, the filtering would have to move into the database, and the tool would be a full-text search table rather than LIKE. That is a real and interesting piece of work — and it would happen entirely inside NoteDao and NotesRepository, with NoteListScreen untouched. Which is the argument for having a repository in the first place.
The box
Three states, one when
The content slot of NoteListScreen changes from a Box to a Column, so the field can sit above the list.
The list's top padding drops from 8dp to 4dp at the same time, because the search field's own 8dp bottom margin now supplies the gap.
And the second empty state is six lines, because chapter 3 split EmptyNotes into a thin wrapper around a private EmptyMessage:
1/** Shown when a search matches nothing. */
2@Composable
3fun NoSearchResults(
4 query: String,
5 modifier: Modifier = Modifier
6) {
7 EmptyMessage(
8 icon = Icons.Outlined.SearchOff,
9 title = "Nothing found",
10 body = "No note contains \"$query\". " +
11 "Try a shorter word.",
12 modifier = modifier
13 )
14}Same 96dp honey circle, same layout, same spacing — a different glyph and different words. That split looked like over-engineering three chapters ago. This is the invoice being paid.
The last three touches
A live word count
One actions block on the editor's app bar:
1 actions = {
2 Text(
3 text = wordCount(body),
4 style = type.labelMedium,
5 color = colors.onSurfaceVariant,
6 modifier = Modifier
7 .padding(end = 16.dp)
8 )
9 },and a helper at the bottom of the file:
1/** "0 words", "1 word", "42 words". */
2private fun wordCount(text: String): String {
3 val words = text
4 .split(' ', '\n', '\t')
5 .count { it.isNotBlank() }
6 return if (words == 1) "1 word" else "$words words"
7}split on three characters produces a lot of empty strings — two spaces in a row give you one — so count { it.isNotBlank() } does the real counting rather than .size. And the if on the last line exists because "1 words" is the kind of detail that makes an app feel unfinished.
There is no remember and no state here. body is already state; when it changes the app bar recomposes and calls wordCount again. Counting a few hundred characters on each keystroke is cheap, and putting it in state would mean keeping two things in step that are really one thing.
The keyboard stops covering the note
Two halves, and you need both. In the editor's content Column:
1 Column(
2 modifier = Modifier
3 .fillMaxSize()
4 .padding(inner)
5 // Lifts the text above the keyboard.
6 .imePadding()
7 .padding(horizontal = 8.dp)
8 ) {and in the manifest:
1 <activity
2 android:name=".MainActivity"
3 android:exported="true"
4 android:label="@string/app_name"
5 android:windowSoftInputMode="adjustResize"
6 android:theme="@style/Theme.PocketNotes">The app calls enableEdgeToEdge(), which means it draws underneath the system bars and takes responsibility for everything the system used to handle. adds bottom padding equal to the keyboard's height, so the body field shrinks instead of being covered. is Android's half of the same agreement: resize the window rather than sliding the whole thing upwards.
Leave out either one and the bottom of a long note sits behind the keys with no error to explain it.
What the finished app looks like
Searching. Two notes match; the third is filtered out.
Nothing matched. The search box stays, still holding the query.
Batteries — the small round ones
Ask about the bike lock
The editor, finished. Word count on the right; the text stops above the keyboard.
Now make it yours
Pocket Notes is finished, and it is a much better base to break than Dice Duel was. Three additions, in rising order of difficulty.
Sort options. The cheapest of the three and a good warm-up. Add a small menu to the list's app bar — IconButton plus DropdownMenu, both plain Material 3 — offering "Recently updated", "Title A–Z" and "Oldest first". Hold the choice as a third in the ViewModel and add it to the combine, exactly the way query was added here. The sorting itself is one sortedBy after the search. Notice how little of the app you have to touch: one flow, one line in combine, one menu. That is the shape a well-layered app gives you.
Pinned notes. Add val pinned: Boolean = false to Note, and immediately meet the thing chapter 1 warned about: changing an changes the table, so version = 1 becomes version = 2 and Room demands either a or fallbackToDestructiveMigration(). Use the real migration — ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 — so that nobody loses their notes to your new feature. Then ORDER BY pinned DESC, updatedAt DESC in the DAO, a pin icon on the card, and a long-press to toggle it.
Colour tags. The most design work and the most interesting. Store an Int on the note — 0 for none, 1–5 for the colours — and paint a strip down the leading edge of the card. The temptation is to store a hex value; resist it, because a stored colour cannot follow the theme into dark mode. Store the meaning and look the colour up from your palette at draw time. Then add a row of filter chips above the list and combine them with the search text, which means your combine block is now doing two kinds of narrowing at once — and you will be very glad you kept it in one place.
One new file, five changed. Start from your chapter 5 project or the chapter 5 ZIP.
- Open Pocket Studio → Projects → Pocket Notes.
- Open
ui/NotesUiState.ktand addval total: Int = 0,aboveloading. - Open
ui/NotesViewModel.kt. Changenotes = notestonotes = notes.search(q), addtotal = notes.size,, addonQueryChange, and put thesearchextension function at the very bottom of the file, outside the class. - In the
uipackage, tap New → Kotlin File, name itSearchField, and type the whole file. - Open
ui/EmptyNotes.kt, add theSearchOffimport, and add theNoSearchResultscomposable underEmptyNotes. - Open
ui/NoteListScreen.kt. Change the content slot'sBoxto aColumn, add theif (state.total > 0) { SearchField(...) }block, replace theif/elsewith the three-branchwhen, and change theLazyColumn'stoppadding from 8dp to 4dp. - Open
ui/NoteEditorScreen.kt. Add theactions = { ... }block to theTopAppBar, add.imePadding()to the contentColumn, and add thewordCounthelper at the bottom of the file. - Open
app/src/main/AndroidManifest.xmland addandroid:windowSoftInputMode="adjustResize"to the<activity>tag. - Tap Run. Type a word into the search box that appears under the title. The list narrows on every keystroke.
- Type nonsense. You should get "Nothing found" with your nonsense quoted back at you — not "No notes yet".
- Tap the ✕ inside the search box. Everything comes back.
- Rotate the phone with a search still active. The text is still in the box, because it lives in the ViewModel.
- Open a note and watch the word count in the top right change as you type. Then comment out
.imePadding()and Run: type enough lines to fill the screen and the last one hides behind the keyboard. Put it back. - Delete every note. The search box disappears entirely and you are back at the chapter 3 empty state — which is
state.total > 0doing its job.
Pocket Notes — end of chapter 6 (finished)
A complete project. Unzip it, open it in Pocket Studio, and press Run.
KeyboardOptions lives in Compose Foundation, so the androidx.compose.material3.* import at the top of the file does not cover it.import androidx.compose.foundation.text.KeyboardOptions. ImeAction is a third package again — androidx.compose.ui.text.input.ImeAction — which is a good reminder that a star import only covers one package, not a topic.OutlinedTextField draws whatever value you hand it. If onValueChange does not lead back round to a new value, the field is frozen no matter how hard you type. Usually the ViewModel is being called but the field is still reading a different variable.query = state.query in, onQueryChange = vm::onQueryChange out, and fun onQueryChange(value: String) { query.value = value } in the ViewModel. If any one of those three is missing the box is dead.remember { mutableStateOf("") } inside the composable. Rotation rebuilds the whole Activity, and remember does not survive that.MutableStateFlow in NotesViewModel, which is not rebuilt. Note that rememberSaveable would also work here — but then the query and the filtered list would live in two different places, and the ViewModel is where the filtering already happens.state.notes.isEmpty() alone, and a search that matches nothing empties exactly that list. From the screen's point of view, a failed search and an empty database look identical.state.notes.isNotEmpty() first, then state.total > 0 for the no-results case, then !state.loading for the genuinely-empty one — in that order, because a when takes the first branch that matches.enableEdgeToEdge(), so it draws behind the system bars and nothing resizes for the keyboard on its own. This is the price of edge-to-edge: you take responsibility for the insets..imePadding() on the editor's content Column, and android:windowSoftInputMode="adjustResize" on the <activity> in the manifest. Either one alone leaves you with the same symptom.- Pocket Notes is finished. Six chapters: a database, a repository and a ViewModel, a real list, an editor with navigation, swipe-to-delete with undo, and search.
- The filter lives in the ViewModel's , next to the data — so it re-runs on every change for free, and the visible list and the total count can never contradict each other, because both come from one snapshot.
- Filtering in Kotlin rather than is a deliberate choice for a small list. When it stops being small, the change belongs in the DAO and the — and no screen has to know.
- The search text lives in a on the ViewModel, so rotation cannot lose it and the field itself owns no state at all.
totalis what tells "you have nothing" apart from "your search found nothing". Two , one sharedEmptyMessage.- A with no subject is a ladder of conditions, first match wins. With no
else, drawing nothing is a legitimate outcome. - Edge-to-edge means you own the insets: and , or the keyboard covers your writing.
- Next: the third and largest app. Focus Flow is a Pomodoro timer with three tabs, saved settings and a chart — and chapter 1 builds the navigation skeleton before there is anything to navigate to.