Stage 3 · Lifecycle & storageProject 10

Pocket Notes with Room

Build a persistent notebook with a searchable list, add/edit navigation, title validation, confirmation before deletion, and recoverable database error states.

Your assignment: Pocket Notes with Room

Build a persistent notebook with a searchable list, add/edit navigation, title validation, confirmation before deletion, and recoverable database error states.

Intermediate · 5–6 hours of building

The download is a complete reference app. Read the brief, predict the result, then implement each step in a separate working copy. Code runs in Android Studio on a device or emulator; the website displays and explains the source.

Before you begin

  • Complete Mini Recipe Book or understand Navigation Compose and stable item IDs.
  • Understand Kotlin data classes, suspend functions, and nullable values.
  • Understand ViewModel, StateFlow, lifecycle-aware collection, and basic SQL SELECT/INSERT/UPDATE/DELETE concepts.

By the end, you can…

  • Define a Room entity, DAO, and a single application-scoped database.
  • Observe Room data as a Flow and keep suspend database writes outside UI code.
  • Distinguish a new note from an existing note so editing updates rather than duplicates.
  • Retain an editor draft across rotation using a navigation-scoped ViewModel and SavedStateHandle.
  • Handle loading, empty, no-match, validation, missing-record, and database-error states.
  • Verify persistence after closing and reopening the app, plus title and search rules with JVM tests.

Required features

  • Store id, title, body, and updatedAt for each note in Room.
  • Create, read, update, and delete notes; show newest updates first.
  • Search both titles and bodies without case sensitivity.
  • Reject blank or whitespace-only titles while keeping the typed draft visible.
  • Confirm deletion and return to the list only after a successful write.
  • Keep saved notes after an ordinary close/reopen, keep editor drafts through rotation, and explain that uninstalling or clearing app data removes the local database.
  • Show recoverable loading/write errors, use suspend DAO writes, and avoid destructive migration fallback.

Set up the project and run the reference app

  1. Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing settings.gradle.kts. Open that folder rather than just a Kotlin file.
  2. Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
  3. Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
  4. Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the app run configuration and press Run.
  5. Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
  6. Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
PowerShell — in the project folder
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. Unit tests run without a device; manual UI checks need a device or emulator.

Keep the toolchain consistent

The projects use the existing course’s pinned toolchain: Kotlin 2.2.21, AGP 8.13.2, Gradle 8.13, and Compose BOM 2025.12.01. The original collection uses lifecycle 2.9.4. Projects 8 and 10 add Navigation 2.9.6; project 10 adds Room 2.8.4. All build files and the Gradle wrapper are included. Open TOOLCHAIN.md for setup details.

Understand the topics before coding

For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.

TOPIC 01

Entities, primary keys, and SQL rows

An @Entity data class describes the notes table. The auto-generated Long primary key identifies a row independently of its title or position in the list. id = 0 requests a new generated ID when inserting; updates carry the existing ID so Room knows which row to replace. updatedAt stores epoch milliseconds for ordering and display.

TOPIC 02

DAO boundaries and generated code

A DAO declares the database operations the app needs. Room's KSP processor validates annotated SQL and generates implementations during the build. observeAll returns a Flow that emits a new list when the table changes, while find/insert/update/delete are suspend operations that Room executes asynchronously. Do not add allowMainThreadQueries to hide a threading mistake.

TOPIC 03

Database lifetime and persistence

NotesDatabase.get uses the application context and a synchronized singleton so screens share one database instance without retaining an activity. pocket-notes.db lives in the application's private storage. Saved rows outlive composables, activities, and processes; an unsaved draft is UI state with a narrower lifetime.

TOPIC 04

One-way state flow and lifecycle

Room emits lists, the ViewModel converts them into Loading/Ready/Failed UI states, and Compose collects with collectAsStateWithLifecycle. User actions call ViewModel functions; those functions perform suspend writes, after which Room emits the new list. The UI never edits the observed list directly.

