Pocket Notes 5 — swipe to delete, with undo
Drag a card left and the note is gone from SQLite immediately — no timer, no pending state, no "are you sure?". A snackbar with an Undo button puts the very same note back where it was.
Four chapters in, and nothing can be removed
Pocket Notes writes notes and edits them. It has never deleted one. You can prove that unpleasantly: open it now and count the "Test note" rows chapter 2 left behind, which you have no way of getting rid of short of uninstalling the app.
Adding delete is easy. The @Delete function has been sitting unused in the DAO since chapter 1. What is not easy is the question that arrives with it:
How do you stop somebody deleting the wrong thing?
There are two answers, and almost every app picks the worse one.
Think about the bin in a kitchen.
You scrape a plate into it without ceremony. Nobody stands beside the bin asking "are you certain about that yoghurt pot?" — and if they did, you would stop reading the question by Wednesday and start saying yes automatically, which defeats the entire point of asking.
What makes a kitchen bin safe is not permission. It is that on Thursday morning, when you realise the receipt you needed went in with the potato peelings, you can open the lid and take it back out.
That is the design in this chapter. The delete is real and immediate. The safety net is a way back, not a gate in front.
Why undo beats "are you sure?"
A confirmation dialog looks like caution. Look at what it actually does.
It taxes the right answer to catch the wrong one. Ninety-nine deletes out of a hundred are meant. All hundred get interrupted, so the ninety-nine correct ones are made slower and more annoying in order to slightly inconvenience the one mistake.
It stops being read almost immediately. A dialog that appears every single time trains you to dismiss it without looking. By the fiftieth note you are tapping "Delete" before your eyes have focused on the words — which means the one time it mattered, you tapped through that too.
It cannot answer the question you actually have. The mistake in a list is almost never "I did not mean to delete anything". It is "I did not mean to delete *that one*". A dialog saying "Delete this note?" does not tell you which note, and by the time you find out you have already answered.
It arrives at the worst possible moment. You are mid-gesture, thumb moving. The dialog stops the motion to ask a question you already answered by starting the gesture.
Undo inverts all four. The common case costs nothing. The message is passive — you only engage with it if something went wrong. It appears after the result is visible, so you can see what happened before deciding. And it costs one tap on a button that is already on screen.
That is an : do the thing, show the result, offer the way back.
The delete is genuinely a delete
This is worth being precise about, because there is a tempting shortcut here and this chapter does not take it.
The shortcut is to hide the row for four seconds, start a timer, and only really delete when the timer runs out. It sounds safer. It is worse in three ways:
- The list is lying. For four seconds the screen shows one thing and the database holds another. Everything downstream — a count, a search, a widget — has to be told about a state that only exists in one composable's head.
- It does not survive being killed. Android can stop your app at any moment. If the note is only "pending deletion" in memory, closing the app during those four seconds resurrects it, which is not what anybody asked for.
- Undo gets harder, not easier. You now have two ways a note can be absent and two ways to bring it back.
Here, vm.delete(note) removes the row the instant your finger lifts. The Note object is still sitting in memory in the snackbar's lambda, complete with its id and its original timestamp — and putting it back is nothing more exotic than saving it again.
Wrapping a row in a gesture
One new file. It knows nothing about notes.
Two functions in the ViewModel
1 fun delete(note: Note) {
2 viewModelScope.launch { repo.delete(note) }
3 }
4
5 /**
6 * Undo. The note still carries its old id and its
7 * old timestamp, so it slots back into exactly the
8 * place it came from - not to the top of the list.
9 */
10 fun restore(note: Note) {
11 viewModelScope.launch { repo.save(note) }
12 }Look at how little restore does. It is save — the same the editor uses — handed the whole original Note.
That is the entire reason undo works, and the reason it works well. The note still carries:
- its id, so updates the row it used to be rather than inserting a new one with a new number;
- its updatedAt, so the query's
ORDER BY updatedAt DESCputs it back in the same position in the list.
Compare the editor's save(id, title, body), which deliberately stamps a fresh System.currentTimeMillis(). Editing a note should move it to the top. Un-deleting one should not — the note has not changed, it has come back. Two functions, because they are two different ideas, even though both end at repo.save.
If your restore path goes through the editor's save(id, title, body) instead, the note reappears — at the top of the list, with today's date, having apparently just been written. It looks almost right, which is why this one survives code review. It is Error Doctor entry 5.
Delete, then offer the way back
The Scaffold needs somewhere to put it. One new parameter:
snackbarHost = { SnackbarHost(snackbars) },That is another slot, exactly like topBar and floatingActionButton. Material knows where a snackbar belongs, knows to lift the out of its way while it is showing, and knows to keep it above the navigation bar. You supply the state; it supplies the choreography.
Wrapping every card
Three things happen in twelve lines, and NoteCard is not one of them — it is untouched from chapter 3.
animateItem() is completely dependent on the you added back in chapter 3. Without keys, Compose tracks rows by position: remove row 2 and row 3 does not move up, it simply becomes the new row 2 with different contents. There is nothing to animate because, as far as Compose is concerned, nothing moved.
With key = { note -> note.id }, row id 47 is row id 47 wherever it appears, so Compose can see it change position and slide it there. One argument, added two chapters early, is what makes this line work at all.
Mid-swipe. The card slides left; the red panel and the bin are underneath it.
Let go. The row is already out of SQLite, the gap is closing, and the snackbar has the way back.
The snackbar is inverseSurface — #362F27, a dark brown against a warm cream page — with inverseOnSurface text and the action in inversePrimary. Those three tokens exist so that a message laid over your app can be legible without being a foreign colour, and they flip together in dark mode.
One new file, two changed. Start from your chapter 4 project or the chapter 4 ZIP.
- Open Pocket Studio → Projects → Pocket Notes.
- In the
uipackage, tap New → Kotlin File, name itSwipeToDelete, and type the whole file. Note that the imports useimport androidx.compose.material3.*— the explicitimport androidx.compose.material3.rememberSwipeToDismissBoxStateis 64 characters, over the house limit. - Open
ui/NotesViewModel.ktand adddelete(...)andrestore(...)at the bottom of the class, undersave(...). - Open
ui/NoteListScreen.kt. Add the two imports at the top:com.nativeworks.pocketnotes.data.Noteandkotlinx.coroutines.launch. - Above the
Scaffold, addsnackbars,scopeand the wholedeleteWithUndofunction. - Add
snackbarHost = { SnackbarHost(snackbars) },to theScaffold, just undercontainerColor. - Add
onDelete = { deleteWithUndo(it) }to theNoteList(...)call, addonDelete: (Note) -> UnittoNoteList's parameters, and wrap theNoteCardinsideSwipeToDelete { ... }. - Tap Run. Drag a card to the left. Let go past halfway. The row goes and the snackbar appears at the bottom.
- Tap Undo before it disappears. The note comes back — check its timestamp and its position: it should be exactly where it was, not at the top.
- Delete another one and let the snackbar time out. Close the app completely, reopen it. Still gone, because it was really gone from the moment you let go.
- Now drag a card to the right. Nothing moves. Delete
enableDismissFromStartToEnd = falseand Run again — the card drags both ways, but a right swipe still springs back, becauseconfirmValueChangeis the gate. Put the line back. - Change
vm.restore(note)tovm.save(note.id, note.title, note.body)and Run. Delete a middle note, tap Undo, and watch it come back at the top of the list with a "Just now" timestamp. That is Error Doctor entry 5. Change it back.
Pocket Notes — end of chapter 5
A complete project. Unzip it, open it in Pocket Studio, and press Run.
SwipeToDismissBox, SwipeToDismissBoxValue and rememberSwipeToDismissBoxState are all still marked experimental in Material 3 1.3. Compose will not let you use them without acknowledging that.@OptIn(ExperimentalMaterial3Api::class) on the line above @Composable fun SwipeToDelete. One annotation covers every usage in the function.SwipeToDismissBox does not bring its companion remember function with it. Every top-level function needs its own import — they are separate names in the same package, not members of a class.import androidx.compose.material3.*. Here the star import is not laziness — the explicit line is 64 characters, which breaks the 60-character house rule.animateItem() is an extension on LazyItemScope. That scope only exists inside the lambda you pass to items { } — anywhere else in the file, including on the LazyColumn itself, the name does not exist.SwipeToDelete(onDelete = ..., modifier = Modifier.animateItem()). If you apply it to NoteCard instead, the swipe wrapper moves and the card animates inside it, which is not what you want.key on items, so Compose identifies rows by position. Delete row 2 and every row below it changes identity, so Compose believes four rows changed contents rather than one row leaving.key = { note -> note.id }. This has been in the project since chapter 3 for exactly this moment — if you removed it while tidying, this is what it looked like.updatedAt to now. The DAO sorts with ORDER BY updatedAt DESC, so a fresh timestamp puts the note first. It looks almost correct, which is why this bug ships.fun restore(note: Note) { viewModelScope.launch { repo.save(note) } }. Only the editor's save(id, title, body) should ever set a new time, because only editing is a change.- Delete for real, immediately, and offer a way back. That is an , and it beats "are you sure?" because the common case costs nothing and the message arrives after you can see what happened.
- A pending delete behind a timer makes the list disagree with the database and does not survive the app being killed. This app does not do it.
- wraps a row. is the gate:
truedismisses,falsesprings the row back. Comparing againstEndToStartmeans only one direction can ever delete. - The reveal panel needs
.clip(shape)with the same shape as the card, or its square corners show past the rounded ones. - is a function that returns
ActionPerformedorDismissed, so it needsrememberCoroutineScope()and alaunch. restorepasses the whole original Note, keeping its id and itsupdatedAt, so it returns to the position it left rather than jumping to the top.- only works inside an
items { }lambda, and only does anything because the rows have a . - Next: the last chapter. A search box that filters as you type, a second empty state for "found nothing", a live word count, and the keyboard finally stops covering what you are writing.