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
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
- 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. - 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.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - 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.
- 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.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
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.
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.
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.
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.
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.
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.
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.
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
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.
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() })
}Stop and check
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.
@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
}Stop and check
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.
@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? = nullStop and check
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.
@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 }
}
}
}Stop and check
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.
@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,
)Stop and check
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.
@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)) }
}Stop and check
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.
}
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) {Stop and check
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.
}
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) }
}
}
}
}Stop and check
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.
context.applicationContext,
NotesDatabase::class.java,
"pocket-notes.db",
).build().also { instance = it }
}
}
}Stop and check
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.
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.Stop and check
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 here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | State, rules, navigation or persistence introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student 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.
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 this | Expected 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
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
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- 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?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small 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.