TOPIC 05

Drafts and navigation-scoped ViewModels

Each editor back-stack entry owns its own NoteEditorViewModel. SavedStateHandle retains the small title/body draft through supported recreation, while the Room row remains the saved source of truth. An initialized marker prevents a recreated editor from overwriting an unsaved draft with an older database row. Back intentionally discards the draft; Save commits it.

TOPIC 06

Error states and cancellation

A failed read shows Retry, and a failed write preserves the draft so the user can try again. Busy state blocks duplicate button submissions. Coroutine cancellation is rethrown instead of displayed as a database error. A missing update target is reported rather than silently inserted as a second note.

TOPIC 07

Schema changes and migrations

This training database has schema version 1 and exportSchema is false to keep the initial setup small. Changing an installed database schema requires a version increment and a migration. The project deliberately does not use fallbackToDestructiveMigration because deleting student notes is not a valid migration strategy. Export schemas and add migration tests before extending a real app.

Three different lifetimes

Recomposition redraws UI from state. rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.

Build the app step by step

Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.

Step 1. 1. Sync the Room project and inspect its screens

Your task: Open pocket-notes in Android Studio and sync the downloaded project. Inspect the supplied app Gradle configuration: Room runtime/ktx and room-compiler must share version 2.8.4, and room-compiler is a KSP dependency. Run on API 23+ and open Add note. Trace PocketNotesApp from MainActivity before changing any code.

Open: app/src/main/java/com/mastercoding/miniprojects/notes/MainActivity.kt

How it works: Room needs both a runtime library and generated implementations. KSP runs at build time; adding runtime alone does not generate NotesDatabase_Impl. This project's fixed toolchain includes the matching Kotlin/KSP configuration. Start from the complete download to avoid mixing Gradle examples from unrelated versions.

MainActivity.kt
import java.util.Date

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MiniTheme { PocketNotesApp() } }
    }
}

@Composable
private fun PocketNotesApp() {
    val controller = rememberNavController()
    NavHost(controller, startDestination = "notes") {
        composable("notes") {
            NotesList(
                onAdd = { controller.navigate("new") },
                onOpen = { id -> controller.navigate("edit/$id") },
            )
        }
        composable("new") { NoteEditor(null, onBack = { controller.popBackStack() }) }
        composable(
            "edit/{id}",
            arguments = listOf(navArgument("id") { type = NavType.LongType }),
        ) { entry ->
            NoteEditor(entry.arguments!!.getLong("id"), onBack = { controller.popBackStack() })
        }
Reference excerpt · lines 27–52. The full file, with imports, is in Complete source files.

Stop and check

Gradle sync completes, the empty notebook appears, and Add note opens an editor without a missing-generated-database error.

Step 2. 2. Design the note table and stable identity

Your task: Open NotesDatabase.kt and read Note. Write down each column's Kotlin type and purpose. Follow how id defaults to zero for insertion and how an existing row's id is retained for updates. Create two notes with identical titles to demonstrate that titles are not identifiers.

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

How it works: A primary key identifies the database row even if the user changes its title or body. Two notes may legitimately have the same title. updatedAt is a stored value rather than a formatted string, so ordering works numerically and the UI can format the same timestamp for the device's locale.

NotesDatabase.kt

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val body: String,
    val updatedAt: Long,
)

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

    @Query("SELECT * FROM notes WHERE id = :id")
    suspend fun find(id: Long): Note?

    @Insert
    suspend fun insert(note: Note): Long

    @Update
    suspend fun update(note: Note): Int

    @Query("DELETE FROM notes WHERE id = :id")
    suspend fun delete(id: Long): Int
}
Reference excerpt · lines 6–31. The full file, with imports, is in Complete source files.

Stop and check

The two same-title notes remain separate rows and can be edited independently.

Step 3. 3. Read and explain all DAO operations

