Phase 4 · Data & PersistenceModule 31~30 min read

Phase 4 Capstone · Room Notes

Build one screen that saves and deletes short notes on the device. Trace each action through a small repository, a ViewModel and Room, then watch a database Flow update Compose.

Phase 4: Room Notes

Build one screen that saves and deletes short notes on the device. Trace each action through a small repository, a ViewModel and Room, then watch a database Flow update Compose.

Guided workshop: 3–4 hours

This capstone has a complete reference solution. Run it first, explain one small feature at a time, then let students rebuild it. The core app works offline; advanced additions are optional exercises.

Before you start

  • Coroutines in Android
  • Kotlin Flow in the UI
  • Room Database

Application structure and responsibilities

  • One screen with one main task. Finish the small working version before adding optional features.
  • All required source files, Gradle configuration, manifest and resources are included in the downloadable project.

Concepts to explain: Room entity and DAO · suspend writes · observable Flow queries · ViewModel and simple factory · lifecycle-aware collection · persistent data versus temporary draft

See the concept, then explain the implementation

Follow one concrete interaction through the app. Watch which responsibility is active, inspect the state snapshot, and pause to predict what happens next. Open the Kotlin excerpt to connect each visual frame to the implementation.

Phase 4 Interactive concept studio

Watch a note travel to Room and back

Validated event → database commit → Flow emission → durable result

Follow this interactionOn an empty database, type Read chapter 1, save it, then close and reopen the app after the write succeeds.
01Active now
Notes screen

Emits draft edits and Add events

02Responsibility
Notes ViewModel

Validates input and manages write status

03Responsibility
Room database

Stores note records on this device

04Responsibility
Observed query

Emits current rows back to UI state

Notes screen
editDraft("Read chapter 1")
Notes ViewModel

Keep a draft separate from saved records

Typing changes the draft in NotesUiState. It does not insert a database row. Until Add succeeds, Read chapter 1 is editable input and the notes list stays empty.

State snapshot
draft
Read chapter 1
database rows
0
busy
false

Will typing alone preserve this draft after a fresh app process starts?

Connect this frame to the Kotlin source

app/src/main/java/com/mastercoding/phase4/NotesViewModel.kt

NotesViewModel.kt
fun editDraft(value: String) {
        mutableState.update { it.copy(draft = value, writeError = null) }
    }
A focused source excerpt. The complete file and runnable project are in the source section below.
Open the complete source section →
Misconception: The database saves everything the student types immediately.

Typing updates the draft. Only a successful Add writes a note record.

Misconception: A coroutine automatically means the save succeeded.

The operation can fail. The ViewModel reports write errors and clears the draft only after success.

Misconception: Flow is the permanent storage.

Room stores rows in notes.db. Flow observes and emits query results from that storage.

Pause, step, or scrub through the interaction. This teaching model explains the included source; it does not execute Android or run tests in the browser. Verify the behavior with the downloaded app.

Download the complete project and run it

Each ZIP includes the application source, build scripts, Gradle wrapper, manifest, resources and README. The full solution is also readable below; no feature is left as an unfinished TODO.

  1. Extract the ZIP. In Android Studio, open the project folder containing settings.gradle.kts.
  2. Use JDK 17 or 21 and install Android SDK Platform 36 plus the Build Tools requested by Android Studio.
  3. Allow the first Gradle sync to download dependencies. No backend account or API key is needed.
  4. Select an emulator or device running API 23 or newer, then run the app configuration.
  5. Start with the expected results below. Open README.md and TOOLCHAIN.md for the exact project instructions.
PowerShell
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
Run inside the extracted project. macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. UI tests need a running emulator: :app:connectedDebugAndroidTest.

Keep the first version small

Run the reference app, trace one input through the code, and reproduce its behavior before changing it. The short English labels are teaching literals; localization is an optional follow-up exercise.

Implementation roadmap

Android concept labInteractive walkthrough

Room Notes: build it one step at a time

Follow the implementation in order. Predict the result of each step, then explain the change in your own words.

Lesson sequence1 / 6
Step 1 of 6

Run the smallest saved-notes app

The title, note field, Add note button and a real empty-state message appear.

Up nextGive each note a durable identity
A teaching simulation; diagrams illustrate the concept. Run the Kotlin examples in Android Studio.

Step-by-step implementation and lecture solution

Follow these steps in order. Each code window shows the relevant part of a real project file; the complete, unabridged files are in the source browser and ZIP. Pause at the prediction prompt before running the next behavior.

Step 1: Run the smallest saved-notes app

