In the previous tutorial, we built the shared data layer with Ktor, SQLDelight, and offline-first sync. The data layer works, but the UI is basic. You can only see a list of notes with a “+” button that creates placeholder notes.
In this tutorial, we build a proper UI layer. You will add navigation between screens, a note editing screen with a color picker, pull-to-refresh sync, and loading and error states. All of this works on both Android (Compose) and iOS (SwiftUI).
What We Are Building
By the end of this tutorial, the app will have:
- Two screens — a note list and a note editor
- Navigation — tap a note to edit it, tap “+” to create a new one
- Note editor — title field, content field, and color picker
- Pull-to-refresh — swipe down to trigger sync with the server
- Loading and error states — show a spinner while syncing, show an error message if sync fails
- Auto-sync on launch — the app syncs when it opens
The shared ViewModel handles all business logic. The UI layer is platform-specific: Compose on Android, SwiftUI on iOS.
Architecture Reminder
┌──────────────┐ ┌──────────────┐
│ Compose │ │ SwiftUI │
│ (Android) │ │ (iOS) │
└──────┬───────┘ └──────┬───────┘
│ │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Shared ViewModel │ ← commonMain
│ (NotesViewModel) │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ NoteRepository │ ← commonMain
└──────────┬──────────┘
│
┌────┴─────┐ ┌────┴─────┐
│ SQLDelight│ │ Ktor API │
└──────────┘ └──────────┘
The ViewModel lives in commonMain. Both platforms consume it, but they build their UI with native tools.
Step 1: Update the Shared ViewModel
Before touching the UI, update NotesViewModel to support sync, loading states, and error handling.
Add Sync Support
The ViewModel needs three new pieces of state:
isLoading— true while sync is runningsyncError— holds the error message if sync failssync()— a function that triggers the sync
// shared/src/commonMain/.../domain/NotesViewModel.kt
class NotesViewModel(private val repository: NoteRepository) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _syncError = MutableStateFlow<String?>(null)
val syncError: StateFlow<String?> = _syncError
@OptIn(ExperimentalCoroutinesApi::class)
val notes: StateFlow<List<Note>> = _searchQuery
.flatMapLatest { query ->
if (query.isBlank()) {
repository.getAllNotes()
} else {
repository.searchNotes(query)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
init {
sync()
}
fun sync() {
viewModelScope.launch {
_isLoading.value = true
_syncError.value = null
when (val result = repository.sync()) {
is SyncResult.Success -> { /* sync complete */ }
is SyncResult.Error -> _syncError.value = result.message
}
_isLoading.value = false
}
}
fun clearSyncError() {
_syncError.value = null
}
// ... createNote, updateNote, toggleFavorite, deleteNote unchanged
}
The init block calls sync() when the ViewModel is created. This means the app syncs every time the user opens the notes screen. The clearSyncError() function lets the UI dismiss the error message after showing it.
Why Auto-Sync on Launch?
Calling sync() in init is a common pattern for offline-first apps. The user sees local data immediately (from the database), and the sync happens in the background. If it fails, the user still has their local notes.
Other sync triggers you might add later:
- After creating or editing a note (optimistic push)
- When the device reconnects to the internet
- On a periodic schedule with WorkManager (Android) or BGTaskScheduler (iOS)
Step 2: Build Android Navigation
The current Android app has one screen. We need two: a note list and a note editor. For this tutorial, we use a simple navigator instead of a full navigation library. This keeps the code focused on KMP concepts.
Create the Navigator
// composeApp/src/androidMain/.../Navigator.kt
sealed class Screen {
data object NoteList : Screen()
data class NoteEdit(val noteId: Long?) : Screen()
}
class Navigator {
private val _currentScreen = MutableStateFlow<Screen>(Screen.NoteList)
val currentScreen: StateFlow<Screen> = _currentScreen
private val backStack = mutableListOf<Screen>()
fun navigateTo(screen: Screen) {
backStack.add(_currentScreen.value)
_currentScreen.value = screen
}
fun goBack() {
if (backStack.isNotEmpty()) {
_currentScreen.value = backStack.removeLast()
}
}
}
The navigator uses a StateFlow to track the current screen and a simple list as a back stack. When you call navigateTo(), it pushes the current screen onto the back stack and switches to the new screen.
Wire Up the App Composable
The App composable now uses the navigator to decide which screen to show:
@Composable
fun App() {
MaterialTheme {
val navigator = remember { Navigator() }
val currentScreen by navigator.currentScreen.collectAsState()
when (val screen = currentScreen) {
is Screen.NoteList -> NoteListScreen(
onNoteClick = { noteId -> navigator.navigateTo(Screen.NoteEdit(noteId)) },
onCreateClick = { navigator.navigateTo(Screen.NoteEdit(null)) }
)
is Screen.NoteEdit -> NoteEditScreen(
noteId = screen.noteId,
onBack = { navigator.goBack() }
)
}
}
}
When noteId is null, the edit screen creates a new note. When it has a value, the edit screen loads and edits the existing note.
Step 3: Build the Note List Screen (Android)
The note list screen is similar to the previous App() composable, but with two additions: pull-to-refresh and a snackbar for sync errors.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteListScreen(
onNoteClick: (Long) -> Unit,
onCreateClick: () -> Unit
) {
val viewModel: NotesViewModel = koinViewModel()
val notes by viewModel.notes.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val syncError by viewModel.syncError.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(syncError) {
syncError?.let {
snackbarHostState.showSnackbar("Sync failed: $it")
viewModel.clearSyncError()
}
}
Scaffold(
modifier = Modifier.safeContentPadding(),
topBar = { TopAppBar(title = { Text("My Notes") }) },
floatingActionButton = {
FloatingActionButton(onClick = onCreateClick) {
Text("+", style = MaterialTheme.typography.headlineMedium)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
PullToRefreshBox(
isRefreshing = isLoading,
onRefresh = { viewModel.sync() },
modifier = Modifier.fillMaxSize().padding(padding),
state = pullToRefreshState
) {
Column(modifier = Modifier.fillMaxSize()) {
// Search field
OutlinedTextField(
value = searchQuery,
onValueChange = viewModel::onSearchQueryChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search notes...") },
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
// Empty state or note list
if (notes.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = if (searchQuery.isBlank()) "No notes yet.\nTap + to create one."
else "No notes found.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(notes, key = { it.id }) { note ->
NoteCard(
note = note,
onFavoriteClick = { viewModel.toggleFavorite(note) },
onDeleteClick = { viewModel.deleteNote(note.id) },
onClick = { onNoteClick(note.id) }
)
}
}
}
}
}
}
}
Pull-to-Refresh
Material 3 provides PullToRefreshBox — a container that shows a refresh indicator when the user pulls down. We connect it to the ViewModel’s sync() function and isLoading state.
When the user pulls down:
onRefreshcallsviewModel.sync()isLoadingbecomestrue, showing the spinner- Sync completes,
isLoadingbecomesfalse, spinner hides - If sync fails, the snackbar shows the error message
Empty State
The empty state shows different messages depending on whether the user is searching or not. We also check !isLoading so we do not flash the empty state while the first sync is running.
Step 4: Build the Note Edit Screen (Android)
The note edit screen has three parts: a title field, a content field, and a color picker.
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun NoteEditScreen(
noteId: Long?,
onBack: () -> Unit
) {
val viewModel: NoteDetailViewModel = koinViewModel()
val title by viewModel.title.collectAsState()
val content by viewModel.content.collectAsState()
val color by viewModel.color.collectAsState()
LaunchedEffect(noteId) {
if (noteId != null) {
viewModel.loadNote(noteId)
}
}
Scaffold(
modifier = Modifier.safeContentPadding(),
topBar = {
TopAppBar(
title = { Text(if (noteId != null) "Edit Note" else "New Note") },
navigationIcon = {
IconButton(onClick = onBack) {
Text("<", style = MaterialTheme.typography.titleLarge)
}
},
actions = {
TextButton(onClick = {
viewModel.saveNote()
onBack()
}) {
Text("Save")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
OutlinedTextField(
value = title,
onValueChange = viewModel::onTitleChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Title") },
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = content,
onValueChange = viewModel::onContentChange,
modifier = Modifier.fillMaxWidth().weight(1f),
placeholder = { Text("Write your note...") },
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
// Color picker
Text("Color", style = MaterialTheme.typography.labelLarge)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
NoteColor.entries.forEach { noteColor ->
ColorCircle(
noteColor = noteColor,
isSelected = noteColor == color,
onClick = { viewModel.onColorChange(noteColor) }
)
}
}
}
}
}
How NoteDetailViewModel Works
The NoteDetailViewModel (from Part 15) manages the edit state. It has title, content, and color as separate StateFlow values. When editing an existing note, loadNote(id) fetches the note from the repository and populates these fields.
When the user taps “Save”:
saveNote()checks if there is an existing note (editing) or not (creating)- For edits, it calls
repository.updateNote()with the new values - For new notes, it calls
repository.createNote() - Both write to the local database with
synced = false - The navigator goes back to the list screen
The Color Picker
The color picker is a FlowRow of colored circles. Each circle represents a NoteColor enum value. The selected color gets a checkmark.
NoteColor.entries.forEach { noteColor ->
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(noteColor.toComposeColor())
.clickable { viewModel.onColorChange(noteColor) }
) {
if (noteColor == color) {
Text(
"✓",
modifier = Modifier.align(Alignment.Center),
color = Color.White,
style = MaterialTheme.typography.labelMedium
)
}
}
}
The toComposeColor() extension function maps each NoteColor to an actual Compose Color:
fun NoteColor.toComposeColor(): Color = when (this) {
NoteColor.DEFAULT -> Color(0xFFE0E0E0)
NoteColor.RED -> Color(0xFFEF5350)
NoteColor.ORANGE -> Color(0xFFFF9800)
NoteColor.YELLOW -> Color(0xFFFFEB3B)
NoteColor.GREEN -> Color(0xFF66BB6A)
NoteColor.BLUE -> Color(0xFF42A5F5)
NoteColor.PURPLE -> Color(0xFFAB47BC)
}
This function lives in the Android Compose code, not in the shared module. The shared module defines the NoteColor enum; each platform maps it to native colors.
Step 5: Build the iOS Note List (SwiftUI)
The iOS version uses SwiftUI’s NavigationStack for navigation. The list screen adds pull-to-refresh, loading states, and navigation to the edit screen.
Update the ViewModel Wrapper
First, update the iOS NotesViewModelWrapper to expose sync state:
// iosApp/iosApp/NotesViewModelWrapper.swift
class NotesViewModelWrapper: ObservableObject {
let repository: NoteRepository
@Published var notes: [Note] = []
@Published var searchQuery: String = "" {
didSet { observeNotes() }
}
@Published var isLoading: Bool = false
@Published var showNewNote: Bool = false
@Published var showSyncError: Bool = false
var syncErrorMessage: String = ""
private var collector: Closeable?
init() {
self.repository = KoinHelper.shared.getNoteRepository()
observeNotes()
sync()
}
func sync() {
isLoading = true
Task {
let result = try await repository.sync()
DispatchQueue.main.async { [weak self] in
self?.isLoading = false
if let error = result as? SyncResult.Error {
self?.syncErrorMessage = error.message
self?.showSyncError = true
}
}
}
}
func refresh() {
observeNotes()
}
// ... observeNotes, createNote, toggleFavorite, deleteNote unchanged
}
The wrapper now has isLoading, showSyncError, and syncErrorMessage properties. The sync() function calls the shared repository’s sync() method and updates the UI state based on the result.
Update ContentView
struct ContentView: View {
@StateObject private var viewModel = NotesViewModelWrapper()
var body: some View {
NavigationStack {
VStack {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search notes...", text: $viewModel.searchQuery)
}
.padding(10)
.background(Color(.systemGray6))
.cornerRadius(12)
.padding(.horizontal)
if viewModel.isLoading && viewModel.notes.isEmpty {
Spacer()
ProgressView("Loading...")
Spacer()
} else if viewModel.notes.isEmpty {
Spacer()
Text(viewModel.searchQuery.isEmpty
? "No notes yet.\nTap + to create one."
: "No notes found.")
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Spacer()
} else {
List {
ForEach(viewModel.notes, id: \.id) { note in
NavigationLink(value: note.id) {
NoteRow(
note: note,
onFavorite: { viewModel.toggleFavorite(note: note) },
onDelete: { viewModel.deleteNote(id: note.id) }
)
}
}
}
.listStyle(.plain)
.refreshable {
viewModel.sync()
}
}
}
.navigationTitle("My Notes")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { viewModel.showNewNote = true }) {
Image(systemName: "plus")
}
}
}
.navigationDestination(for: Int64.self) { noteId in
NoteEditView(
noteId: noteId,
repository: viewModel.repository,
onSave: { viewModel.refresh() }
)
}
.sheet(isPresented: $viewModel.showNewNote) {
NavigationStack {
NoteEditView(
noteId: nil,
repository: viewModel.repository,
onSave: { viewModel.refresh() }
)
}
}
.alert("Sync Error", isPresented: $viewModel.showSyncError) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.syncErrorMessage)
}
}
}
}
Key differences from the Android version:
- Navigation — SwiftUI uses
NavigationStackwithNavigationLink(value:)and.navigationDestination(for:). No manual navigator needed. - Pull-to-refresh — SwiftUI has
.refreshable {}built in. Just callviewModel.sync()inside. - New note — opens as a
.sheet(bottom sheet) instead of a full-screen push. This is common on iOS. - Error display — uses an
.alertinstead of a Snackbar.
Step 6: Build the iOS Note Edit Screen (SwiftUI)
The iOS note edit screen is similar to the Android one: title, content, and color picker.
// iosApp/iosApp/NoteEditView.swift
struct NoteEditView: View {
let noteId: Int64?
let repository: NoteRepository
let onSave: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var title: String = ""
@State private var content: String = ""
@State private var selectedColor: NoteColor = .default_
var body: some View {
VStack(spacing: 16) {
TextField("Title", text: $title)
.font(.title2)
.padding(.horizontal)
TextEditor(text: $content)
.padding(.horizontal, 12)
.overlay(
Group {
if content.isEmpty {
Text("Write your note...")
.foregroundColor(.secondary)
.padding(.horizontal, 16)
.padding(.top, 8)
}
},
alignment: .topLeading
)
// Color picker
VStack(alignment: .leading, spacing: 8) {
Text("Color")
.font(.subheadline)
.fontWeight(.medium)
HStack(spacing: 12) {
ForEach(noteColors, id: \.self) { noteColor in
Circle()
.fill(colorForNote(noteColor))
.frame(width: 36, height: 36)
.overlay(
Circle()
.stroke(Color.primary, lineWidth: selectedColor == noteColor ? 3 : 0)
)
.onTapGesture { selectedColor = noteColor }
}
}
}
.padding(.horizontal)
}
.padding(.top)
.navigationTitle(noteId != nil ? "Edit Note" : "New Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveNote()
onSave()
dismiss()
}
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
}
ToolbarItem(placement: .cancellationAction) {
if noteId == nil {
Button("Cancel") { dismiss() }
}
}
}
.onAppear { loadNote() }
}
private func loadNote() {
guard let noteId = noteId else { return }
let flow = repository.getNoteById(id: noteId)
_ = FlowCollectorCompanion.shared.collect(flow: flow) { value in
if let note = value as? Note {
DispatchQueue.main.async {
self.title = note.title
self.content = note.content
self.selectedColor = note.color
}
}
}
}
private func saveNote() {
if let noteId = noteId {
repository.updateNote(note: Note(
id: noteId,
title: title,
content: content,
createdAt: /* existing timestamp */,
updatedAt: /* current timestamp */,
isFavorite: false,
color: selectedColor
))
} else {
_ = repository.createNote(
title: title,
content: content,
color: selectedColor
)
}
}
}
iOS Color Picker
The color picker uses SwiftUI’s Circle shape. The selected color gets a border stroke:
private let noteColors: [NoteColor] = [
.default_, .red, .orange, .yellow, .green, .blue, .purple
]
Note the .default_ — Kotlin’s DEFAULT enum case becomes default_ in Swift because default is a reserved word in Swift. The KMP compiler adds the underscore automatically.
iOS vs Android Differences
The save button is disabled when the title is empty. This is a small UX improvement. On Android, you could add the same check:
TextButton(
onClick = { viewModel.saveNote(); onBack() },
enabled = title.isNotBlank()
) {
Text("Save")
}
Step 7: Loading and Error States
Both platforms handle three states:
Loading State
When the app first opens and has no cached data, show a loading spinner:
Android:
if (notes.isEmpty() && !isLoading) {
// Empty state message
}
iOS:
if viewModel.isLoading && viewModel.notes.isEmpty {
ProgressView("Loading...")
}
If the user already has cached notes, the list shows immediately. The sync runs in the background without blocking the UI.
Error State
If sync fails (no internet, server down), show a message but keep the existing data:
Android: Snackbar at the bottom of the screen
LaunchedEffect(syncError) {
syncError?.let {
snackbarHostState.showSnackbar("Sync failed: $it")
viewModel.clearSyncError()
}
}
iOS: Alert dialog
.alert("Sync Error", isPresented: $viewModel.showSyncError) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.syncErrorMessage)
}
Empty State
When there are no notes and no search query, show a helpful message. When searching and no results match, show “No notes found.” This small detail makes the app feel polished.
Build and Run
Both platforms should build:
# Android
./gradlew :composeApp:assembleDebug
# iOS (shared framework)
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
Run the Android app to test:
- The notes list shows with pull-to-refresh
- Tap “+” to create a new note
- Fill in the title, content, and pick a color
- Tap “Save” to go back to the list
- Tap any note to edit it
- Pull down to trigger sync (sync will fail since we use a placeholder API URL, and the snackbar shows the error)
What Stays in Shared Code vs Platform Code
| Component | Where It Lives | Why |
|---|---|---|
NotesViewModel | commonMain | Business logic is the same on both platforms |
NoteDetailViewModel | commonMain | Edit logic is the same on both platforms |
NoteRepository | commonMain | Data access is the same on both platforms |
Note, NoteColor | commonMain | Domain models are shared |
NoteListScreen | composeApp (Android) | UI is platform-specific |
NoteEditScreen | composeApp (Android) | UI is platform-specific |
ContentView | iosApp (iOS) | UI is platform-specific |
NoteEditView | iosApp (iOS) | UI is platform-specific |
Navigator | composeApp (Android) | Android navigation is platform-specific |
| Color mapping | Both platforms | Each platform maps to native colors |
The shared ViewModel provides the same API to both platforms. Each platform builds its UI in the way that feels native to that platform.
What We Built
| Feature | Android | iOS |
|---|---|---|
| Navigation | Simple navigator with back stack | NavigationStack with NavigationLink |
| Note editor | Compose OutlinedTextField + FlowRow | SwiftUI TextField + TextEditor |
| Color picker | Colored circles with checkmark | Colored circles with border stroke |
| Pull-to-refresh | PullToRefreshBox | .refreshable |
| Error display | Snackbar | Alert |
| Loading state | Empty state check | ProgressView |
| Auto-sync | ViewModel init block | ViewModel init block |
Source Code
Full source code for this tutorial: GitHub — tutorial-17-ui-layer
What’s Next?
In the next tutorial, we cover publishing your KMP app — building a release APK for Android, an IPA for iOS, and setting up GitHub Actions CI/CD.