Your task: Inspect NoteDao. Translate each query into plain language, including ORDER BY updatedAt DESC, id DESC. Follow find's nullable result, insert's new ID, and update's affected-row count. Do not call these methods from a composable body and do not enable main-thread queries.

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

How it works: A Flow query observes changes over time; a suspend query or write performs one asynchronous operation. The second ordering column makes the order deterministic when timestamps match. Checking the update count lets the app report a deleted/missing row instead of pretending the save succeeded.

NotesDatabase.kt

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

    @Query("SELECT * FROM notes WHERE id = :id")
    suspend fun find(id: Long): Note?

    @Insert
    suspend fun insert(note: Note): Long

    @Update
    suspend fun update(note: Note): Int

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

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

    companion object {
        @Volatile private var instance: NotesDatabase? = null
Reference excerpt · lines 14–39. The full file, with imports, is in Complete source files.

Stop and check

You can map create, load, edit, and delete actions to specific DAO methods and explain their return values.

Step 4. 4. Create one safe database instance

Your task: Read NotesDatabase.get. Locate context.applicationContext, the fixed pocket-notes.db filename, @Volatile, synchronized, and the final build call. Explain why this instance is shared rather than rebuilt on every recomposition. Leave the schema at version 1 for the base exercise.

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

How it works: The application context lives as long as the process, so holding it does not retain an obsolete activity. Synchronization prevents simultaneous callers from creating separate database instances. The filename chooses the persistent local database; an in-memory database would lose all notes when closed. Future schema changes require explicit migration work.

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

        fun get(context: Context): NotesDatabase = instance ?: synchronized(this) {
            instance ?: Room.databaseBuilder(
                context.applicationContext,
                NotesDatabase::class.java,
                "pocket-notes.db",
            ).build().also { instance = it }
        }
    }
}
Reference excerpt · lines 38–49. The full file, with imports, is in Complete source files.

Stop and check

Repeated navigation uses the same database instance, and saved rows remain after the app is closed and reopened.

Step 5. 5. Observe notes and render loading, empty, and search states

Your task: In NotesViewModel.kt, follow refresh into observeAll, map, onStart, catch, and stateIn. Then inspect NotesList in MainActivity.kt. Add two notes, search a word present only in a body, clear the search, and enter an absent word. Keep LazyColumn in the bounded non-scrolling parent Column.

Open: app/src/main/java/com/mastercoding/miniprojects/notes/NotesViewModel.kt

How it works: Room drives the list after a successful write, so the UI does not need to manually append or replace rows. WhileSubscribed stops unnecessary observation when no screen collects the state. Search is performed in memory for this small notebook and checks both title and body. Empty database and no search matches are different states with different messages.

NotesViewModel.kt

@OptIn(ExperimentalCoroutinesApi::class)
class NotesViewModel(application: Application) : AndroidViewModel(application) {
    private val dao = NotesDatabase.get(application).noteDao()
    private val refresh = MutableStateFlow(0)
    val state = refresh.flatMapLatest {
        dao.observeAll()
            .map<List<Note>, NotesListState> { NotesListState.Ready(it) }
            .onStart { emit(NotesListState.Loading) }
            .catch { emit(NotesListState.Failed) }
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NotesListState.Loading)

    fun retry() { refresh.update { it + 1 } }
}

enum class NoteProblem { LoadFailed, Missing, WriteFailed }

data class EditorState(
    val title: String = "",
    val body: String = "",
    val loading: Boolean = false,
    val busy: Boolean = false,
    val titleError: Boolean = false,
    val problem: NoteProblem? = null,
    val finished: Boolean = false,
)
Reference excerpt · lines 18–43. The full file, with imports, is in Complete source files.

Stop and check

New notes appear automatically, body-only searches work, and empty/no-match states have distinct messages.

Step 6. 6. Navigate to separate new and existing editors