Open this project root in Android Studio, use JDK 17 and SDK Platform 36, sync, then run on API 23 or later. Read the screen before adding any data.

Open: README.md · app/src/main/java/com/mastercoding/phase4/MainActivity.kt

MainActivity.kt
import androidx.lifecycle.compose.collectAsStateWithLifecycle

class MainActivity : ComponentActivity() {
    private val model: NotesViewModel by viewModels {
        NotesViewModel.factory(NotesRepository(NotesDatabase.get(applicationContext).notes()))
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                val state by model.state.collectAsStateWithLifecycle()
                NotesScreen(state, model::editDraft, model::addNote, model::deleteNote, model::retryRead)
            }
        }
    }
}
Focused excerpt, lines 31–49. Select this file in Complete source code for every line.

Expected behavior

The title, note field, Add note button and a real empty-state message appear.
Pause and explain this step

Ask students which values are visible before the first database row exists. Explain that an empty result is a successful read, not a failure.

Step 2: Give each note a durable identity

Start with Note and NoteDao. An auto-generated Long ID identifies a row even when two notes contain identical text. observeAll returns newest rows first; insert and delete are suspend functions.

Open: app/src/main/java/com/mastercoding/phase4/NotesDatabase.kt

NotesDatabase.kt

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY id DESC")
    fun observeAll(): Flow<List<Note>>

    @Insert
    suspend fun insert(note: Note)

    @Query("DELETE FROM notes WHERE id = :id")
    suspend fun delete(id: Long)
}

@Database(entities = [Note::class], version = 1, exportSchema = true)
abstract class NotesDatabase : RoomDatabase() {
    abstract fun notes(): NoteDao

    companion object {
        @Volatile private var instance: NotesDatabase? = null
Focused excerpt, lines 19–37. Select this file in Complete source code for every line.

Expected behavior

Adding identical text twice creates separate rows; deleting one ID leaves the other.
Pause and explain this step

Draw a two-row table with the same text and different IDs. Ask why deleting by text would be ambiguous.

Step 3: Create one database and a small repository

NotesDatabase.get uses the application context and a synchronized singleton. NotesRepository delegates to the DAO. The ViewModel factory supplies that repository without a framework or a second in-memory list.

Open: app/src/main/java/com/mastercoding/phase4/NotesDatabase.kt · app/src/main/java/com/mastercoding/phase4/NotesViewModel.kt · app/src/main/java/com/mastercoding/phase4/MainActivity.kt

NotesDatabase.kt
        @Volatile private var instance: NotesDatabase? = null

        fun get(context: Context): NotesDatabase = instance ?: synchronized(this) {
            instance ?: Room.databaseBuilder(
                context.applicationContext, NotesDatabase::class.java, "notes.db",
            ).build().also { instance = it }
        }
    }
}

class NotesRepository(private val dao: NoteDao) {
    val notes: Flow<List<Note>> = dao.observeAll()
    suspend fun add(text: String) = dao.insert(Note(text = text))
    suspend fun delete(id: Long) = dao.delete(id)
}
Focused excerpt, lines 37–52. Select this file in Complete source code for every line.

Expected behavior

Screen recreation reuses the app database; saved rows remain after the app closes.
Pause and explain this step

Separate the lifetime of an Activity, a ViewModel and a SQLite file. Point out that a singleton is one instance per process, not persistent data by itself.

Step 4: Observe Room and name the read states

retryRead starts one collection job and cancels the old job on retry. It distinguishes loading, a successful empty list, loaded notes and a read error. The Activity collects StateFlow with collectAsStateWithLifecycle.

Open: app/src/main/java/com/mastercoding/phase4/NotesViewModel.kt · app/src/main/java/com/mastercoding/phase4/MainActivity.kt

NotesViewModel.kt
    init { retryRead() }

    fun retryRead() {
        readJob?.cancel()
        mutableState.update { it.copy(loading = true, readError = null) }
        readJob = viewModelScope.launch {
            repository.notes
                .catch { error ->
                    if (error is CancellationException) throw error
                    mutableState.update {
                        it.copy(loading = false, readError = "Could not read notes. Try again.")
                    }
                }
                .collect { notes ->
                    mutableState.update { it.copy(notes = notes, loading = false) }
                }
        }
    }
Focused excerpt, lines 30–48. Select this file in Complete source code for every line.

Expected behavior

Every successful write is reflected by a new database emission; a real read failure offers Retry reading.
Pause and explain this step

Trace Room Flow → ViewModel state → Compose. Explain that the small loading indicator may only flash briefly for local storage.

