Most Android tutorials stop at "it runs on the emulator." But getting an app from a blank project to a listing that real people can install from Google Play involves a second half that's rarely discussed in one place: release builds, signing keys, app bundles, store listings, policy forms, and a mandatory testing period for new developer accounts.
Android development has also changed a lot in the last few years. Jetpack Compose is now the default UI toolkit, XML layouts are legacy, Kotlin is the only language the official templates use, and Google Play has tightened its requirements for new developers.
Older guides that start with findViewById or end with uploading an APK no longer match how things work.
This handbook walks through the whole path. You'll build a small but complete notes app with Jetpack Compose, Room, ViewModel, and Navigation 3, write a couple of tests, produce a signed release bundle, and then take it through the Google Play Console all the way to a production release.
Table of Contents
Prerequisites
You should be comfortable with basic programming concepts and have at least read some Kotlin before. You don't need prior Android experience. Every Android-specific concept is explained as it comes up.
For tools, you need:
A computer running macOS, Windows, or Linux with at least 16 GB of RAM (8 GB works, but the emulator will be slow)
The latest stable version of Android Studio
A physical Android phone (optional for development, but required later for Play Console account verification)
A Google account and 25 USD for the one-time Google Play developer registration fee
What You Will Build
The sample app is called Quick Notes. It shows a list of notes sorted by the last time they were edited, and it lets you create a new note with a floating action button, tap a note to edit it, and delete notes. Everything is stored locally on the device in a SQLite database, so the app works offline and needs no backend.
A notes app sounds simple, but it touches every piece a real app needs: a list screen and a detail screen, navigation between them with a working back button, persistent storage, state that survives configuration changes such as screen rotation, and user input. Once you understand how these pieces fit together, you can swap the notes for anything else.
Here's the high level architecture you'll end up with:
+-------------------------------------------------------------+ | UI Layer | | MainActivity -> NavDisplay -> NoteListScreen | | -> NoteEditorScreen | | observes StateFlow, sends events | +------------------------------+------------------------------+ | v +-------------------------------------------------------------+ | NotesViewModel | | holds UI state, survives rotation, launches coroutines | +------------------------------+------------------------------+ | v +-------------------------------------------------------------+ | Data Layer | | NoteRepository -> NoteDao -> NotesDatabase (Room/SQLite) | +-------------------------------------------------------------+ The diagram shows three layers stacked vertically, with arrows pointing down to show the direction of dependencies.
The UI layer at the top contains the activity and the two screens. It only talks to the ViewModel, never directly to the database.
The ViewModel in the middle owns the state that the screens display and survives events like rotation that would otherwise destroy the screen.
The data layer at the bottom wraps Room, which is a typed layer on top of SQLite. Data flows back up the stack as a Flow of notes, so when the database changes, the UI updates automatically.
This is the layered architecture that Google recommends in its official app architecture guide.
How to Set Up Your Development Environment
Download Android Studio from developer.android.com/studio and run the installer. On first launch, the setup wizard downloads the Android SDK, the platform tools (including adb), and a system image for the emulator. Accept the defaults unless you have a reason not to. Android Studio bundles its own JDK, so you don't need to install Java separately.
Once setup finishes, open the SDK Manager from Tools > SDK Manager and confirm that the latest stable Android platform is installed. At the time of writing, that means at least Android 16 (API level 36).
Google Play requires new apps and app updates to target a recent API level, and the requirement moves forward every year, usually at the end of August. Check the target API level requirements page before you start so you know which number to aim for.
Next, create an emulator from Tools > Device Manager. Pick a recent Pixel phone profile and the newest system image with Google Play services.
If you would rather test on a physical phone, enable Developer Options on the device by tapping Build number seven times in Settings > About phone, then turn on USB debugging (or Wireless debugging) in the Developer Options menu.
How to Create a New Compose Project
In Android Studio, choose File > New > New Project and select the Empty Activity template. This template is Compose-based. The similarly named "Empty Views Activity" uses the older XML layout system, so make sure you pick the right one.
Fill in the project details. The Name is what users see (Quick Notes). The Package name is the permanent, globally unique identifier for your app on Google Play, written in reverse domain format such as com.yourname.quicknotes. Choose it carefully, because you can't change it after you publish. Set Minimum SDK to API 24, which covers nearly every active Android device, and leave the build configuration language as Kotlin DSL.
Click Finish and wait for the first Gradle sync to complete. The project you get looks roughly like this:
QuickNotes/ ├── app/ │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/com/yourname/quicknotes/ │ │ │ ├── MainActivity.kt │ │ │ └── ui/theme/ │ │ └── res/ │ ├── test/ │ └── androidTest/ ├── gradle/ │ └── libs.versions.toml ├── build.gradle.kts └── settings.gradle.kts The tree shows the important files in a fresh project.
The app folder is the single module that becomes your installable app, and its build.gradle.kts holds the Android configuration (SDK levels, version numbers, dependencies).
src/main contains production code, the AndroidManifest.xml that declares your app's components to the system, and the res folder for resources such as icons and strings.
The test folder is for unit tests that run on your computer, while androidTest is for tests that run on a device or emulator.
At the root, gradle/libs.versions.toml is the version catalog where all dependency versions live, and settings.gradle.kts lists the modules in the project. Kotlin files sit under a folder named java for historical reasons, which is normal.
Press the green Run button. After a short build, the emulator should show "Hello Android!" on screen. That confirms your toolchain works.
How to Configure Dependencies with a Version Catalog
Modern Android projects keep every library version in one file, gradle/libs.versions.toml, and refer to those libraries by alias from the build scripts.
Open the catalog and replace its contents with the following:
[versions] agp = "9.0.0" kotlin = "2.3.0" ksp = "2.3.4" composeBom = "2026.01.00" activityCompose = "1.12.2" lifecycle = "2.10.0" navigation3 = "1.0.0" room = "2.8.4" serialization = "1.9.0" junit = "4.13.2" androidxJunit = "1.3.0" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "serialization" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } room = { id = "androidx.room", version.ref = "room" } The catalog has three sections: [versions] names each version number once. [libraries] maps a short alias to a Maven coordinate, and [plugins] does the same for Gradle plugins.
Gradle turns the dashes in each alias into dots, so androidx-room-runtime becomes libs.androidx.room.runtime in your build scripts. The Compose libraries have no version of their own because they get it from the Compose BOM (bill of materials), which pins a set of Compose libraries that are tested together. The Kotlin Compose compiler plugin shares its version with Kotlin itself, which has been the case since Kotlin 2.0.
KSP is the annotation processor Room uses to generate database code. The version numbers here were current when this article was written. Android Studio highlights outdated entries in the catalog, and you should accept its suggestions to move to the latest stable versions.
Now update the root build.gradle.kts so the plugins are available to the app module:
plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.room) apply false } This file declares each plugin with apply false, which puts the plugin on the build classpath without applying it to the root project. Each module then opts into the plugins it actually needs.
You might notice there's no org.jetbrains.kotlin.android plugin. Starting with Android Gradle Plugin 9, Kotlin support is built into the Android plugin, so a separate Kotlin Android plugin is no longer required. If you're working in an older project on AGP 8, you'll still see that plugin applied, and that's fine.
Then replace the contents of app/build.gradle.kts:
plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.room) } android { namespace = "com.yourname.quicknotes" compileSdk = 36 defaultConfig { applicationId = "com.yourname.quicknotes" minSdk = 24 targetSdk = 36 versionCode = 1 versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } buildFeatures { compose = true } } room { schemaDirectory("$projectDir/schemas") } dependencies { val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) implementation(libs.kotlinx.serialization.core) ksp(libs.androidx.room.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } This is the most important configuration file in the project, so read it carefully.
The namespace is the package used for the generated R class, while applicationId is the identity of the app on devices and on Google Play. They usually match.
compileSdk is the API level your code is compiled against, and targetSdk tells Android which platform behaviors your app has been tested with. When you raise targetSdk, newer behaviors such as enforced edge-to-edge drawing switch on.
versionCode is an integer that must increase with every upload to Play, while versionName is the human-readable string users see.
The release block turns on R8, which shrinks, obfuscates, and optimizes your code, and isShrinkResources removes unused resources.
The room block tells the Room plugin where to write JSON snapshots of your database schema, which you'll need later when you write migrations.
In the dependencies, platform(libs.androidx.compose.bom) applies the BOM so the Compose libraries line up, and ksp(...) runs the Room compiler at build time. The debugImplementation lines include Compose preview tooling and a test manifest only in debug builds, so they never ship to users.
Click Sync Now in the banner at the top of the editor. If the sync succeeds, you're ready to write code.
How Jetpack Compose Thinks About UI
Before writing screens, it helps to understand the model Compose is built on, because it's different from the older View system.
In Compose, UI is a function of state. You write functions annotated with @Composable that describe what the screen should look like for the current data. You never reach into a view and call setText on it. Instead, when the data changes, Compose calls your function again with the new data and updates only the parts of the screen that actually changed. This re-running is called recomposition.
Here's the smallest useful example:
@Composable fun Counter() { var count by remember { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text("Clicked $count times") } } The Counter function declares a piece of state with mutableIntStateOf(0). Compose watches every read of that state. When the button's onClick lambda increments count, Compose knows that Counter read the old value, so it schedules Counter to run again, and the Text shows the new number.
The remember call is what keeps the value alive across recompositions. Without it, count would reset to zero every time the function ran.
The by keyword is Kotlin property delegation, which lets you read and write count like a regular variable instead of writing count.value.
remember survives recomposition but not configuration changes such as rotating the phone, because Android destroys and recreates the activity in that case. For simple values that must survive rotation, use rememberSaveable instead. For anything more substantial, such as data loaded from a database, keep the state in a ViewModel, which outlives the activity. You'll use both in this app.
The other core idea is state hoisting. A composable that owns its own state is hard to reuse and hard to test. A better pattern is to pass state in as parameters and pass events out as lambdas, so the composable only displays what it's given and reports what the user did. The caller decides where the state lives. You'll see this pattern in the list screen, where it makes testing straightforward.
How to Structure the App
Create the following packages under com.yourname.quicknotes by right-clicking the package in the Project panel and choosing New > Package:
com.yourname.quicknotes ├── data/ │ ├── Note.kt │ ├── NoteDao.kt │ ├── NotesDatabase.kt │ └── NoteRepository.kt ├── ui/ │ ├── NotesViewModel.kt │ ├── NoteListScreen.kt │ ├── NoteEditorScreen.kt │ └── theme/ ├── navigation/ │ └── NavKeys.kt ├── MainActivity.kt └── NotesApp.kt The layout groups files by layer.
The data package holds everything related to storage: the Note entity, the DAO (data access object) that defines queries, the Room database class, and a repository that the rest of the app uses instead of talking to Room directly.
The ui package contains the ViewModel and the two screens, plus the theme files the template generated.
The navigation package holds the keys that identify each screen. MainActivity is the single activity that hosts all of the Compose UI, and NotesApp is a custom Application class used to create the database once for the whole app.
For a larger app, you would likely split these into separate Gradle modules, but packages are enough here.
How to Store Data Locally with Room
Room is the Jetpack persistence library. You describe tables as Kotlin classes and queries as annotated interface methods, and Room generates the SQLite code at compile time. Because queries are checked during the build, a typo in a column name becomes a compiler error instead of a crash on a user's phone.
Start with the entity in data/Note.kt:
package com.yourname.quicknotes.data 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() ) @Entity tells Room that this class maps to a table named notes, and each property becomes a column.
The id field is the primary key, and autoGenerate = true asks SQLite to assign a new ID when you insert a row whose ID is 0. That's why id defaults to 0: a new note doesn't have a real ID until it's saved.
The updatedAt property stores a timestamp in milliseconds and defaults to the current time whenever a Note object is created, which is how the app knows which notes were edited most recently.
Using a plain data class gives you equals, hashCode, and copy for free, which Compose relies on to decide whether a list item has changed.
Next, define the queries in data/NoteDao.kt:
package com.yourname.quicknotes.data import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @Dao interface NoteDao { @Query("SELECT * FROM notes ORDER BY updatedAt DESC") fun observeAll(): Flow<List<Note>> @Query("SELECT * FROM notes WHERE id = :id") suspend fun getById(id: Long): Note? @Upsert suspend fun upsert(note: Note) @Delete suspend fun delete(note: Note) } A DAO is an interface whose implementation Room writes for you. The observeAll method returns a Flow<List<Note>> rather than a plain list. A Flow is a stream from Kotlin coroutines that emits a new value whenever the underlying data changes, so any time a note is inserted, updated, or deleted, Room automatically re-runs the query and emits the fresh list. This is what keeps the list screen in sync without any manual refresh logic.
The other methods are marked suspend, which means they must be called from a coroutine. Room runs them on a background thread, so database work never blocks the main thread and freezes the UI.
@Upsert inserts a row if its primary key doesn't exist and updates it if it does, which lets one method handle both creating and editing a note.
The :id syntax in the second query binds the method parameter to the SQL statement safely, with no string concatenation.
Now create the database in data/NotesDatabase.kt:
package com.yourname.quicknotes.data import androidx.room.Database import androidx.room.RoomDatabase @Database(entities = [Note::class], version = 1, exportSchema = true) abstract class NotesDatabase : RoomDatabase() { abstract fun noteDao(): NoteDao } The @Database annotation lists every entity in the database and a schema version number. Room generates a concrete subclass of this abstract class, including the implementation of noteDao().
The version field matters once your app is live. If you later add a column to Note, you must bump the version and provide a migration, or Room will refuse to open the existing database on users' devices.
exportSchema = true writes a JSON description of each schema version to the schemas folder you configured in Gradle. Commit that folder to version control, because Room uses those files to verify and auto-generate migrations.
How to Expose Data with a Repository and Application Class
The repository is a thin layer between the ViewModel and Room. In this app it mostly forwards calls, but it gives you one place to add caching, network sync, or validation later without touching the UI code. Create data/NoteRepository.kt:
package com.yourname.quicknotes.data import kotlinx.coroutines.flow.Flow class NoteRepository(private val noteDao: NoteDao) { fun observeNotes(): Flow<List<Note>> = noteDao.observeAll() suspend fun getNote(id: Long): Note? = noteDao.getById(id) suspend fun save(note: Note) = noteDao.upsert(note) suspend fun delete(note: Note) = noteDao.delete(note) } The repository takes a NoteDao in its constructor instead of creating one itself. This is dependency injection in its simplest form. Because the dependency is passed in, a test could hand the repository a fake DAO. The public methods use domain language (save, observeNotes) rather than database language (upsert, observeAll), which keeps Room details from leaking into the rest of the app.
The database should be created exactly once per app process, because opening SQLite is expensive and multiple instances can conflict. A common way to do that without a dependency injection framework is a custom Application subclass. Create NotesApp.kt in the root package:
package com.yourname.quicknotes import android.app.Application import androidx.room.Room import com.yourname.quicknotes.data.NoteRepository import com.yourname.quicknotes.data.NotesDatabase class NotesApp : Application() { private val database: NotesDatabase by lazy { Room.databaseBuilder(this, NotesDatabase::class.java, "notes.db").build() } val repository: NoteRepository by lazy { NoteRepository(database.noteDao()) } } Android creates one instance of the Application class before any activity starts, and keeps it for the lifetime of the process. That makes it a natural home for app-wide singletons.
The by lazy delegate defers creating the database until the first time repository is accessed, which keeps app startup fast.
Room.databaseBuilder takes a context, the database class, and a file name, and build() returns the generated implementation. For larger apps, Hilt or Koin can manage this wiring for you, but the manual approach shows exactly what those frameworks automate.
For Android to use this class, register it in AndroidManifest.xml by adding the android:name attribute to the <application> tag:
<application android:name=".NotesApp" android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.QuickNotes"> The only line you're adding is android:name=".NotesApp". The leading dot is shorthand for your app's namespace, so Android resolves it to com.yourname.quicknotes.NotesApp.
The other attributes came from the template: the launcher icon, the app label shown under the icon, right-to-left layout support, and the base theme used before Compose draws its first frame. If you forget the android:name attribute, Android uses the default Application class and your app crashes the first time it tries to cast it to NotesApp.
How to Manage UI State with a ViewModel
A ViewModel holds the state for a screen and survives configuration changes. When the user rotates the phone, the activity is destroyed and recreated, but the ViewModel stays in memory, so the list of notes doesn't need to be reloaded. Create ui/NotesViewModel.kt:
package com.yourname.quicknotes.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.yourname.quicknotes.NotesApp import com.yourname.quicknotes.data.Note import com.yourname.quicknotes.data.NoteRepository import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class NotesViewModel(private val repository: NoteRepository) : ViewModel() { val notes: StateFlow<List<Note>> = repository.observeNotes() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) suspend fun getNote(id: Long): Note? = repository.getNote(id) fun saveNote(id: Long?, title: String, body: String) { viewModelScope.launch { repository.save(Note(id = id ?: 0, title = title.trim(), body = body.trim())) } } fun deleteNote(note: Note) { viewModelScope.launch { repository.delete(note) } } companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NotesApp NotesViewModel(app.repository) } } } } There are three ideas packed into this class.
The first is the notes property. repository.observeNotes() returns a cold Flow, which only does work while someone is collecting it. The stateIn operator converts it into a StateFlow, which always holds a current value (starting with an empty list) that the UI can read immediately. SharingStarted.WhileSubscribed(5_000) keeps the database query active while the UI is observing and for five seconds after it stops. That five second grace period means a quick rotation doesn't tear down and restart the query, while backgrounding the app for longer does release it.
The second idea is viewModelScope. Saving and deleting are suspend functions, so they need a coroutine. viewModelScope is a coroutine scope tied to the ViewModel's lifetime, and any work launched in it is cancelled automatically when the ViewModel is cleared.
In saveNote, a null ID means "this is a new note," so it becomes 0 and Room generates a fresh ID. A non-null ID updates the existing row. Because a new Note object is created on every save, updatedAt is refreshed to the current time.
The third idea is the Factory. Android creates ViewModels for you so it can keep them alive across rotations, which means you can't call the constructor directly.
A factory tells Android how to build the ViewModel when it needs one. The viewModelFactory and initializer builders read the Application instance out of the creation extras, cast it to NotesApp, and pass its repository into the constructor.
How to Build the Note List Screen
The list screen is split into two composables. A stateful wrapper connects to the ViewModel, and a stateless content composable does the actual drawing. Create ui/NoteListScreen.kt:
package com.yourname.quicknotes.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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 com.yourname.quicknotes.data.Note @Composable fun NoteListScreen( viewModel: NotesViewModel, onAddNote: () -> Unit, onOpenNote: (Long) -> Unit ) { val notes by viewModel.notes.collectAsStateWithLifecycle() NoteListContent( notes = notes, onAddNote = onAddNote, onOpenNote = onOpenNote, onDeleteNote = viewModel::deleteNote ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteListContent( notes: List<Note>, onAddNote: () -> Unit, onOpenNote: (Long) -> Unit, onDeleteNote: (Note) -> Unit ) { Scaffold( topBar = { TopAppBar(title = { Text("Quick Notes") }) }, floatingActionButton = { FloatingActionButton(onClick = onAddNote) { Icon(Icons.Default.Add, contentDescription = "Add note") } } ) { innerPadding -> if (notes.isEmpty()) { Box( modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center ) { Text("No notes yet", style = MaterialTheme.typography.bodyLarge) } } else { LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxSize().padding(innerPadding) ) { items(notes, key = { it.id }) { note -> NoteCard( note = note, onClick = { onOpenNote(note.id) }, onDelete = { onDeleteNote(note) } ) } } } } } @Composable private fun NoteCard(note: Note, onClick: () -> Unit, onDelete: () -> Unit) { Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text(note.title, style = MaterialTheme.typography.titleMedium) if (note.body.isNotBlank()) { Text( note.body, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis ) } } IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Delete ${note.title}") } } } } NoteListScreen is the only part that knows about the ViewModel. It calls collectAsStateWithLifecycle(), which turns the StateFlow into Compose state and, unlike the plain collectAsState(), stops collecting when the app moves to the background. That saves battery and avoids doing database work for a screen nobody can see. It then passes the plain list and some lambdas into NoteListContent.
The viewModel::deleteNote syntax is a function reference, a compact way of writing { note -> viewModel.deleteNote(note) }.
NoteListContent is stateless. It receives everything as parameters and reports clicks through lambdas, which is state hoisting in practice. Inside it, Scaffold is a Material 3 layout that provides slots for a top app bar and a floating action button and calculates innerPadding so your content doesn't end up behind those bars or behind the system status and navigation bars.
When the list is empty, the screen shows a centered message. Otherwise it uses LazyColumn, which only composes the rows that are currently visible, like RecyclerView in the old View system.
The key = { it.id } argument gives each row a stable identity, so when a note is deleted Compose knows exactly which row to remove instead of redrawing everything below it.
NoteCard draws one note. Modifier chains configure layout and behavior in order: fillMaxWidth() stretches the card across the screen and clickable makes the whole card respond to taps.
Inside the Row, Modifier.weight(1f) tells the text column to take all horizontal space left over after the delete button is measured. The contentDescription values on icons are read aloud by TalkBack, Android's screen reader. Including the note title in the delete button's description means a TalkBack user hears "Delete Groceries" instead of a row of identical "Delete" buttons.
How to Build the Note Editor Screen
The editor handles both new notes and existing ones. Create ui/NoteEditorScreen.kt:
package com.yourname.quicknotes.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteEditorScreen( noteId: Long?, viewModel: NotesViewModel, onDone: () -> Unit ) { var title by rememberSaveable { mutableStateOf("") } var body by rememberSaveable { mutableStateOf("") } var loaded by rememberSaveable { mutableStateOf(noteId == null) } LaunchedEffect(noteId) { if (noteId != null && !loaded) { viewModel.getNote(noteId)?.let { title = it.title body = it.body } loaded = true } } Scaffold( topBar = { TopAppBar( title = { Text(if (noteId == null) "New note" else "Edit note") }, navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { TextButton( onClick = { viewModel.saveNote(noteId, title, body) onDone() }, enabled = title.isNotBlank() ) { Text("Save") } } ) } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .imePadding() .padding(16.dp) ) { OutlinedTextField( value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(12.dp)) OutlinedTextField( value = body, onValueChange = { body = it }, label = { Text("Note") }, modifier = Modifier.fillMaxWidth().weight(1f) ) } } } The editor keeps the text the user is typing in local state with rememberSaveable, so a half-written note survives a rotation. The loaded flag tracks whether the existing note has already been read from the database. It starts as true for new notes (nothing to load) and false for existing ones.
LaunchedEffect(noteId) runs a coroutine when the composable first appears and again only if noteId changes. Inside it, the screen asks the ViewModel for the note and copies its title and body into the text fields.
Checking loaded before loading is important. Without that check, rotating the screen would run the effect again and overwrite whatever the user had typed with the saved version from the database.
OutlinedTextField is a Material 3 text input. Compose text fields are controlled components: the field shows whatever value you pass in, and when the user types, it calls onValueChange with the new text. If you forget to update title in that callback, the field appears frozen because Compose keeps redrawing the old value. The Save button is disabled while the title is blank, and pressing it saves through the ViewModel and calls onDone to go back.
imePadding() adds space at the bottom equal to the height of the on-screen keyboard, so the body field shrinks instead of hiding behind the keyboard. The back arrow uses the AutoMirrored icon set, which flips automatically in right-to-left languages such as Arabic and Hebrew.
How to Add Navigation with Navigation 3
Navigation 3 is the Compose-first navigation library from the Jetpack team. The earlier Navigation Compose library was adapted from a system designed for fragments and treated the back stack as something it owned internally.
Navigation 3 takes the opposite approach: the back stack is a plain list that you own, and navigating means adding or removing items from that list. The library observes the list and shows the screen for whatever key is on top.
First, define a key for each screen in navigation/NavKeys.kt:
package com.yourname.quicknotes.navigation import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable data object NoteList : NavKey @Serializable data class NoteEditor(val noteId: Long? = null) : NavKey Each destination is a type that implements NavKey. NoteList takes no arguments, so it's a data object, a singleton with a readable toString.
NoteEditor carries an optional note ID, where null means "create a new note." Arguments are typed properties instead of strings embedded in a URL route, so the compiler catches mistakes such as passing the wrong type.
The @Serializable annotation lets Navigation 3 save the back stack when Android kills your app in the background and restore it when the user comes back, which is why the serialization plugin was added earlier.
Now replace MainActivity.kt with the following:
package com.yourname.quicknotes import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.yourname.quicknotes.navigation.NoteEditor import com.yourname.quicknotes.navigation.NoteList import com.yourname.quicknotes.ui.NoteEditorScreen import com.yourname.quicknotes.ui.NoteListScreen import com.yourname.quicknotes.ui.NotesViewModel import com.yourname.quicknotes.ui.theme.QuickNotesTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { QuickNotesTheme { QuickNotesNavHost() } } } } @Composable fun QuickNotesNavHost() { val backStack = rememberNavBackStack(NoteList) val notesViewModel: NotesViewModel = viewModel(factory = NotesViewModel.Factory) NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = entryProvider { entry<NoteList> { NoteListScreen( viewModel = notesViewModel, onAddNote = { backStack.add(NoteEditor()) }, onOpenNote = { id -> backStack.add(NoteEditor(id)) } ) } entry<NoteEditor> { key -> NoteEditorScreen( noteId = key.noteId, viewModel = notesViewModel, onDone = { backStack.removeLastOrNull() } ) } } ) } MainActivity is now just a host. enableEdgeToEdge() lets the app draw behind the status bar and navigation bar (more on that in the next section), and setContent replaces the traditional XML layout with a Compose tree wrapped in the theme the template generated.
QuickNotesNavHost is where navigation happens. rememberNavBackStack(NoteList) creates a saveable back stack that starts with the list screen. The ViewModel is created once with the factory you wrote earlier and shared by both screens, since they work with the same notes.
NavDisplay renders the entry for the key at the top of the stack. The entryProvider block maps each key type to its composable with entry<T>. For NoteEditor, the lambda receives the key instance, so key.noteId gives you the typed argument directly.
Navigating forward is backStack.add(...) and going back is backStack.removeLastOrNull(). That's the whole API for basic navigation.
onBack is called when the user presses the system back button or makes the back gesture, and it removes the top entry. Navigation 3 also supports predictive back, the animation where the previous screen peeks in as the user swipes, without any extra code from you.
Run the app now. You should be able to add notes, see them appear in the list, tap one to edit it, delete notes, rotate the device without losing anything, and close and reopen the app with all notes still there.