Your task: Inspect PocketNotesApp. Follow new to an editor with id = null and edit/{id} to an editor with a Long ID. In NoteEditor, inspect viewModelFactory and createSavedStateHandle. Open an existing note, type an unsaved change, and rotate once before saving.

Open: app/src/main/java/com/mastercoding/miniprojects/notes/MainActivity.kt

How it works: Navigation supplies the editor's ViewModelStoreOwner, giving each back-stack entry a separate editor instance. The factory provides a DAO, requested ID, and SavedStateHandle. A new note starts with an empty draft; an existing note loads once. Restoring initialized draft fields avoids replacing the student's unsaved text after activity recreation.

MainActivity.kt

@Composable
private fun NoteEditor(id: Long?, onBack: () -> Unit) {
    val applicationContext = LocalContext.current.applicationContext
    val factory = remember(applicationContext, id) {
        viewModelFactory {
            initializer {
                NoteEditorViewModel(NotesDatabase.get(applicationContext).noteDao(), id, createSavedStateHandle())
            }
        }
    }
    // Navigation supplies a distinct ViewModelStoreOwner for each editor back-stack entry.
    val model: NoteEditorViewModel = viewModel(factory = factory)
    val state by model.state.collectAsStateWithLifecycle()
    var confirmDelete by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(state.finished) { if (state.finished) onBack() }
    MiniScreen(stringResource(if (id == null) R.string.add_note else R.string.edit_note)) {
        TextButton(onClick = onBack, enabled = !state.busy) { Text(stringResource(R.string.back)) }
        Text(stringResource(R.string.draft_help), style = MaterialTheme.typography.bodySmall)
        if (state.loading) {
            CircularProgressIndicator()
        } else if (state.problem == NoteProblem.LoadFailed || state.problem == NoteProblem.Missing) {
            Text(stringResource(if (state.problem == NoteProblem.Missing) R.string.note_missing else R.string.load_failed))
            if (state.problem == NoteProblem.LoadFailed) {
                Button(onClick = model::load) { Text(stringResource(R.string.retry)) }
            }
Reference excerpt · lines 102–127. The full file, with imports, is in Complete source files.

Stop and check

A new editor is blank, an existing editor loads the correct row, and unsaved text remains after rotation.

Step 7. 7. Validate and save without duplicate rows

Your task: Read NoteEditorViewModel.save. Try a title made of spaces, then save a valid note. Edit that saved note and save again. Follow cleanTitle, the busy guard, recordId, insert versus update, the affected-row check, and finished state. Run NoteRulesTest from Android Studio or gradle testDebugUnitTest.

Open: app/src/main/java/com/mastercoding/miniprojects/notes/NotesViewModel.kt

How it works: Validation happens before database work, and a failed title check leaves the draft intact. New records use insert and capture the generated ID; existing records use update with the same ID. Busy state prevents repeated taps from launching parallel saves. The finished flag returns to the list only after Room confirms success, while errors keep the editor open.

NotesViewModel.kt
    }

    fun save() {
        if (state.value.busy || state.value.loading || state.value.finished) return
        val title = cleanTitle(state.value.title)
        if (title == null) {
            _state.update { it.copy(titleError = true) }
            return
        }
        val body = state.value.body
        _state.update { it.copy(busy = true, problem = null) }
        viewModelScope.launch {
            try {
                val existingId = recordId
                val note = Note(existingId ?: 0, title, body, System.currentTimeMillis())
                if (existingId == null) {
                    recordId = dao.insert(note)
                    saved["recordId"] = recordId
                } else if (dao.update(note) != 1) {
                    _state.update { it.copy(busy = false, problem = NoteProblem.Missing) }
                    return@launch
                }
                _state.update { it.copy(busy = false, finished = true) }
            } catch (cancelled: CancellationException) {
                throw cancelled
            } catch (_: Exception) {
Reference excerpt · lines 94–119. The full file, with imports, is in Complete source files.

Stop and check

Whitespace titles show a field error; a valid save creates one row; editing changes that row and its timestamp without increasing the note count; rule tests pass.

Step 8. 8. Confirm deletions and keep failures recoverable

Your task: Open a saved note, tap Delete, and cancel the dialog. Repeat and confirm. Inspect NoteEditorViewModel.delete, the AlertDialog callbacks, and the error rendering. Read each catch block and locate the CancellationException rethrow. If you add a failing DAO in a test variant, make find/insert/update throw and check the matching Retry or write-error state.

Open: app/src/main/java/com/mastercoding/miniprojects/notes/NotesViewModel.kt

How it works: Destructive actions need an explicit user choice, while canceling must not touch the database. Successful deletion sets finished and Room updates the list. A database failure must not erase the editor draft or silently navigate away. Cancellation is normal when an editor is removed, so it should propagate through structured concurrency instead of masquerading as a storage failure.

NotesViewModel.kt
    }

    fun delete() {
        val id = recordId ?: return
        if (state.value.busy || state.value.loading || state.value.finished) return
        _state.update { it.copy(busy = true, problem = null) }
        viewModelScope.launch {
            try {
                dao.delete(id)
                _state.update { it.copy(busy = false, finished = true) }
            } catch (cancelled: CancellationException) {
                throw cancelled
            } catch (_: Exception) {
                _state.update { it.copy(busy = false, problem = NoteProblem.WriteFailed) }
            }
        }
    }
}
Reference excerpt · lines 123–141. The full file, with imports, is in Complete source files.

Stop and check

Cancel preserves the row; confirmation removes it; a simulated write failure retains the editor and displays a retryable error.

Step 9. 9. Verify persistence and document storage boundaries

Your task: Save two notes, edit one, delete the other, then close and reopen the application. Repeat after stopping the process. Confirm that only the edited survivor remains. Test a new unsaved draft separately by rotating and by using Back. Submit source, list/editor screenshots, the rule-test result, and a table recording each completion check.

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

How it works: Room persistence is proved by reconstructing the UI from disk after the process ends, not merely by navigating away and back. A draft survives rotation but Back intentionally discards it. Clearing app data or uninstalling removes this device-local notebook; the project includes no cloud backup or cross-device sync feature.

NotesDatabase.kt
                context.applicationContext,
                NotesDatabase::class.java,
                "pocket-notes.db",
            ).build().also { instance = it }
        }
    }
}
Reference excerpt · lines 42–49. The full file, with imports, is in Complete source files.

