What You'll Build
A single-screen notes app: a scrollable list of notes, a floating action button that opens a dialog to add a new note, swipe-free tap-to-delete, and everything persisted locally with Room so notes survive an app restart.
Prerequisites
- Android Studio (Hedgehog or newer) with a Compose-enabled project already created
- Basic Kotlin syntax — variables, functions, classes
- Helpful but not required: read Jetpack Compose Basics and Room Database first
Step by Step
1 Add dependencies
Open your module's build.gradle.kts and add Room plus its Kotlin Symbol Processing (KSP) compiler. Room needs the compiler to generate the database implementation at build time.
// build.gradle.kts (module)
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
}
Sync the project after adding these. If KSP isn't already set up, add id("com.google.devtools.ksp") version "1.9.22-1.0.17" to your top-level build.gradle.kts plugins block (match the version to your Kotlin version).
2 Define the Note entity
An @Entity class maps directly to a database table. Each instance is one row.
// Note.kt
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val title: String,
val body: String,
val createdAt: Long = System.currentTimeMillis()
)
3 Write the DAO and Database
The DAO (Data Access Object) declares the queries you need. Returning a Flow from a query means the UI automatically re-collects whenever the underlying table changes — no manual refresh logic required.
// NoteDao.kt
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY createdAt DESC")
fun getAllNotes(): Flow<List<Note>>
@Insert
suspend fun insert(note: Note)
@Delete
suspend fun delete(note: Note)
}
// AppDatabase.kt
@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 getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"notes.db"
).build().also { INSTANCE = it }
}
}
}
The getInstance singleton pattern guarantees only one database connection exists for the whole app, even if multiple screens try to access it at once.
4 Build the ViewModel
The ViewModel exposes notes as a StateFlow the UI can collect, and offers addNote/deleteNote functions that run on a coroutine so the database work never blocks the main thread.
// NotesViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class NotesViewModel(private val dao: NoteDao) : ViewModel() {
val notes: StateFlow<List<Note>> = dao.getAllNotes()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun addNote(title: String, body: String) {
if (title.isBlank()) return
viewModelScope.launch {
dao.insert(Note(title = title, body = body))
}
}
fun deleteNote(note: Note) {
viewModelScope.launch { dao.delete(note) }
}
}
class NotesViewModelFactory(private val dao: NoteDao) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return NotesViewModel(dao) as T
}
}
stateIn converts the cold Flow from Room into a hot StateFlow that Compose can collect with collectAsState(). WhileSubscribed(5000) keeps it active for 5 seconds after the last collector disappears, so a quick screen rotation doesn't restart the query from scratch.
5 Build the Compose UI
The screen composable is intentionally "dumb" — it only reads state and forwards events, keeping all logic in the ViewModel (state hoisting, covered in topic 4).
// NotesScreen.kt
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun NotesScreen(viewModel: NotesViewModel) {
val notes by viewModel.notes.collectAsState()
var showDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = { TopAppBar(title = { Text("My Notes") }) },
floatingActionButton = {
FloatingActionButton(onClick = { showDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "Add note")
}
}
) { padding ->
if (notes.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text("No notes yet — tap + to add one")
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(notes, key = { it.id }) { note ->
NoteRow(note = note, onDelete = { viewModel.deleteNote(note) })
}
}
}
}
if (showDialog) {
AddNoteDialog(
onDismiss = { showDialog = false },
onSave = { title, body ->
viewModel.addNote(title, body)
showDialog = false
}
)
}
}
@Composable
fun NoteRow(note: Note, onDelete: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(12.dp)) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(note.title, style = MaterialTheme.typography.titleMedium)
Text(note.body, style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete note")
}
}
}
}
@Composable
fun AddNoteDialog(onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
var title by remember { mutableStateOf("") }
var body by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New note") },
text = {
Column {
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") })
Spacer(Modifier.height(8.dp))
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text("Body") })
}
},
confirmButton = { TextButton(onClick = { onSave(title, body) }) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
6 Wire it up in MainActivity
// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val dao = AppDatabase.getInstance(applicationContext).noteDao()
setContent {
MaterialTheme {
val viewModel: NotesViewModel = viewModel(factory = NotesViewModelFactory(dao))
NotesScreen(viewModel = viewModel)
}
}
}
}
Run the app. You should be able to add notes, see them appear instantly at the top of the list (thanks to the Flow-driven query), delete them, and — because Room persists to disk — reopen the app later and find them still there.
Final Working Code
All five files together, in the order you'd create them in a real project: Note.kt → NoteDao.kt + AppDatabase.kt → NotesViewModel.kt → NotesScreen.kt → MainActivity.kt, exactly as shown in the steps above. Copy each block into its own file with a matching name and the app will build as-is — nothing was left out or abbreviated for the walkthrough.