Bryn Flow

🤖 Build a Notes App with Jetpack Compose and Room

A complete, project-based walkthrough — from an empty Android Studio project to a working notes app with a local database. No prior Compose experience required.

Beginner–Intermediate ⏱️ ~45 minutes 🧰 Kotlin, Jetpack Compose, Room
← Android Track

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.ktNoteDao.kt + AppDatabase.ktNotesViewModel.ktNotesScreen.ktMainActivity.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.

What to Try Next

Want more Android walkthroughs?

Head back to the Android track for the full topic list, roadmap, and practice quiz.

Back to Android Track