Project 5: Notes App with a Room Database

Android Development with Kotlin: 21 Real Projects · Building Real Apps · 11 min read · Updated

Room keeps data on the phone

  1. The user saves a note. The screen calls the ViewModel.
  2. The ViewModel calls the DAO, which runs the SQL.
  3. The row is written to the SQLite file on the phone and stays there.
  4. Because the list is a Flow, the database sends the new list back and the screen redraws.

So far every app forgot its data when closed. Real apps need to store information on the device. Android has SQLite built in, a small database in a file, and the Room library makes it pleasant to use from Kotlin. You describe your table as a class, write queries as functions, and Room generates all the boring code and checks your SQL when the app is built.

Concepts in this project

  • Entity: a data class marked @Entity. One class is one table and one object is one row.
  • DAO (data access object): an interface with functions marked @Query, @Upsert and @Delete. You write the SQL, Room writes the code.
  • Database: an abstract class marked @Database that lists the entities and gives you the DAOs.
  • Flow: a DAO function that returns Flow<List<Note>> emits a new list every time the table changes. The screen updates by itself, with no refresh code.
  • KSP: a build tool that Room uses to generate its code. You add it as a plugin.

Create the project

  1. New Empty Activity project Notes, package com.example.notes.
  2. Add the KSP plugin and the Room, ViewModel and lifecycle libraries from the snippet below, then sync.
  3. Create the six files.

`Gradle additions for projects 5 to 7`

// Top of app/build.gradle.kts  -> plugins { ... }  (add the KSP plugin; match its version to your Kotlin version)
id("com.google.devtools.ksp") version "2.0.21-1.0.28"
// (In the project-level build file, or in settings, declare the same plugin with "apply false" if Android Studio asks.)

// app/build.gradle.kts  -> dependencies { ... }
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")                                   // generates the database code at build time

// Project 6 only (weather):
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// and in the plugins block: id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
Notes/app/src/main/java/com/example/notes/
  Note.kt           the entity (the table)
  NoteDao.kt        queries
  AppDatabase.kt    the database
  NotesViewModel.kt search + data for the screen
  NotesScreen.kt    list, search box, add/edit dialog
  MainActivity.kt   creates the database and shows the screen

The code

`app/src/main/java/com/example/notes/Note.kt`

package com.example.notes

import androidx.room.Entity
import androidx.room.PrimaryKey

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

`app/src/main/java/com/example/notes/NoteDao.kt`

package com.example.notes

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow

@Dao
interface NoteDao {
    // A Flow re-emits every time the table changes, so the screen updates by itself.
    @Query("SELECT * FROM notes ORDER BY updatedAt DESC")
    fun observeAll(): Flow<List<Note>>

    @Upsert
    suspend fun save(note: Note)

    @Delete
    suspend fun delete(note: Note)
}

@Upsert inserts a new note or updates an existing one with the same ID, so one function covers both Add and Edit. The search query uses LIKE with percent signs on either side to match text anywhere in the title or body.

`app/src/main/java/com/example/notes/AppDatabase.kt`