Step 5: Validate and save without losing the draft

addNote trims outer whitespace and rejects an empty result. write prevents overlapping operations, calls the suspend repository function and clears the draft only after success. The input is disabled during that write.

Open: app/src/main/java/com/mastercoding/phase4/NotesViewModel.kt · app/src/main/java/com/mastercoding/phase4/MainActivity.kt

NotesViewModel.kt
    }

    fun addNote() {
        val text = state.value.draft.trim()
        if (text.isEmpty()) {
            mutableState.update { it.copy(writeError = "Write a note first.") }
            return
        }
        write(clearDraft = true) { repository.add(text) }
    }

    fun deleteNote(id: Long) = write { repository.delete(id) }

    private fun write(clearDraft: Boolean = false, action: suspend () -> Unit) {
        if (state.value.busy) return
        mutableState.update { it.copy(busy = true, writeError = null) }
        viewModelScope.launch {
            try {
                action()
Focused excerpt, lines 51–69. Select this file in Complete source code for every line.

Expected behavior

Spaces alone show a message; a valid note is saved once and its draft clears; a failed save retains the draft.
Pause and explain this step

Have students predict the result of submitting two spaces, then a padded sentence. Follow the ID and text from the event to the new Room row.

Step 6: Delete by ID and prove persistence

Use a stable ID both for the LazyColumn key and for deleteNote. Add two notes, remove one, rotate, then close and reopen the app. Read the schema/migration note before changing the entity.

Open: app/src/main/java/com/mastercoding/phase4/MainActivity.kt · app/src/main/java/com/mastercoding/phase4/NotesViewModel.kt · app/src/main/java/com/mastercoding/phase4/NotesDatabase.kt · README.md

MainActivity.kt
                }
            }
            items(state.notes, key = { it.id }) { note ->
                Card(Modifier.fillMaxWidth()) {
                    Row(Modifier.padding(12.dp)) {
                        Text(note.text, Modifier.weight(1f))
                        TextButton(
                            onClick = { onDelete(note.id) },
                            enabled = !state.busy && state.readError == null,
                        ) { Text("Delete") }
                    }
                }
            }
        }
    }
}
Focused excerpt, lines 87–103. Select this file in Complete source code for every line.

Expected behavior

Only the selected note disappears; reopening shows the saved remaining rows and no deleted row.
Pause and explain this step

Explain why recomposition does not save data, why a ViewModel is not a database, and why upgrades need migrations.

Complete source code — every file

Start with MainActivity.kt, then follow the file guide. The selector also contains the tests, manifest, resources and complete Gradle configuration. The binary Gradle wrapper JAR is included in the download.

FileWhat to explain
app/src/main/java/com/mastercoding/phase4/NotesDatabase.ktThe Note entity, observable DAO, one database instance per app process, and a tiny repository.
app/src/main/java/com/mastercoding/phase4/NotesViewModel.ktExplicit screen state, trimmed input validation, cancellable reading, asynchronous writes and visible storage errors.
app/src/main/java/com/mastercoding/phase4/MainActivity.ktActivity setup, ViewModel creation, lifecycle collection and the complete add/delete screen.
README.mdExact setup commands, a lecture trace, state lifetime discussion and manual verification scenarios.

Activity setup, ViewModel creation, lifecycle collection and the complete add/delete screen.

18 complete text files. Copy the selected file with the code panel button, or download the full project above.

MainActivity.kt
package com.mastercoding.phase4

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle

class MainActivity : ComponentActivity() {
    private val model: NotesViewModel by viewModels {
        NotesViewModel.factory(NotesRepository(NotesDatabase.get(applicationContext).notes()))
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                val state by model.state.collectAsStateWithLifecycle()
                NotesScreen(state, model::editDraft, model::addNote, model::deleteNote, model::retryRead)
            }
        }
    }
}

