Personal Bookmark Manager App
Build a two-screen Android app with Jetpack Compose, ViewModel, and Navigation.
Introduction
30 Second Summary
You probably have dozens of links scattered across browser tabs, notes apps, and messaging threads. What if you could build your own app to organize them all in one place?
In this project, you will build a Personal Bookmark Manager Android app using Kotlin and Jetpack Compose. You'll create two screens, manage state with a ViewModel, and navigate between them using type-safe routes.
What You'll Build
A native Android app with two screens: a bookmark list displaying categorized cards, and a form screen for adding new bookmarks.
By the end of this project, you'll have:
- A Bookmark List screen with a TopAppBar, categorized LazyColumn, and a floating action button.
- An Add Bookmark screen with text field inputs, form validation, and ViewModel-powered state management using StateFlow.
- Compose Navigation with type-safe routes and unidirectional data flow connecting both screens.
- Secret Mission: Add a category filter using FilterChip composables to search bookmarks by category.
Are there any prerequisites?
This project is designed for developers with Flutter experience who want to learn native Android development. Basic programming knowledge and familiarity with declarative UI patterns (like Widgets and state management) will help you pick up Compose quickly.
Create the Project
Every Jetpack Compose app starts as an Android Studio project. The project template sets up Kotlin, the Compose compiler, and Material 3 theming for you automatically.
In this step, you will create a new Compose project, confirm it runs on your physical device, and add the extra dependencies your Bookmark Manager needs for navigation and state management.
In this step, get ready to:
- Create a new Empty Activity project named BookmarkManager.
- Verify the app runs on your physical Android device.
- Add navigation, serialization, and ViewModel dependencies to your Gradle file.
Create a new project in Android Studio
The Empty Activity template gives you a ready-made Compose project with a single screen displaying a greeting. This is the fastest way to get a working Compose app.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Android Studio and press Enter to open it.
- On the Welcome screen, click New Project.
- Make sure the Phone and Tablet tab is selected at the top.
- Select the Empty Activity template, then click Next.
What is the Empty Activity template?
This template creates a Compose-ready project with all the correct dependencies and a single MainActivity.kt file. It displays "Hello Android!" on screen using a composable function.
The Configure your project screen appears next. This is where you name your app and set target API levels.
- In the Name field, type BookmarkManager.
- Verify the Package name shows com.example.bookmarkmanager.
- Leave Save location as the default.
- In the Minimum SDK dropdown, select API 24: Android 7.0 (Nougat).
- Click Finish.
Why API 24?
API 24 (Android 7.0) covers over 99% of active Android devices while giving access to all modern Compose and Material 3 features. This is the sweet spot between broad device coverage and modern API access.
Android Studio will spend a minute or two downloading dependencies and indexing your project. Wait for the progress bar at the bottom to finish before continuing.
Run the app on your physical device
Before writing any code, confirm the default template works on your device. This catches connection issues early.
- Connect your Android device to your computer with a USB cable.
- Your device will show a dialog asking you to allow USB debugging. Check Always allow from this computer and tap OK.
- In Android Studio, check the device dropdown in the toolbar. Your physical device name should appear.
- Click the green Run button (triangle icon) in the toolbar.
Android Studio builds and installs the app on your device. You should see a screen displaying "Hello Android!" text.
Device not showing in the dropdown?
- Make sure USB debugging is enabled on your device. Go to Settings then Developer options and toggle USB debugging on.
- Try unplugging and replugging the USB cable.
- On Windows, you may need to install a USB driver for your device manufacturer. Check your device brand's support site for the correct driver.
- If the device still shows as "unauthorized", disconnect the cable, go to Settings then Developer options then Revoke USB debugging authorizations. Reconnect and accept the dialog again.
Still stuck? Help me connect my Android device to Android Studio for USB debugging.
Add navigation and serialization dependencies
The default template includes basic Compose libraries, but your Bookmark Manager needs extra packages for screen navigation, state management, and type-safe routes.
- In the Project pane on the left side of Android Studio, expand Gradle Scripts.
- Double-click build.gradle.kts (Module :app) to open it.
At the top of this file, you will see a plugins block. You need to add the Kotlin serialization plugin here.
- Find the plugins block at the top of the file. It looks like this:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
- Add the serialization plugin as the last line inside the plugins block, so it now looks like this:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.0.21"
}
What does the serialization plugin do?
The kotlinx-serialization plugin lets Kotlin convert objects to and from JSON. You will use it later for type-safe navigation routes, where each screen is defined as a serializable object instead of a raw string path.
Now scroll down to the dependencies block. You need to replace the entire block with a version that includes navigation, ViewModel, and serialization libraries.
- Select everything inside the dependencies { ... } block (including the opening and closing braces).
- Replace it with this code:
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2026.05.00")
implementation(composeBom)
androidTestImplementation(composeBom)
// Compose UI
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
// Activity Compose
implementation("androidx.activity:activity-compose:1.13.0")
// ViewModel Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
// Navigation Compose
implementation("androidx.navigation:navigation-compose:2.9.8")
// Kotlinx Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Material Icons Extended
implementation("androidx.compose.material:material-icons-extended")
}
What do these dependencies do?
- Compose BOM manages all Compose library versions from a single declaration, so they stay compatible with each other.
- navigation-compose provides NavHost and NavController for moving between screens. This is the Compose equivalent of GoRouter in Flutter.
- lifecycle-viewmodel-compose gives you the viewModel() function to create and scope ViewModels. Think of ViewModel as the Compose equivalent of a Provider or Riverpod notifier.
- kotlinx-serialization-json enables type-safe navigation routes using @Serializable objects instead of string paths.
- material-icons-extended gives access to the full Material icon set (like Icons.Filled.Delete and Icons.Filled.Add).
- A yellow banner appears at the top of the editor saying Gradle files have changed. Click Sync Now.
- Wait for the sync to complete. The progress bar at the bottom should finish with no errors.
Seeing a sync error?
- Check that the serialization plugin version "2.0.21" matches your project's Kotlin version. Open build.gradle.kts (Project) or libs.versions.toml to confirm the Kotlin version.
- Make sure you replaced the entire dependencies { } block, including both braces. A stray brace causes parsing errors.
- Confirm you have an internet connection. Gradle needs to download the new libraries.
Still stuck? Help me fix my Gradle sync error after adding dependencies.
✔️ Awesome, I've got everything!
Great. Make sure you have saved build.gradle.kts and the sync completed without errors.
ⓧ I'd like to double check the full code
Here is the full app/build.gradle.kts file with the plugins and dependencies sections. Compare your file to this:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.0.21"
}
// ... existing android block ...
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2026.05.00")
implementation(composeBom)
androidTestImplementation(composeBom)
// Compose UI
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
// Activity Compose
implementation("androidx.activity:activity-compose:1.13.0")
// ViewModel Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
// Navigation Compose
implementation("androidx.navigation:navigation-compose:2.9.8")
// Kotlinx Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Material Icons Extended
implementation("androidx.compose.material:material-icons-extended")
}
Your project is set up and ready to go. In the next step, you will build the data model and ViewModel that will hold your bookmarks in memory.
Build the Data Model and ViewModel
Your project is set up and running on your device. Now it's time to create the data structures that power your bookmark app.
Every app needs a way to represent its data and manage changes to it. In this step, you'll create a Kotlin data class to define what a bookmark looks like, and a ViewModel to hold the list of bookmarks in memory.
In this step, get ready to:
- Create a Bookmark data class with id, title, url, and category fields.
- Build a BookmarkViewModel to hold and manage a list of bookmarks.
- Add functions to add and delete bookmarks from the list.
Create the Bookmark data class
In Kotlin, a data class automatically generates useful methods like equals(), hashCode(), and toString(). This makes it perfect for representing a single bookmark.
- In Android Studio, right-click on the com.example.bookmarkmanager package in the Project panel on the left.
- Select New, then Kotlin Class/File.
- Enter Bookmark as the name and select File.
- Replace any existing content with this code:
package com.example.bookmarkmanager
data class Bookmark(
val id: String = java.util.UUID.randomUUID().toString(),
val title: String,
val url: String,
val category: String
)
What does this code do?
- data class Bookmark defines a simple data holder with four fields.
- val id: String = java.util.UUID.randomUUID().toString() generates a unique ID automatically when you create a new bookmark.
- title, url, and category are required fields with no defaults. Every bookmark must have all three.
- Save Bookmark.kt.
No visible change yet
This file defines a data structure only. You won't see anything new on your device until you use it in a screen later.
Seeing 'Unresolved reference' errors?
- Make sure the package declaration at the top matches your project package: com.example.bookmarkmanager.
- Check that you created the file inside the correct package folder, not in the root java directory.
Why am I getting an unresolved reference error in my Bookmark.kt file?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
package com.example.bookmarkmanager
data class Bookmark(
val id: String = java.util.UUID.randomUUID().toString(),
val title: String,
val url: String,
val category: String
)
Create the BookmarkViewModel
A ViewModel holds your app's state and survives screen rotations. In Flutter terms, it's like a Provider or Riverpod notifier that lives outside the widget tree.
StateFlow is a reactive data stream. When the list of bookmarks changes, any composable reading that flow automatically recomposes with the new data.
- Right-click on the com.example.bookmarkmanager package in the Project panel.
- Select New, then Kotlin Class/File.
- Enter BookmarkViewModel as the name and select File.
- Replace any existing content with this code:
package com.example.bookmarkmanager
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class BookmarkViewModel : ViewModel() {
private val _bookmarks = MutableStateFlow<List<Bookmark>>(
listOf(
Bookmark(title = "Kotlin Docs", url = "https://kotlinlang.org", category = "Development"),
Bookmark(title = "Android Developers", url = "https://developer.android.com", category = "Development"),
Bookmark(title = "Compose Samples", url = "https://github.com/android/compose-samples", category = "Learning")
)
)
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks.asStateFlow()
}
What does this code do?
- class BookmarkViewModel : ViewModel() creates a ViewModel that survives configuration changes like screen rotation.
- _bookmarks is a private MutableStateFlow holding the bookmark list. The underscore prefix is a Kotlin convention for private mutable state.
- The list starts with three sample bookmarks so you can see data on screen immediately when you build the UI.
- val bookmarks exposes a read-only StateFlow. Screens can observe this but cannot modify it directly.
- asStateFlow() converts the mutable flow to a read-only version. This enforces unidirectional data flow.
Why unidirectional data flow?
Unidirectional data flow (UDF) means data flows in one direction: ViewModel to screen. Screens send events back up to the ViewModel to request changes.
This is the same pattern you know from Flutter's Provider. The UI reads state but never modifies it directly. Instead, it calls functions on the ViewModel to trigger updates.
- Save BookmarkViewModel.kt.
- Build the project by pressing Ctrl+F9 (Windows) or Cmd+F9 (macOS).
- Confirm the build succeeds in the Build panel at the bottom of Android Studio.
Build failed with unresolved reference errors?
- If you see an error about ViewModel, check that the lifecycle-viewmodel-compose dependency is in your app/build.gradle.kts. You added it in Step 1.
- If you see an error about MutableStateFlow, make sure all three import statements are present at the top of the file.
- Try clicking File, then Sync Project with Gradle Files to refresh dependencies.
Why is my BookmarkViewModel build failing with unresolved references?
Add the add and delete functions
Your ViewModel needs functions that modify the bookmark list. These functions are the only way screens can change the data, which keeps state management predictable.
- In BookmarkViewModel.kt, find this line:
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks.asStateFlow()
- Add the following two functions below it, inside the class body:
fun addBookmark(title: String, url: String, category: String) {
val newBookmark = Bookmark(title = title, url = url, category = category)
_bookmarks.value = _bookmarks.value + newBookmark
}
fun deleteBookmark(id: String) {
_bookmarks.value = _bookmarks.value.filter { it.id != id }
}
What do these functions do?
- addBookmark() creates a new Bookmark object and appends it to the existing list using +. This triggers recomposition in any screen observing the flow.
- deleteBookmark() filters out the bookmark with the matching id. The filter function creates a new list without that item.
- Both functions update _bookmarks.value directly, which emits a new value through the StateFlow to any observers.
- Save BookmarkViewModel.kt.
- Build the project again with Ctrl+F9 (Windows) or Cmd+F9 (macOS).
- Confirm the build succeeds with no errors in the Build panel.
Seeing a syntax error?
- Make sure both functions are inside the BookmarkViewModel class body (before the closing }).
- Check for missing commas between function parameters.
- Verify there is a blank line between the two functions for readability, but no extra closing braces.
Why am I getting a syntax error in my BookmarkViewModel after adding the functions?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
package com.example.bookmarkmanager
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class BookmarkViewModel : ViewModel() {
private val _bookmarks = MutableStateFlow<List<Bookmark>>(
listOf(
Bookmark(title = "Kotlin Docs", url = "https://kotlinlang.org", category = "Development"),
Bookmark(title = "Android Developers", url = "https://developer.android.com", category = "Development"),
Bookmark(title = "Compose Samples", url = "https://github.com/android/compose-samples", category = "Learning")
)
)
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks.asStateFlow()
fun addBookmark(title: String, url: String, category: String) {
val newBookmark = Bookmark(title = title, url = url, category = category)
_bookmarks.value = _bookmarks.value + newBookmark
}
fun deleteBookmark(id: String) {
_bookmarks.value = _bookmarks.value.filter { it.id != id }
}
}
Your data model and state management are ready. Next up, you'll build the Bookmark List screen to display these bookmarks on your device.
Build the Bookmark List Screen
Your data model and ViewModel are ready to manage bookmarks. Now you need a screen that actually shows them to the user.
In this step, you'll build the main list screen using Jetpack Compose components. You'll use a Scaffold for the page layout, a LazyColumn for the scrollable list, and reusable card components for each bookmark.
In this step, get ready to:
- Create a BookmarkListScreen composable with Scaffold, TopAppBar, and FloatingActionButton.
- Build a BookmarkCard composable to display each bookmark's details.
- Display bookmarks using LazyColumn with a delete button on each card.
Create a BookmarkListScreen composable with Scaffold, TopAppBar, and FloatingActionButton
In Compose, Scaffold provides the standard Material page layout with slots for a top bar, floating button, and main content. This is similar to Flutter's Scaffold widget.
- In the Project panel on the left side of Android Studio, right-click the com.example.bookmarkmanager package folder (under app > java).
- Select New, then Kotlin Class/File.
- Type BookmarkListScreen and select File.
- Replace the file contents with the following code:
package com.example.bookmarkmanager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.CardDefaults
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.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookmarkListScreen(
bookmarks: List<Bookmark>,
onAddClick: () -> Unit,
onDeleteClick: (String) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("My Bookmarks") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAddClick) {
Icon(Icons.Filled.Add, contentDescription = "Add Bookmark")
}
}
) { innerPadding ->
if (bookmarks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No bookmarks yet. Tap + to add one!",
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(bookmarks, key = { it.id }) { bookmark ->
BookmarkCard(
bookmark = bookmark,
onDeleteClick = { onDeleteClick(bookmark.id) }
)
}
}
}
}
}
What does this code do?
- The function takes three parameters: the list of bookmarks to display, a callback for the add button, and a callback for deleting a bookmark by ID. This is state hoisting (the Compose equivalent of lifting state up in Flutter).
- Scaffold provides the page structure with a TopAppBar slot (the colored header) and a floatingActionButton slot (the + button in the bottom corner).
- The innerPadding parameter accounts for the space taken up by the top bar, so content doesn't render behind it.
- When the bookmark list is empty, a centered message appears. Otherwise, a LazyColumn renders each bookmark as a card.
What is @OptIn(ExperimentalMaterial3Api::class)?
TopAppBar is marked as experimental in Material 3. The @OptIn annotation tells the compiler you acknowledge this and accept that the API might change in future releases.
Build a BookmarkCard composable using Card, Column, and Text
The BookmarkListScreen references a BookmarkCard composable that doesn't exist yet. This small component handles displaying a single bookmark's details inside a Material card.
- Add the following composable function below BookmarkListScreen in the same BookmarkListScreen.kt file:
@Composable
fun BookmarkCard(
bookmark: Bookmark,
onDeleteClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = bookmark.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = bookmark.url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = bookmark.category,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDeleteClick) {
Icon(
Icons.Filled.Delete,
contentDescription = "Delete Bookmark",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
What does this code do?
- Card wraps the content with a Material surface and a subtle elevation shadow.
- Row arranges the text and delete button side by side. Modifier.weight(1f) on the Column makes the text fill available space, pushing the button to the right.
- Three Text composables show the title, URL, and category with different typography styles.
- TextOverflow.Ellipsis truncates long titles or URLs with "..." instead of wrapping to multiple lines.
- The red delete IconButton calls onDeleteClick when tapped, following the same callback pattern as the FAB.
- Save BookmarkListScreen.kt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Check the editor for any red underlines. The file should compile cleanly with no errors.
Seeing red underlines in the editor?
- Make sure the BookmarkCard function is placed BELOW the closing brace of BookmarkListScreen, not inside it.
- Check that the package declaration at the top says package com.example.bookmarkmanager. If the file was created in the wrong folder, the imports won't resolve.
- If Icons.Filled.Delete is unresolved, confirm that material-icons-extended is in your build.gradle.kts dependencies and you synced Gradle.
Help me fix red underlines in my BookmarkListScreen.kt file
Display bookmarks using LazyColumn with items() and add a delete IconButton to each card
Your screen and card composables are complete. Let's look at how the LazyColumn connects them together to create the scrollable bookmark list.
How does LazyColumn work with items()?
LazyColumn is Compose's equivalent of Flutter's ListView.builder. It only renders the items currently visible on screen, making it efficient for long lists.
The items(bookmarks, key = { it.id }) call tells Compose which list to iterate and how to uniquely identify each item. The key parameter is like Flutter's Key widget. It helps Compose efficiently recompose only the items that changed when bookmarks are added or deleted.
Why use callback lambdas instead of passing NavController?
Notice that BookmarkListScreen receives onAddClick and onDeleteClick as lambda parameters instead of directly accessing a NavController or ViewModel. This pattern is called Unidirectional Data Flow (UDF).
The screen only knows what data to display and what events to emit. A parent composable decides what actually happens when those events fire. This makes the screen easier to test and reuse.
You won't see the list screen on your device yet. In Step 5, you'll wire this screen up with Compose Navigation so it displays when the app launches.
✔️ Awesome, I've got everything!
Great. Make sure you've saved BookmarkListScreen.kt and the editor shows no red underlines.
ⓧ I'd like to double check the full code
Here's the complete BookmarkListScreen.kt file for comparison:
package com.example.bookmarkmanager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.CardDefaults
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.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookmarkListScreen(
bookmarks: List<Bookmark>,
onAddClick: () -> Unit,
onDeleteClick: (String) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("My Bookmarks") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAddClick) {
Icon(Icons.Filled.Add, contentDescription = "Add Bookmark")
}
}
) { innerPadding ->
if (bookmarks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No bookmarks yet. Tap + to add one!",
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(bookmarks, key = { it.id }) { bookmark ->
BookmarkCard(
bookmark = bookmark,
onDeleteClick = { onDeleteClick(bookmark.id) }
)
}
}
}
}
}
@Composable
fun BookmarkCard(
bookmark: Bookmark,
onDeleteClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = bookmark.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = bookmark.url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = bookmark.category,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDeleteClick) {
Icon(
Icons.Filled.Delete,
contentDescription = "Delete Bookmark",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
Your list screen is ready to display bookmarks. Next up, you'll build the Add Bookmark screen with text inputs and validation so users can save new bookmarks.
Build the Add Bookmark Screen
Your list screen can display bookmarks and handle delete actions. Now you need a way for users to actually add new bookmarks to that list.
In this step, you will build a form screen with text inputs, validation logic, and a save button. This screen follows the same Scaffold pattern you used for the list screen, but introduces local state management with remember and mutableStateOf.
In this step, get ready to:
- Create an AddBookmarkScreen composable with TextField inputs for title, URL, and category.
- Add input validation so title and URL cannot be empty.
- Wire the Save button to call the ViewModel and navigate back.
Create the AddBookmarkScreen composable
The Add Bookmark screen is a form with three text inputs and a top bar for navigation. In Flutter, you would use a TextFormField inside a Scaffold. The Compose equivalent uses OutlinedTextField inside the same Scaffold pattern.
- In the Project panel on the left side of Android Studio, right-click the com.example.bookmarkmanager package.
- Select New, then Kotlin Class/File.
- Choose File and name it AddBookmarkScreen.
- Replace any generated content with the following code:
package com.example.bookmarkmanager
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddBookmarkScreen(
onSave: (title: String, url: String, category: String) -> Unit,
onNavigateBack: () -> Unit
) {
// Local state for each text field
var title by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var category by remember { mutableStateOf("") }
// Error flags for validation
var titleError by remember { mutableStateOf(false) }
var urlError by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Bookmark") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Title input field with error support
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = false
},
label = { Text("Title") },
isError = titleError,
supportingText = if (titleError) {
{ Text("Title cannot be empty") }
} else null,
modifier = Modifier.fillMaxWidth()
)
// URL input field with error support
OutlinedTextField(
value = url,
onValueChange = {
url = it
urlError = false
},
label = { Text("URL") },
isError = urlError,
supportingText = if (urlError) {
{ Text("URL cannot be empty") }
} else null,
modifier = Modifier.fillMaxWidth()
)
// Category input field (optional, no validation)
OutlinedTextField(
value = category,
onValueChange = { category = it },
label = { Text("Category") },
modifier = Modifier.fillMaxWidth()
)
}
}
}
What does this code do?
This composable follows the same Scaffold + TopAppBar pattern from your list screen. The key differences are the navigationIcon slot (a back arrow) and the form content instead of a list.
- onSave and onNavigateBack are callback parameters. The screen does not know what happens when the user taps Save or Back. It just calls the function it was given.
- remember { mutableStateOf("") } creates local state that survives recomposition. This is the Compose equivalent of a TextEditingController in Flutter.
- Each OutlinedTextField clears its error flag inside onValueChange so the red highlight disappears as soon as the user starts typing.
- Icons.AutoMirrored.Filled.ArrowBack provides a back arrow that automatically mirrors for right-to-left languages.
- Save AddBookmarkScreen.kt.
- The file should have no red underlines in the editor. You will not see a visible change on your device yet because no navigation connects to this screen.
Seeing red underlines on Icons.AutoMirrored?
Make sure the material-icons-extended dependency is in your build.gradle.kts file. You added it in Step 1. If you see unresolved references, click Sync Now in the yellow banner at the top of the editor.
help me fix unresolved reference errors in my AddBookmarkScreen composable
Add input validation and a Save button
A form without validation lets users save empty bookmarks. You need a Save button that checks the title and URL fields before accepting the input.
- In AddBookmarkScreen.kt, find the closing } of the Category OutlinedTextField (the last text field inside the Column).
- Add the following code below it, still inside the Column:
Spacer(modifier = Modifier.height(8.dp))
// Save button with validation logic
Button(
onClick = {
// Check that required fields are filled
titleError = title.isBlank()
urlError = url.isBlank()
if (!titleError && !urlError) {
// Call the save callback with trimmed values
onSave(
title.trim(),
url.trim(),
category.trim().ifEmpty { "Uncategorized" }
)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save Bookmark")
}
What does this code do?
- The Button's onClick first sets titleError and urlError based on whether those fields are blank. This triggers recomposition and shows the red error styling on the TextFields.
- Only when both fields pass validation does the button call onSave with the trimmed input values.
- If the category is left empty, it defaults to "Uncategorized" using Kotlin's ifEmpty extension function.
- Save AddBookmarkScreen.kt.
- Confirm the file has no red underlines. The screen is not yet visible in your app because navigation has not been set up.
Red underline on Spacer or Button?
Make sure you pasted the code inside the Column's curly braces, after the last OutlinedTextField. If Spacer or Button are unresolved, confirm your imports at the top of the file include androidx.compose.foundation.layout.Spacer and androidx.compose.material3.Button.
help me fix import errors in my AddBookmarkScreen
Wire the Save button to the ViewModel
Notice that AddBookmarkScreen does not import or reference BookmarkViewModel directly. Instead, it exposes two callback parameters: onSave and onNavigateBack.
Why callbacks instead of direct ViewModel access?
This pattern is called Unidirectional Data Flow (UDF). State flows down into the composable as parameters. Events flow up as callback invocations. The composable never decides where to navigate or how to store data.
In the next step, you will create a NavHost that connects these callbacks to the ViewModel's addBookmark() function and the navigator's popBackStack(). The screen itself stays reusable and testable because it has no hard dependency on any particular ViewModel or navigation library.
How does this compare to Flutter?
In Flutter, you might pass a callback like onPressed to a button or use a Navigator.pop() call directly inside the widget. Compose takes the same idea further by making ALL navigation and data mutations flow through callbacks passed from the parent. This keeps each screen as a pure function of its inputs.
✔️ Awesome, I've got everything!
Great. Double check you have saved AddBookmarkScreen.kt with no red underlines.
ⓧ I'd like to double check the full code
package com.example.bookmarkmanager
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddBookmarkScreen(
onSave: (title: String, url: String, category: String) -> Unit,
onNavigateBack: () -> Unit
) {
var title by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var category by remember { mutableStateOf("") }
var titleError by remember { mutableStateOf(false) }
var urlError by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Bookmark") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = false
},
label = { Text("Title") },
isError = titleError,
supportingText = if (titleError) {
{ Text("Title cannot be empty") }
} else null,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = url,
onValueChange = {
url = it
urlError = false
},
label = { Text("URL") },
isError = urlError,
supportingText = if (urlError) {
{ Text("URL cannot be empty") }
} else null,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = category,
onValueChange = { category = it },
label = { Text("Category") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
titleError = title.isBlank()
urlError = url.isBlank()
if (!titleError && !urlError) {
onSave(
title.trim(),
url.trim(),
category.trim().ifEmpty { "Uncategorized" }
)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save Bookmark")
}
}
}
}
Your form screen is ready and waiting. In the next step, you will set up Compose Navigation to connect both screens together so the FAB on your list screen actually opens this Add Bookmark form.
Set Up Navigation Between Screens
You have two fully built screens and a ViewModel managing state. The missing piece is the glue that lets users move between them.
In this step, you will wire up Compose Navigation using type-safe routes. This is the Compose equivalent of GoRouter in Flutter. Instead of passing string paths like /add, you will use Kotlin objects annotated with @Serializable to define routes at compile time.
In this step, get ready to:
- Define type-safe route objects for each screen.
- Create a NavHost that connects both screens with a shared ViewModel.
- Pass navigation callbacks to screens following the Unidirectional Data Flow pattern.
Define type-safe route objects
Compose Navigation needs to know which destinations exist in your app. With type-safe routes, you define each destination as a Kotlin object annotated with @Serializable. The compiler checks your routes at build time, so a typo in a route name becomes a compile error instead of a runtime crash.
- In the Project panel on the left side of Android Studio, expand app > src > main > java > com.example.bookmarkmanager.
- Right-click the com.example.bookmarkmanager package folder.
- Select New, then Kotlin Class/File.
- Select File from the list, type Navigation, and press Enter.
- Add the following route definitions to Navigation.kt:
package com.example.bookmarkmanager
import kotlinx.serialization.Serializable
// Each object represents a navigation destination in the app
@Serializable
object BookmarkList
@Serializable
object AddBookmark
What does this code do?
Each @Serializable object defines a route for Compose Navigation. The navigation library uses Kotlin serialization to turn these objects into route identifiers under the hood.
This is the modern approach available since Navigation 2.8.0. Older tutorials use string-based routes like "bookmarkList", but type-safe routes catch mistakes at compile time.
- Save Navigation.kt.
You won't see a change yet. The next chunk wires these routes into a NavHost.
Create a NavHost connecting both screens
A NavHost is the container that swaps screens in and out based on the current route. It is the Compose equivalent of a Router widget wrapping your screen tree in Flutter.
- Open MainActivity.kt from the Project panel (it is in the same com.example.bookmarkmanager package).
- Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Replace the file contents with the following code:
package com.example.bookmarkmanager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.bookmarkmanager.ui.theme.BookmarkManagerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
BookmarkManagerTheme {
BookmarkApp()
}
}
}
}
@Composable
fun BookmarkApp() {
// Create navigation controller and shared ViewModel
val navController = rememberNavController()
val viewModel: BookmarkViewModel = viewModel()
val bookmarks by viewModel.bookmarks.collectAsState()
// NavHost swaps screens based on the current route
NavHost(navController = navController, startDestination = BookmarkList) {
composable<BookmarkList> {
BookmarkListScreen(
bookmarks = bookmarks,
onAddClick = { navController.navigate(AddBookmark) },
onDeleteClick = { id -> viewModel.deleteBookmark(id) }
)
}
composable<AddBookmark> {
AddBookmarkScreen(
onSave = { title, url, category ->
viewModel.addBookmark(title, url, category)
navController.popBackStack()
},
onNavigateBack = { navController.popBackStack() }
)
}
}
}
What does this code do?
- rememberNavController() creates and remembers a navigation controller that survives recompositions. It tracks the back stack and handles screen transitions.
- viewModel() obtains a shared ViewModel instance scoped to the activity. Both screens share the same bookmark data.
- collectAsState() converts the StateFlow into Compose State so the UI recomposes whenever bookmarks change.
- NavHost defines which composable renders for each route. The startDestination = BookmarkList sets the home screen.
- composable<BookmarkList> and composable<AddBookmark> use the type-safe route objects you defined in Navigation.kt.
- Save MainActivity.kt.
- Wait for the editor to resolve imports. You should see no red underlines in the file.
Seeing unresolved reference errors?
- If BookmarkList or AddBookmark is red, make sure you saved Navigation.kt and that it is in the same package (com.example.bookmarkmanager).
- If NavHost or composable is unresolved, confirm you added the navigation-compose dependency in Step 1 and ran a Gradle sync.
- Try clicking File then Sync Project with Gradle Files in the top menu bar to force a refresh.
Help me fix unresolved references in my Compose Navigation setup.
Understand the UDF navigation callbacks
Notice that neither BookmarkListScreen nor AddBookmarkScreen receives the navController directly. Instead, each screen receives lambda callbacks like onAddClick and onNavigateBack. This is the Unidirectional Data Flow (UDF) pattern.
Why pass callbacks instead of the NavController?
Screens that receive only data and callbacks are easier to test and reuse. They do not know about navigation. They only know that "something should happen" when the user taps a button.
The parent composable (BookmarkApp) is the single owner of navigation logic. Data flows down as parameters. Events flow up as lambda calls. This is the same concept as lifting state up in Flutter.
- Run the app on your physical device by clicking the green Run button (triangle icon) in the top toolbar.
- You should see the Bookmark List screen with three sample bookmarks.
- Tap the floating action button (the + icon in the bottom right).
- You should navigate to the Add Bookmark screen with the title, URL, and category fields.
- Tap the back arrow in the top left to return to the list screen.
- Try adding a bookmark by filling in all three fields and tapping Save Bookmark. You should return to the list and see your new bookmark appear.
- Try deleting a bookmark by tapping the red trash icon on any card. The card should disappear immediately.
App crashes on launch?
- Check the Logcat panel at the bottom of Android Studio for the stack trace. Look for the first line that mentions your package name.
- If you see a serialization-related error, confirm the kotlin("plugin.serialization") plugin is in your build.gradle.kts plugins block and that you synced Gradle.
- If you see a NoSuchMethodError, make sure all your Compose library versions are managed by the BOM and match the versions from Step 1.
Help me debug my Compose Navigation crash.
✔️ Awesome, I've got everything!
Great. Double check you have saved both Navigation.kt and MainActivity.kt.
ⓧ I'd like to double check the full code
Here is the full Navigation.kt:
package com.example.bookmarkmanager
import kotlinx.serialization.Serializable
@Serializable
object BookmarkList
@Serializable
object AddBookmark
Here is the full MainActivity.kt:
package com.example.bookmarkmanager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.bookmarkmanager.ui.theme.BookmarkManagerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
BookmarkManagerTheme {
BookmarkApp()
}
}
}
}
@Composable
fun BookmarkApp() {
val navController = rememberNavController()
val viewModel: BookmarkViewModel = viewModel()
val bookmarks by viewModel.bookmarks.collectAsState()
NavHost(navController = navController, startDestination = BookmarkList) {
composable<BookmarkList> {
BookmarkListScreen(
bookmarks = bookmarks,
onAddClick = { navController.navigate(AddBookmark) },
onDeleteClick = { id -> viewModel.deleteBookmark(id) }
)
}
composable<AddBookmark> {
AddBookmarkScreen(
onSave = { title, url, category ->
viewModel.addBookmark(title, url, category)
navController.popBackStack()
},
onNavigateBack = { navController.popBackStack() }
)
}
}
}
Your two-screen Bookmark Manager is fully wired and running. You can add bookmarks, view them in categorized cards, delete them, and navigate between screens. Next up, the Secret Mission challenges you to add category filtering with chips.
Secret mission
Add Category Filter
Your bookmark list works, but as it grows, finding the right link gets harder. What if you could filter bookmarks by category with a single tap? Add a row of filter chips that narrows the list instantly.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine and physical device. There are no cloud resources or ongoing costs to worry about.
Decide whether to keep your project files for future reference, or delete them entirely.
Resources you used:
- BookmarkManager project files in Android Studio.
- BookmarkManager app installed on your physical Android device.
Keep everything running
No action needed. Choose this if you want to keep building on the project or refer back to the code later.
- Your project remains in Android Studio, ready to open anytime.
- The app stays installed on your physical device.
Pause - I'll come back to this later
Since everything is local, there is nothing to pause. Your project files sit on disk and consume no resources when Android Studio is closed.
- Close Android Studio to free up memory.
- The app on your device uses minimal storage and no background resources.
Delete - I don't want to use this again
Remove all project files and the installed app to start fresh.
- In Android Studio, go to the Welcome screen. Right-click the BookmarkManager project in the recent projects list and select Remove from Recent Projects.
- Delete the project folder from your file system. Open a terminal and run the command below (adjust the path if you saved the project elsewhere):
macOS
rm -rf ~/AndroidStudioProjects/BookmarkManager
Windows
Remove-Item -Recurse -Force "$env:USERPROFILE\AndroidStudioProjects\BookmarkManager"
- Uninstall the app from your physical device. On your Android device, long-press the BookmarkManager app icon and select Uninstall.
Nice Work!
Nice Work!
You just built your first native Android app with Jetpack Compose. You now have a fully functional two-screen Bookmark Manager running on your physical device, built entirely with Kotlin and modern declarative UI patterns.
You've learned how to:
- Built a two-screen app with Compose Navigation using type-safe routes and Unidirectional Data Flow (UDF) navigation callbacks.
- Managed state with ViewModel and StateFlow, the Android equivalent of Provider/Riverpod in Flutter.
- Used Material 3 components including Scaffold, TopAppBar, FloatingActionButton, Card, TextField, and LazyColumn.
- Added category filtering with FilterChip components in the Secret Mission.
Ready to quiz yourself?