Stop and check

Saved edits and deletions survive reopening, rotation preserves a current draft, Back discards it, and your report states the limits of local storage.

Step 10. 10. Automate reactive CRUD and disk-persistence checks

Your task: Start an API 23+ emulator, open NotesDatabaseTest under app/src/androidTest, and run it with the green test action. Alternatively, run gradle connectedDebugAndroidTest when Gradle is installed. Read the initial Flow emission, insert, update, delete, database close/reopen, and final cleanup. Compare these device tests with the JVM-only NoteRulesTest.

Open: app/src/androidTest/java/com/mastercoding/miniprojects/notes/NotesDatabaseTest.kt

How it works: Room depends on Android SQLite, so this integration test runs on an emulator or device. A unique test-only database filename prevents interference with the real notebook. Waiting for the initial empty emission proves observation starts before writing; later emissions verify updates are reactive. Closing and rebuilding the same named database verifies disk persistence. finally closes resources and removes only that unique test database even when an assertion fails.

NotesDatabaseTest.kt
class NotesDatabaseTest {
    @Test
    fun observesChangesAndRetainsEditedRowsAfterReopen() = runBlocking {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        // A unique test-only filename keeps the student's real notebook untouched.
        val filename = "notes-instrumented-${UUID.randomUUID()}.db"
        fun openDatabase(): NotesDatabase = Room.databaseBuilder(
            context, NotesDatabase::class.java, filename,
        ).build()
        var database = openDatabase()
        val emissions = Channel<List<Note>>(Channel.UNLIMITED)
        val watcher = launch {
            database.noteDao().observeAll().collect { emissions.send(it) }
        }
        try {
            // Waiting for the initial emission ensures the collector is active before writing.
            assertTrue(withTimeout(5_000) { emissions.receive() }.isEmpty())
            val keptId = database.noteDao().insert(Note(title = "First", body = "Before", updatedAt = 100L))
            val added = emissions.awaitRows { rows -> rows.any { it.id == keptId } }
            assertEquals("Before", added.first { it.id == keptId }.body)

            val deletedId = database.noteDao().insert(Note(title = "Temporary", body = "Remove", updatedAt = 200L))
            emissions.awaitRows { it.size == 2 }
            assertEquals(1, database.noteDao().update(Note(keptId, "Edited", "After", 300L)))
            val updated = emissions.awaitRows { rows -> rows.any { it.id == keptId && it.body == "After" } }
            assertEquals(keptId, updated.first().id) // Newest timestamp is sorted first.
Reference excerpt · lines 18–43. The full file, with imports, is in Complete source files.

Stop and check

The instrumented test passes, observes inserted/updated/deleted rows, and reloads the edited survivor after reopening without touching the student's notebook.

Complete source files

Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.

Start hereResponsibility
MainActivity.ktThe launcher Activity installs the Compose UI with setContent.
CommonUi.ktMaterial 3 theme and reusable screen container with scrolling and system insets.
Application Kotlin filesState, rules, navigation or persistence introduced in the guide.
app/src/main/resImages, text resources, and the launch theme.
app/build.gradle.ktsSDK settings and the exact libraries used by this app.
README.md / TOOLCHAIN.mdStudent instructions and setup, usable without the website.

Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.

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

MainActivity.kt
package com.mastercoding.miniprojects.notes

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import java.text.DateFormat
import java.util.Date

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MiniTheme { PocketNotesApp() } }
    }
}