@Composable
private fun NotesScreen(
    state: NotesUiState,
    onDraft: (String) -> Unit,
    onAdd: () -> Unit,
    onDelete: (Long) -> Unit,
    onRetry: () -> Unit,
) {
    Scaffold { insets ->
        LazyColumn(
            modifier = Modifier.fillMaxSize().padding(insets)
                .consumeWindowInsets(insets).imePadding(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            item {
                Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                    Text("Room Notes", style = MaterialTheme.typography.headlineMedium)
                    Text("Small notes, saved on this device.")
                    OutlinedTextField(
                        value = state.draft, onValueChange = onDraft,
                        label = { Text("New note") }, enabled = !state.busy,
                        maxLines = 4,
                        modifier = Modifier.fillMaxWidth(),
                    )
                    Button(onClick = onAdd, enabled = !state.busy && !state.loading && state.readError == null) {
                        Text(if (state.busy) "Saving…" else "Add note")
                    }
                    state.writeError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
                    if (state.loading) CircularProgressIndicator()
                    state.readError?.let {
                        Text(it, color = MaterialTheme.colorScheme.error)
                        TextButton(onClick = onRetry) { Text("Retry reading") }
                    }
                    if (!state.loading && state.readError == null && state.notes.isEmpty()) {
                        Text("No notes yet. Add your first note above.")
                    }
                }
            }
            items(state.notes, key = { it.id }) { note ->
                Card(Modifier.fillMaxWidth()) {
                    Row(Modifier.padding(12.dp)) {
                        Text(note.text, Modifier.weight(1f))
                        TextButton(
                            onClick = { onDelete(note.id) },
                            enabled = !state.busy && state.readError == null,
                        ) { Text("Delete") }
                    }
                }
            }
        }
    }
}

Definition of done

Project acceptance checklist0 / 5 verified

Check an item only after demonstrating it. Progress is saved in this browser.

Try the reference solution yourself

Run each action and compare the result. Record the device, action and observed outcome. For the testing phase, run the included unit tests and the Compose test separately: a JVM test does not prove on-device UI behavior.

ActionExpected result
Submit a note containing only spaces.A validation message appears and the database stays unchanged.
Add “ Review Room ”, then add the same note again.Both rows display “Review Room” and have distinct generated IDs.
Delete one of the duplicate notes.Only the selected ID is removed; the other note remains.
Rotate with an unsaved draft.The Activity-scoped ViewModel keeps the draft through the configuration change.
Close the app completely and reopen it.Saved notes return from Room; deleted notes stay deleted. An unsaved draft is not guaranteed after process death.

Common mistakes and repairs

  • Do not replace suspend DAO operations with allowMainThreadQueries; storage work must not block the screen.
  • Do not append a separate UI-only row after inserting. Room is the saved list’s source of truth.
  • Do not use note text or a list position as identity; repeated text is valid.
  • Do not clear the draft before an insert succeeds or catch coroutine cancellation as a storage error.
  • The schema starts at version 1. Keep exported schemas and add a tested migration before a stored-model change.

Instructor lecture plan

MinutesClassroom activity
0–10Run the finished app first. Ask students to describe its input, state and expected output.
10–30Walk through the source files in the solution order. Pause at each named function and predict the next change.
30–50Rebuild the main feature together using the guided steps; compare behavior with the complete reference source.
50–65Pairs reproduce an edge case and explain one callback or data-flow path to the class.

Instructor discussion and answer notes

Draw a two-row table with the same text and different IDs. Ask why deleting by text would be ambiguous.

Start with Note and NoteDao. An auto-generated Long ID identifies a row even when two notes contain identical text. observeAll returns newest rows first; insert and delete are suspend functions. Expected result: Adding identical text twice creates separate rows; deleting one ID leaves the other.

Separate the lifetime of an Activity, a ViewModel and a SQLite file. Point out that a singleton is one instance per process, not persistent data by itself.

NotesDatabase.get uses the application context and a synchronized singleton. NotesRepository delegates to the DAO. The ViewModel factory supplies that repository without a framework or a second in-memory list. Expected result: Screen recreation reuses the app database; saved rows remain after the app closes.

Trace Room Flow → ViewModel state → Compose. Explain that the small loading indicator may only flash briefly for local storage.

retryRead starts one collection job and cancels the old job on retry. It distinguishes loading, a successful empty list, loaded notes and a read error. The Activity collects StateFlow with collectAsStateWithLifecycle. Expected result: Every successful write is reflected by a new database emission; a real read failure offers Retry reading.

Assessment rubric

CriterionPointsEvidence
Working core behavior40Demonstrate the project's acceptance checklist and expected scenarios.
Understand the implementation30Explain the named state owner, event callback and source files without reading a copied script.
Edge cases and verification20Reproduce the listed boundary/error cases and record the observed outcome.
Clear classroom demonstration10Show the app, trace one action and describe one small extension.

Total: 100 points. Ask students to show the working app, explain one complete event/data path, reproduce an edge case, and make one small change themselves. Keep any limitations explicit.

Optional extensions after the core works

  • Add a small note length limit with a validation message.
  • Add an edit action that updates the existing ID.
  • Write an instrumented Room test for insert, observation and deletion.

After the intervening lessons, continue with Animated Profile Card.

For deeper study, use the official Compose course and Android architecture guide.