Pocket Studio Academy
HomePart 55.11

Pocket Notes 6 — search and final polish

Full course14 min read·4 questions

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 of it like this

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

ui/NotesUiState.ktkotlin
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:

ui/NotesViewModel.ktkotlin
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:

ui/EmptyNotes.ktkotlin
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:

ui/NoteEditorScreen.ktkotlin
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:

ui/NoteEditorScreen.ktkotlin
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:

ui/NoteEditorScreen.ktkotlin
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:

app/src/main/AndroidManifest.xmlxml
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

9:41▲ ▮
Pocket Notes
🔍
milk
Shopping
Oat milk, tomatoes, bread
Just now
Untitled
Ask the milkman to stop Fridays
Yesterday
+

Searching. Two notes match; the third is filtered out.

9:41▲ ▮
Pocket Notes
🔍
beekeping
🔎
Nothing found
No note contains "beekeping". Try a shorter word.
+

Nothing matched. The search box stays, still holding the query.

9:41▲ ▮
Edit note
14 words
Shopping
Oat milk, tomatoes, bread
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.

Try it in Pocket Studio

One new file, five changed. Start from your chapter 5 project or the chapter 5 ZIP.

  1. Open Pocket StudioProjectsPocket Notes.
  2. Open ui/NotesUiState.kt and add val total: Int = 0, above loading.
  3. Open ui/NotesViewModel.kt. Change notes = notes to notes = notes.search(q), add total = notes.size,, add onQueryChange, and put the search extension function at the very bottom of the file, outside the class.
  4. In the ui package, tap NewKotlin File, name it SearchField, and type the whole file.
  5. Open ui/EmptyNotes.kt, add the SearchOff import, and add the NoSearchResults composable under EmptyNotes.
  6. Open ui/NoteListScreen.kt. Change the content slot's Box to a Column, add the if (state.total > 0) { SearchField(...) } block, replace the if/else with the three-branch when, and change the LazyColumn's top padding from 8dp to 4dp.
  7. Open ui/NoteEditorScreen.kt. Add the actions = { ... } block to the TopAppBar, add .imePadding() to the content Column, and add the wordCount helper at the bottom of the file.
  8. Open app/src/main/AndroidManifest.xml and add android:windowSoftInputMode="adjustResize" to the <activity> tag.
  9. Tap Run. Type a word into the search box that appears under the title. The list narrows on every keystroke.
  10. Type nonsense. You should get "Nothing found" with your nonsense quoted back at you — not "No notes yet".
  11. Tap the ✕ inside the search box. Everything comes back.
  12. Rotate the phone with a search still active. The text is still in the box, because it lives in the ViewModel.
  13. 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.
  14. Delete every note. The search box disappears entirely and you are back at the chapter 3 empty state — which is state.total > 0 doing its job.

Pocket Notes — end of chapter 6 (finished)

A complete project. Unzip it, open it in Pocket Studio, and press Run.

Download ZIP
Error Doctor5 common errors
e: file:///.../SearchField.kt:52:27 Unresolved reference 'KeyboardOptions'.
MeansIt looks like a Material 3 class and it is not. KeyboardOptions lives in Compose Foundation, so the androidx.compose.material3.* import at the top of the file does not cover it.
Fiximport 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.
Typing in the search box does nothing — the letters do not even appear
MeansAn 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.
FixClose the loop: 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.
The search box loses its text when the phone rotates — no error message
MeansThe text was kept in a remember { mutableStateOf("") } inside the composable. Rotation rebuilds the whole Activity, and remember does not survive that.
FixKeep it in the 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.
"No notes yet" appears during a search — no error message, just wrong words
MeansThe screen branched on 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.
FixBranch on the unfiltered count too. 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.
The keyboard covers the bottom of the note — no error, and it looks broken on tall phones
MeansThe app calls 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.
FixBoth halves. .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.
Recap
  • 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.
  • total is what tells "you have nothing" apart from "your search found nothing". Two , one shared EmptyMessage.
  • 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.