package com.example.notes

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

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

    companion object {
        @Volatile private var instance: AppDatabase? = null

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

`app/src/main/java/com/example/notes/NotesViewModel.kt`

package com.example.notes

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

class NotesViewModel(app: Application) : AndroidViewModel(app) {
    private val dao = AppDatabase.get(app).noteDao()

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query.asStateFlow()

    // Combine the database flow with the search text. The result is a new list every time either changes.
    val notes: StateFlow<List<Note>> = combine(dao.observeAll(), _query) { all, q ->
        if (q.isBlank()) all
        else all.filter { it.title.contains(q, ignoreCase = true) || it.body.contains(q, ignoreCase = true) }
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun setQuery(text: String) { _query.value = text }

    fun save(id: Long, title: String, body: String) {
        if (title.isBlank() && body.isBlank()) return
        viewModelScope.launch {
            dao.save(Note(id = id, title = title.trim().ifEmpty { "Untitled" }, body = body.trim()))
        }
    }

    fun delete(note: Note) {
        viewModelScope.launch { dao.delete(note) }
    }
}

The ViewModel holds the search text as a StateFlow. flatMapLatest switches to a fresh database query each time the text changes and drops the old one. The database work runs off the main thread automatically, because Room Flow queries do.

`app/src/main/java/com/example/notes/NotesScreen.kt`

package com.example.notes

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesScreen(vm: NotesViewModel = viewModel()) {
    val notes by vm.notes.collectAsStateWithLifecycle()
    val query by vm.query.collectAsStateWithLifecycle()
    // null = no dialog, a Note with id 0 = new note, otherwise editing that note
    var editing by remember { mutableStateOf<Note?>(null) }

    Scaffold(
        topBar = { TopAppBar(title = { Text("Notes") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = { editing = Note(title = "", body = "") }) {
                Icon(Icons.Default.Add, contentDescription = "New note")
            }
        }
    ) { padding ->
        Column(Modifier.padding(padding).padding(horizontal = 16.dp)) {
            OutlinedTextField(
                value = query,
                onValueChange = vm::setQuery,
                label = { Text("Search notes") },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(Modifier.height(8.dp))
            if (notes.isEmpty()) {
                Text(
                    if (query.isBlank()) "No notes yet. Tap + to write one." else "No notes match \"$query\".",
                    modifier = Modifier.padding(top = 32.dp)
                )
            }
            LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                items(notes, key = { it.id }) { note ->
                    Card(Modifier.fillMaxWidth().clickable { editing = note }) {
                        Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
                            Column(Modifier.weight(1f)) {
                                Text(note.title, style = MaterialTheme.typography.titleMedium)
                                Text(note.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
                            }
                            IconButton(onClick = { vm.delete(note) }) {
                                Icon(Icons.Default.Delete, contentDescription = "Delete note")
                            }
                        }
                    }
                }
            }
        }
    }

    editing?.let { note ->
        var title by remember(note.id) { mutableStateOf(note.title) }
        var body by remember(note.id) { mutableStateOf(note.body) }
        AlertDialog(
            onDismissRequest = { editing = null },
            title = { Text(if (note.id == 0L) "New note" else "Edit note") },
            text = {
                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    OutlinedTextField(title, { title = it }, label = { Text("Title") }, singleLine = true)
                    OutlinedTextField(body, { body = it }, label = { Text("Note") }, minLines = 3)
                }
            },
            confirmButton = { TextButton(onClick = { vm.save(note.id, title, body); editing = null }) { Text("Save") } },
            dismissButton = { TextButton(onClick = { editing = null }) { Text("Cancel") } }
        )
    }
}

`app/src/main/java/com/example/notes/MainActivity.kt`

package com.example.notes

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { MaterialTheme { NotesScreen() } }
    }
}

Expected output

You see a search box, an empty-list message and a floating + button. Tap + and a dialog asks for a title and text. Save, and the note appears in the list at once. Tap a note to edit it. Long-press or use the delete icon to remove it. Type in the search box and the list narrows as you type. Close the app completely and reopen it: the notes are still there, which is the whole point of Room.

Your task

  1. Build the app, add three notes and check that they survive closing the app.
  2. Add a createdAt column and show the date under each title. Raise the version number and remember to uninstall the old build.
  3. Sort notes with the newest first using ORDER BY.
  4. Add a pin flag and show pinned notes above the others.

How to check it worked

  • Notes persist after the app is killed.
  • Search narrows the list as you type.
  • You can explain what an entity, a DAO and a Flow are.

Frequently asked questions

Why does Room use Flow?

A Flow query keeps watching the table. Whenever a row changes it sends the new result, so the screen stays up to date without any manual refresh.

What is a migration?

A migration is code that upgrades an old database to the new table layout without deleting the user's data. You need one every time you change an entity in a released app.

Can I run Room queries on the main thread?

Not by default, and you should not. Suspend functions and Flow queries run on a background thread for you.