@Composable
private fun PocketNotesApp() {
    val controller = rememberNavController()
    NavHost(controller, startDestination = "notes") {
        composable("notes") {
            NotesList(
                onAdd = { controller.navigate("new") },
                onOpen = { id -> controller.navigate("edit/$id") },
            )
        }
        composable("new") { NoteEditor(null, onBack = { controller.popBackStack() }) }
        composable(
            "edit/{id}",
            arguments = listOf(navArgument("id") { type = NavType.LongType }),
        ) { entry ->
            NoteEditor(entry.arguments!!.getLong("id"), onBack = { controller.popBackStack() })
        }
    }
}

@Composable
private fun NotesList(onAdd: () -> Unit, onOpen: (Long) -> Unit, model: NotesViewModel = viewModel()) {
    val state by model.state.collectAsStateWithLifecycle()
    var query by rememberSaveable { mutableStateOf("") }
    Column(
        Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).imePadding().padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Text(stringResource(R.string.notes_title), style = MaterialTheme.typography.headlineMedium)
        Button(onClick = onAdd) { Text(stringResource(R.string.add_note)) }
        OutlinedTextField(
            value = query, onValueChange = { query = it },
            label = { Text(stringResource(R.string.search_notes)) },
            singleLine = true, modifier = Modifier.fillMaxWidth(),
        )
        when (val current = state) {
            NotesListState.Loading -> CircularProgressIndicator()
            NotesListState.Failed -> {
                Text(stringResource(R.string.load_failed), color = MaterialTheme.colorScheme.error)
                Button(onClick = model::retry) { Text(stringResource(R.string.retry)) }
            }
            is NotesListState.Ready -> {
                val filtered = current.notes.filter { noteMatches(it.title, it.body, query) }
                if (filtered.isEmpty()) {
                    Text(stringResource(if (current.notes.isEmpty()) R.string.no_notes else R.string.no_matches))
                }
                LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
                    items(filtered, key = { it.id }) { note ->
                        Card(onClick = { onOpen(note.id) }, modifier = Modifier.fillMaxWidth()) {
                            Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
                                Text(note.title, style = MaterialTheme.typography.titleMedium)
                                Text(note.body, maxLines = 2, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
                                Text(
                                    stringResource(R.string.updated_at, DateFormat.getDateTimeInstance(
                                        DateFormat.SHORT, DateFormat.SHORT,
                                    ).format(Date(note.updatedAt))),
                                    style = MaterialTheme.typography.bodySmall,
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun NoteEditor(id: Long?, onBack: () -> Unit) {
    val applicationContext = LocalContext.current.applicationContext
    val factory = remember(applicationContext, id) {
        viewModelFactory {
            initializer {
                NoteEditorViewModel(NotesDatabase.get(applicationContext).noteDao(), id, createSavedStateHandle())
            }
        }
    }
    // Navigation supplies a distinct ViewModelStoreOwner for each editor back-stack entry.
    val model: NoteEditorViewModel = viewModel(factory = factory)
    val state by model.state.collectAsStateWithLifecycle()
    var confirmDelete by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(state.finished) { if (state.finished) onBack() }
    MiniScreen(stringResource(if (id == null) R.string.add_note else R.string.edit_note)) {
        TextButton(onClick = onBack, enabled = !state.busy) { Text(stringResource(R.string.back)) }
        Text(stringResource(R.string.draft_help), style = MaterialTheme.typography.bodySmall)
        if (state.loading) {
            CircularProgressIndicator()
        } else if (state.problem == NoteProblem.LoadFailed || state.problem == NoteProblem.Missing) {
            Text(stringResource(if (state.problem == NoteProblem.Missing) R.string.note_missing else R.string.load_failed))
            if (state.problem == NoteProblem.LoadFailed) {
                Button(onClick = model::load) { Text(stringResource(R.string.retry)) }
            }
        } else {
            OutlinedTextField(
                value = state.title, onValueChange = model::setTitle,
                label = { Text(stringResource(R.string.note_title)) },
                singleLine = true, isError = state.titleError, enabled = !state.busy,
                supportingText = { if (state.titleError) Text(stringResource(R.string.title_required)) },
                modifier = Modifier.fillMaxWidth(),
            )
            OutlinedTextField(
                value = state.body, onValueChange = model::setBody,
                label = { Text(stringResource(R.string.note_body)) },
                minLines = 5, maxLines = 10, enabled = !state.busy, modifier = Modifier.fillMaxWidth(),
            )
            if (state.problem == NoteProblem.WriteFailed) {
                Text(stringResource(R.string.write_failed), color = MaterialTheme.colorScheme.error)
            }
            Button(onClick = model::save, enabled = !state.busy) {
                Text(stringResource(if (state.busy) R.string.saving else R.string.save))
            }
            if (id != null) {
                OutlinedButton(onClick = { confirmDelete = true }, enabled = !state.busy) {
                    Text(stringResource(R.string.delete))
                }
            }
        }
    }
    if (confirmDelete) {
        AlertDialog(
            onDismissRequest = { confirmDelete = false },
            title = { Text(stringResource(R.string.delete_title)) },
            text = { Text(stringResource(R.string.delete_body)) },
            confirmButton = {
                TextButton(onClick = { confirmDelete = false; model.delete() }) { Text(stringResource(R.string.delete)) }
            },
            dismissButton = {
                TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.cancel)) }
            },
        )
    }
}

Test the app with concrete scenarios

Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.

Do thisExpected result
Launch with a fresh database.An empty-notebook message appears after loading, with an Add note action.
Save with an empty title or only spaces/newlines.A title validation error appears; no row is inserted and the body draft stays visible.
Save two notes with identical titles but different bodies.Two separate notes appear because generated IDs identify them.
Edit one existing note and save repeatedly with rapid taps.The existing ID is updated once per completed operation; no duplicate note is created.
Search a title word and a body-only word using different letter cases.Both fields are searched without case sensitivity; an absent word shows no matching notes.
Rotate while editing unsaved title and body text.The draft stays intact instead of being overwritten by the saved row.
Use Back on an unsaved draft, then reopen the note or start a new editor.The discarded draft is absent; only an explicitly saved row is loaded.
Cancel deletion, then confirm deletion.Cancel preserves the note; confirmation deletes it and returns to the list.
Save, edit, and delete different notes; end the process and reopen.Only the expected saved rows remain, with the edited content and updated ordering.
Run NoteRulesTest and inspect the error branches; optionally use a test DAO that throws during reads or writes.Rule tests pass; read failures show Retry, write failures retain the draft, and cancellation is rethrown.
Run NotesDatabaseTest on an emulator or device.Flow emits CRUD changes, a closed/reopened database retains the edited row, the deleted row stays absent, and the isolated test database is cleaned up.
Use a small emulator with the keyboard open and large font enabled.The editor can scroll to Save and Delete; the list remains a bounded scrollable viewport.

Your completion checklist

Project acceptance checklist0 / 7 verified

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

Troubleshooting: understand the cause

Room reports that NotesDatabase_Impl does not exist.

Cause: The KSP plugin or room-compiler dependency is missing, or its version differs from runtime.

Repair: Use the supplied app Gradle file, keep Room artifacts on 2.8.4, sync, and rebuild before investigating UI code.

Editing a note creates another row.

Cause: The existing ID was discarded and the save path always called insert with id = 0.

Repair: Pass the Long ID in the edit route, retain recordId in the editor ViewModel, and call update for existing notes.

Unsaved text disappears on rotation.

Cause: Draft state was stored only in local composable variables, or a database reload overwrote restored text.

Repair: Keep title/body in the editor ViewModel and SavedStateHandle, and use the initialized marker to avoid reloading an already restored draft.

All notes disappear after changing the schema.

Cause: Destructive migration fallback or manual database clearing was used as a shortcut.

Repair: Restore data from a backup if available, remove destructive fallback, export schemas for future versions, and write an explicit migration before shipping a schema change.

The list does not reflect an edit until reopening the app.

Cause: The UI copied a one-time database result into an unrelated mutable list.

Repair: Collect the DAO Flow through NotesViewModel so Room invalidation emits the updated table after writes.

Submit your app and explain your decisions

  1. Submit the Android Studio project without build caches, .gradle or machine-specific local.properties.
  2. Include two screenshots demonstrating normal behavior and an edge case.
  3. Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
  4. Answer the questions below using function and state names from your own implementation.
  • Why must the existing primary key be preserved when editing, and why is a title unsuitable as the key?
  • Which values belong in Room and which belong in SavedStateHandle? Compare reopening after process death with rotating an unsaved editor.
  • Why should a database error keep the draft visible, and why must CancellationException be rethrown?
  • How would you add a new column without deleting notes already stored on a student's device?
CriterionPointsWhat to demonstrate
Core behavior40All required actions give the expected result.
Validation and lifecycle20Invalid inputs, empty states, rotation and the project's persistence rules work.
Interface and accessibility20Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets.
Code and explanation20Small named functions, clear state ownership and a correct explanation of one event-to-result trace.

Extra challenges after the core works

  • Add a pinned column, increment the schema version, export schemas, and implement a tested migration that preserves existing notes.
  • Add a repository interface and fake DAO tests for load, write, missing-record, and cancellation behavior.
  • Extend the instrumented Room test to cover missing-row updates and equal-timestamp sorting.
  • Move search into an SQL query for larger data sets and decide how literal percent and underscore characters should behave.
  • Add an explicit export/import feature with the Storage Access Framework and explain backup privacy to users.

Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.

Further reading