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:

  1. Two screens — a note list and a note editor
  2. Navigation — tap a note to edit it, tap “+” to create a new one
  3. Note editor — title field, content field, and color picker
  4. Pull-to-refresh — swipe down to trigger sync with the server
  5. Loading and error states — show a spinner while syncing, show an error message if sync fails
  6. 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 running
  • syncError — holds the error message if sync fails
  • sync() — 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:

  1. onRefresh calls viewModel.sync()
  2. isLoading becomes true, showing the spinner
  3. Sync completes, isLoading becomes false, spinner hides
  4. 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”:

  1. saveNote() checks if there is an existing note (editing) or not (creating)
  2. For edits, it calls repository.updateNote() with the new values
  3. For new notes, it calls repository.createNote()
  4. Both write to the local database with synced = false
  5. 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 NavigationStack with NavigationLink(value:) and .navigationDestination(for:). No manual navigator needed.
  • Pull-to-refresh — SwiftUI has .refreshable {} built in. Just call viewModel.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 .alert instead 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:

  1. The notes list shows with pull-to-refresh
  2. Tap “+” to create a new note
  3. Fill in the title, content, and pick a color
  4. Tap “Save” to go back to the list
  5. Tap any note to edit it
  6. 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

ComponentWhere It LivesWhy
NotesViewModelcommonMainBusiness logic is the same on both platforms
NoteDetailViewModelcommonMainEdit logic is the same on both platforms
NoteRepositorycommonMainData access is the same on both platforms
Note, NoteColorcommonMainDomain models are shared
NoteListScreencomposeApp (Android)UI is platform-specific
NoteEditScreencomposeApp (Android)UI is platform-specific
ContentViewiosApp (iOS)UI is platform-specific
NoteEditViewiosApp (iOS)UI is platform-specific
NavigatorcomposeApp (Android)Android navigation is platform-specific
Color mappingBoth platformsEach 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

FeatureAndroidiOS
NavigationSimple navigator with back stackNavigationStack with NavigationLink
Note editorCompose OutlinedTextField + FlowRowSwiftUI TextField + TextEditor
Color pickerColored circles with checkmarkColored circles with border stroke
Pull-to-refreshPullToRefreshBox.refreshable
Error displaySnackbarAlert
Loading stateEmpty state checkProgressView
Auto-syncViewModel init blockViewModel 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.