Mobile development is hard. Cross-platform mobile development is harder. Kotlin Multiplatform sits in the middle — shared business logic with native UI on each platform. The Gradle configuration alone can take hours.

Can Claude handle it?

This is the most technically challenging project in the entire series. KMP has expect/actual declarations, platform-specific dependency injection, multiplatform Gradle config, and two completely different UI frameworks (Jetpack Compose for Android, SwiftUI for iOS).

I wanted to know: is KMP too complex for vibe coding?

Total time: 5 hours 11 minutes. 6 prompts. Gradle configuration was 40% of the debugging time.

What We’re Building

A productivity app (Pomodoro timer + task management + daily stats) with:

  • Pomodoro timer — 25 min work, 5 min short break, 15 min long break
  • Task management — CRUD with SQLDelight local database
  • Daily stats — tasks completed today, focus time, streak count
  • Timer notifications — platform-specific (Android notifications, iOS UNUserNotifications)
  • Settings — customizable timer durations, notification sounds, daily goal
  • Data export — export/import JSON backup

Platforms:

  • Android — Jetpack Compose + Material 3
  • iOS — SwiftUI

Shared logic (Kotlin):

  • Timer state machine
  • Task repository (SQLDelight)
  • Stats calculation
  • Settings management

Tech stack: Kotlin Multiplatform, Jetpack Compose, SwiftUI, SQLDelight, Koin, Kotlinx Serialization

Prerequisites

  • Claude Code installed and configured
  • Android Studio with KMP plugin
  • Xcode 15+ (for iOS)
  • JDK 17+

The Build Session

Total time: 5 hours 11 minutes. 6 prompts.

Prompt 1: Project Setup and Shared Logic (Minute 0)

KMP project setup is the hardest part. I gave Claude a detailed prompt to avoid common Gradle issues:

Create a Kotlin Multiplatform project for a productivity app called "FocusFlow".

Project structure:
- shared/ — KMP shared module
  - commonMain/ — shared business logic
  - androidMain/ — Android-specific implementations
  - iosMain/ — iOS-specific implementations
- androidApp/ — Android application (Jetpack Compose)
- iosApp/ — iOS application (SwiftUI via Xcode project)

Shared module (commonMain):
1. Timer state machine:
   - States: Idle, Working, ShortBreak, LongBreak
   - Default durations: Work 25 min, Short Break 5 min, Long Break 15 min
   - Long break every 4 work sessions
   - Events: Start, Pause, Resume, Skip, Reset
   - Expose current state, remaining time (seconds), session count

2. Database (SQLDelight):
   - Task: id (INTEGER PRIMARY KEY), title (TEXT), description (TEXT),
     isCompleted (INTEGER), completedAt (TEXT nullable), createdAt (TEXT)
   - DailyStats: date (TEXT PRIMARY KEY), tasksCompleted (INTEGER),
     focusMinutes (INTEGER), sessionsCompleted (INTEGER)
   - Settings: key (TEXT PRIMARY KEY), value (TEXT)

3. Repository interfaces:
   - TaskRepository: getAll, getActive, add, update, complete, delete
   - StatsRepository: getToday, getWeek, getStreak, recordSession
   - SettingsRepository: get, set, getTimerDurations

4. Dependency injection with Koin:
   - Common module with repositories
   - Platform-specific module for database driver

Gradle config:
- Kotlin 2.1.21
- AGP 8.10.1
- SQLDelight 2.0.2
- Koin 3.5.6
- Kotlinx Serialization 1.7.3
- compileSdk 35, minSdk 26, targetSdk 35

What Claude generated:

Claude created the KMP project structure with all Gradle files. The build.gradle.kts for the shared module:

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.sqldelight)
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "17"
            }
        }
    }

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach {
        it.binaries.framework {
            baseName = "shared"
            isStatic = true
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.koin.core)
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.kotlinx.datetime)
            implementation(libs.sqldelight.runtime)
            implementation(libs.sqldelight.coroutines)
        }
        androidMain.dependencies {
            implementation(libs.sqldelight.android)
            implementation(libs.koin.android)
        }
        iosMain.dependencies {
            implementation(libs.sqldelight.native)
        }
    }
}

sqldelight {
    databases {
        create("FocusFlowDb") {
            packageName.set("com.kemalcodes.focusflow.db")
        }
    }
}

The timer state machine:

class PomodoroTimer {
    private val _state = MutableStateFlow(TimerState())
    val state: StateFlow<TimerState> = _state.asStateFlow()

    private var timerJob: Job? = null
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    data class TimerState(
        val phase: Phase = Phase.IDLE,
        val remainingSeconds: Int = 25 * 60,
        val totalSeconds: Int = 25 * 60,
        val sessionCount: Int = 0,
        val isRunning: Boolean = false,
    )

    enum class Phase { IDLE, WORKING, SHORT_BREAK, LONG_BREAK }

    fun start(durations: TimerDurations = TimerDurations()) {
        val currentState = _state.value

        if (currentState.phase == Phase.IDLE) {
            _state.value = currentState.copy(
                phase = Phase.WORKING,
                remainingSeconds = durations.workMinutes * 60,
                totalSeconds = durations.workMinutes * 60,
                isRunning = true,
            )
        } else {
            _state.value = currentState.copy(isRunning = true)
        }

        startCountdown()
    }

    fun pause() {
        timerJob?.cancel()
        _state.value = _state.value.copy(isRunning = false)
    }

    fun skip(durations: TimerDurations = TimerDurations()) {
        timerJob?.cancel()
        moveToNextPhase(durations)
    }

    fun reset() {
        timerJob?.cancel()
        _state.value = TimerState()
    }

    private fun startCountdown() {
        timerJob?.cancel()
        timerJob = scope.launch {
            while (_state.value.remainingSeconds > 0 && _state.value.isRunning) {
                delay(1000)
                _state.value = _state.value.copy(
                    remainingSeconds = _state.value.remainingSeconds - 1
                )
            }
            if (_state.value.remainingSeconds <= 0) {
                onPhaseComplete()
            }
        }
    }

    private fun onPhaseComplete() {
        // Notify via platform-specific callback
        onPhaseCompleteCallback?.invoke(_state.value.phase)
        moveToNextPhase(TimerDurations())
    }

    private fun moveToNextPhase(durations: TimerDurations) {
        val current = _state.value
        val nextState = when (current.phase) {
            Phase.WORKING -> {
                val newCount = current.sessionCount + 1
                if (newCount % 4 == 0) {
                    current.copy(
                        phase = Phase.LONG_BREAK,
                        remainingSeconds = durations.longBreakMinutes * 60,
                        totalSeconds = durations.longBreakMinutes * 60,
                        sessionCount = newCount,
                        isRunning = true,
                    )
                } else {
                    current.copy(
                        phase = Phase.SHORT_BREAK,
                        remainingSeconds = durations.shortBreakMinutes * 60,
                        totalSeconds = durations.shortBreakMinutes * 60,
                        sessionCount = newCount,
                        isRunning = true,
                    )
                }
            }
            Phase.SHORT_BREAK, Phase.LONG_BREAK -> {
                current.copy(
                    phase = Phase.WORKING,
                    remainingSeconds = durations.workMinutes * 60,
                    totalSeconds = durations.workMinutes * 60,
                    isRunning = true,
                )
            }
            Phase.IDLE -> current
        }

        _state.value = nextState
        if (nextState.isRunning) startCountdown()
    }

    var onPhaseCompleteCallback: ((Phase) -> Unit)? = null
}

data class TimerDurations(
    val workMinutes: Int = 25,
    val shortBreakMinutes: Int = 5,
    val longBreakMinutes: Int = 15,
)

The SQLDelight schema:

-- shared/src/commonMain/sqldelight/com/kemalcodes/focusflow/db/Task.sq

CREATE TABLE Task (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    isCompleted INTEGER NOT NULL DEFAULT 0,
    completedAt TEXT,
    createdAt TEXT NOT NULL
);

getAll:
SELECT * FROM Task ORDER BY createdAt DESC;

getActive:
SELECT * FROM Task WHERE isCompleted = 0 ORDER BY createdAt DESC;

insert:
INSERT INTO Task (title, description, createdAt)
VALUES (?, ?, ?);

complete:
UPDATE Task SET isCompleted = 1, completedAt = ? WHERE id = ?;

delete:
DELETE FROM Task WHERE id = ?;
-- DailyStats.sq
CREATE TABLE DailyStats (
    date TEXT PRIMARY KEY,
    tasksCompleted INTEGER NOT NULL DEFAULT 0,
    focusMinutes INTEGER NOT NULL DEFAULT 0,
    sessionsCompleted INTEGER NOT NULL DEFAULT 0
);

getToday:
SELECT * FROM DailyStats WHERE date = ?;

upsert:
INSERT OR REPLACE INTO DailyStats (date, tasksCompleted, focusMinutes, sessionsCompleted)
VALUES (?, ?, ?, ?);

getWeek:
SELECT * FROM DailyStats WHERE date >= ? ORDER BY date;

The expect/actual for database driver:

// commonMain
expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

// androidMain
actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver {
        return AndroidSqliteDriver(FocusFlowDb.Schema, context, "focusflow.db")
    }
}

// iosMain
actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver {
        return NativeSqliteDriver(FocusFlowDb.Schema, "focusflow.db")
    }
}

My review: The project structure was correct. The timer state machine was clean and testable. The SQLDelight queries were well-defined. The expect/actual for the database driver followed KMP best practices.

Problem 1: The libs.versions.toml file had the wrong SQLDelight plugin ID. Claude used app.cash.sqldelight (correct for SQLDelight 2.x) but the version catalog entry was wrong. I fixed it:

[plugins]
sqldelight = { id = "app.cash.sqldelight", version = "2.0.2" }

Problem 2: The iOS targets needed iosMain as a source set that depends on both iosX64Main, iosArm64Main, and iosSimulatorArm64Main. Claude set this up correctly with intermediate source sets, but the Gradle sync took 4 attempts before it worked because of a missing dependsOn declaration.

Problem 3: The CoroutineScope in the timer was created with Dispatchers.Default, but on iOS, you need Dispatchers.Main for state updates that will be observed by SwiftUI. I used expect/actual for the dispatcher.

Time spent: 68 minutes (most of it was Gradle debugging)

Prompt 2: Android UI with Jetpack Compose (Minute 68)

Build the Android UI in androidApp/ using Jetpack Compose + Material 3.

Screens:
1. Home screen — current timer (large circular progress), today's stats summary, active tasks
2. Tasks screen — list of tasks with add/complete/delete, tabs for Active/Completed
3. Stats screen — daily/weekly charts showing focus time and tasks completed
4. Settings screen — timer durations, notification toggle, daily goal

Navigation: Bottom navigation bar with 4 tabs (Home, Tasks, Stats, Settings)

Timer display:
- Large circular progress indicator
- Time remaining in MM:SS format
- Phase label (Working, Short Break, Long Break)
- Start/Pause button, Skip button, Reset button
- Session count dots (4 dots, filled for completed sessions)

Design:
- Material 3 with dynamic colors
- Dark and light theme support
- Use Material 3 components: TopAppBar, NavigationBar, FloatingActionButton, Card

What Claude generated:

The home screen with the timer:

@Composable
fun HomeScreen(
    timerState: PomodoroTimer.TimerState,
    todayStats: DailyStats?,
    activeTasks: List<Task>,
    onStart: () -> Unit,
    onPause: () -> Unit,
    onSkip: () -> Unit,
    onReset: () -> Unit,
    onCompleteTask: (Long) -> Unit,
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        // Timer section
        TimerDisplay(
            state = timerState,
            onStart = onStart,
            onPause = onPause,
            onSkip = onSkip,
            onReset = onReset,
        )

        Spacer(modifier = Modifier.height(24.dp))

        // Today's stats
        if (todayStats != null) {
            TodayStatsCard(stats = todayStats)
        }

        Spacer(modifier = Modifier.height(16.dp))

        // Active tasks
        Text(
            text = "Active Tasks",
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.fillMaxWidth(),
        )
        Spacer(modifier = Modifier.height(8.dp))

        activeTasks.take(5).forEach { task ->
            TaskItem(
                task = task,
                onComplete = { onCompleteTask(task.id) },
            )
        }
    }
}

@Composable
fun TimerDisplay(
    state: PomodoroTimer.TimerState,
    onStart: () -> Unit,
    onPause: () -> Unit,
    onSkip: () -> Unit,
    onReset: () -> Unit,
) {
    val progress = if (state.totalSeconds > 0) {
        state.remainingSeconds.toFloat() / state.totalSeconds.toFloat()
    } else 0f

    val phaseColor = when (state.phase) {
        PomodoroTimer.Phase.WORKING -> MaterialTheme.colorScheme.primary
        PomodoroTimer.Phase.SHORT_BREAK -> MaterialTheme.colorScheme.secondary
        PomodoroTimer.Phase.LONG_BREAK -> MaterialTheme.colorScheme.tertiary
        PomodoroTimer.Phase.IDLE -> MaterialTheme.colorScheme.outline
    }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        // Phase label
        Text(
            text = when (state.phase) {
                PomodoroTimer.Phase.IDLE -> "Ready"
                PomodoroTimer.Phase.WORKING -> "Focus Time"
                PomodoroTimer.Phase.SHORT_BREAK -> "Short Break"
                PomodoroTimer.Phase.LONG_BREAK -> "Long Break"
            },
            style = MaterialTheme.typography.titleLarge,
            color = phaseColor,
        )

        Spacer(modifier = Modifier.height(16.dp))

        // Circular timer
        Box(contentAlignment = Alignment.Center) {
            CircularProgressIndicator(
                progress = { progress },
                modifier = Modifier.size(240.dp),
                color = phaseColor,
                trackColor = phaseColor.copy(alpha = 0.1f),
                strokeWidth = 12.dp,
                strokeCap = StrokeCap.Round,
            )

            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(
                    text = formatTime(state.remainingSeconds),
                    style = MaterialTheme.typography.displayLarge,
                    fontWeight = FontWeight.Light,
                )

                // Session dots
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    repeat(4) { index ->
                        Box(
                            modifier = Modifier
                                .size(8.dp)
                                .clip(CircleShape)
                                .background(
                                    if (index < state.sessionCount % 4) phaseColor
                                    else phaseColor.copy(alpha = 0.2f)
                                )
                        )
                    }
                }
            }
        }

        Spacer(modifier = Modifier.height(24.dp))

        // Control buttons
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            // Reset button
            IconButton(onClick = onReset) {
                Icon(Icons.Default.Refresh, contentDescription = "Reset")
            }

            // Start/Pause button
            FilledIconButton(
                onClick = if (state.isRunning) onPause else onStart,
                modifier = Modifier.size(64.dp),
            ) {
                Icon(
                    imageVector = if (state.isRunning) Icons.Default.Pause else Icons.Default.PlayArrow,
                    contentDescription = if (state.isRunning) "Pause" else "Start",
                    modifier = Modifier.size(32.dp),
                )
            }

            // Skip button
            IconButton(onClick = onSkip) {
                Icon(Icons.Default.SkipNext, contentDescription = "Skip")
            }
        }
    }
}

private fun formatTime(seconds: Int): String {
    val minutes = seconds / 60
    val secs = seconds % 60
    return "%02d:%02d".format(minutes, secs)
}

The bottom navigation:

@Composable
fun MainScreen(viewModel: MainViewModel = koinViewModel()) {
    val navController = rememberNavController()

    Scaffold(
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentRoute = navBackStackEntry?.destination?.route

                listOf(
                    Triple("home", "Home", Icons.Default.Home),
                    Triple("tasks", "Tasks", Icons.Default.CheckCircle),
                    Triple("stats", "Stats", Icons.Default.BarChart),
                    Triple("settings", "Settings", Icons.Default.Settings),
                ).forEach { (route, label, icon) ->
                    NavigationBarItem(
                        icon = { Icon(icon, contentDescription = label) },
                        label = { Text(label) },
                        selected = currentRoute == route,
                        onClick = {
                            navController.navigate(route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                    )
                }
            }
        },
    ) { padding ->
        NavHost(
            navController = navController,
            startDestination = "home",
            modifier = Modifier.padding(padding),
        ) {
            composable("home") { HomeScreen(/* ... */) }
            composable("tasks") { TasksScreen(/* ... */) }
            composable("stats") { StatsScreen(/* ... */) }
            composable("settings") { SettingsScreen(/* ... */) }
        }
    }
}

My review: The Android UI was polished. The circular timer with Material 3 colors, session dots, and smooth controls looked professional. The bottom navigation followed Material 3 guidelines.

Problem: Claude used koinViewModel() but did not set up the Koin Android module correctly. The ViewModel was not injected. I needed to add the ViewModel definition to the Koin module:

val androidModule = module {
    viewModel { MainViewModel(get(), get(), get()) }
}

And initialize Koin in the Application class:

class FocusFlowApp : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@FocusFlowApp)
            modules(commonModule, androidModule)
        }
    }
}

Time spent: 55 minutes

Prompt 3: iOS UI with SwiftUI (Minute 123)

Build the iOS UI in iosApp/ using SwiftUI.

Important: The shared Kotlin module is imported as a framework called "shared".
Access Kotlin classes like: import shared

Screens (same as Android):
1. Home — timer display, today's stats, active tasks
2. Tasks — list with add/complete/delete
3. Stats — daily/weekly summary
4. Settings — timer durations, daily goal

Timer display:
- Circular progress ring
- Time in MM:SS
- Phase label
- Start/Pause/Skip/Reset buttons

Navigation: TabView with 4 tabs

Design:
- Use SwiftUI native components
- Support dark mode
- iOS 17+ target

What Claude generated:

The SwiftUI timer view:

import SwiftUI
import shared

struct TimerView: View {
    @ObservedObject var viewModel: TimerViewModel

    var body: some View {
        VStack(spacing: 24) {
            // Phase label
            Text(phaseLabel)
                .font(.title2)
                .foregroundColor(phaseColor)

            // Circular timer
            ZStack {
                Circle()
                    .stroke(phaseColor.opacity(0.1), lineWidth: 12)
                    .frame(width: 240, height: 240)

                Circle()
                    .trim(from: 0, to: progress)
                    .stroke(
                        phaseColor,
                        style: StrokeStyle(lineWidth: 12, lineCap: .round)
                    )
                    .frame(width: 240, height: 240)
                    .rotationEffect(.degrees(-90))
                    .animation(.linear(duration: 1), value: progress)

                VStack(spacing: 8) {
                    Text(formatTime(viewModel.remainingSeconds))
                        .font(.system(size: 56, weight: .light, design: .monospaced))

                    // Session dots
                    HStack(spacing: 8) {
                        ForEach(0..<4, id: \.self) { index in
                            Circle()
                                .fill(index < viewModel.sessionCount % 4 ? phaseColor : phaseColor.opacity(0.2))
                                .frame(width: 8, height: 8)
                        }
                    }
                }
            }

            // Controls
            HStack(spacing: 32) {
                Button(action: { viewModel.reset() }) {
                    Image(systemName: "arrow.counterclockwise")
                        .font(.title2)
                }

                Button(action: {
                    if viewModel.isRunning {
                        viewModel.pause()
                    } else {
                        viewModel.start()
                    }
                }) {
                    Image(systemName: viewModel.isRunning ? "pause.fill" : "play.fill")
                        .font(.title)
                        .frame(width: 64, height: 64)
                        .background(phaseColor)
                        .foregroundColor(.white)
                        .clipShape(Circle())
                }

                Button(action: { viewModel.skip() }) {
                    Image(systemName: "forward.fill")
                        .font(.title2)
                }
            }
        }
    }

    private var progress: CGFloat {
        guard viewModel.totalSeconds > 0 else { return 0 }
        return CGFloat(viewModel.remainingSeconds) / CGFloat(viewModel.totalSeconds)
    }

    private var phaseLabel: String {
        switch viewModel.phase {
        case .idle: return "Ready"
        case .working: return "Focus Time"
        case .shortBreak: return "Short Break"
        case .longBreak: return "Long Break"
        default: return ""
        }
    }

    private var phaseColor: Color {
        switch viewModel.phase {
        case .working: return .blue
        case .shortBreak: return .green
        case .longBreak: return .purple
        default: return .gray
        }
    }

    private func formatTime(_ seconds: Int32) -> String {
        let mins = seconds / 60
        let secs = seconds % 60
        return String(format: "%02d:%02d", mins, secs)
    }
}

The iOS ViewModel that wraps the Kotlin shared timer:

import shared
import Combine

class TimerViewModel: ObservableObject {
    private let timer: PomodoroTimer

    @Published var remainingSeconds: Int32 = 25 * 60
    @Published var totalSeconds: Int32 = 25 * 60
    @Published var sessionCount: Int32 = 0
    @Published var isRunning: Bool = false
    @Published var phase: PomodoroTimer.Phase = .idle

    private var cancellable: AnyCancellable?

    init(timer: PomodoroTimer) {
        self.timer = timer
        observeState()
    }

    private func observeState() {
        // Collect Kotlin StateFlow from Swift
        let flow = timer.state
        cancellable = FlowPublisher(flow: flow)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] state in
                self?.remainingSeconds = state.remainingSeconds
                self?.totalSeconds = state.totalSeconds
                self?.sessionCount = state.sessionCount
                self?.isRunning = state.isRunning
                self?.phase = state.phase
            }
    }

    func start() { timer.start(durations: TimerDurations.companion.invoke()) }
    func pause() { timer.pause() }
    func skip() { timer.skip(durations: TimerDurations.companion.invoke()) }
    func reset() { timer.reset() }
}

My review: The SwiftUI UI was clean and matched the Android version in functionality. The circular timer, session dots, and control buttons all worked.

Problem 1: Collecting a Kotlin StateFlow from Swift is not straightforward. Claude generated a FlowPublisher class, but it did not exist. In reality, you need a helper function in the Kotlin shared module that converts StateFlow to a callback:

// In shared/iosMain
fun PomodoroTimer.observeState(onChange: (TimerState) -> Unit): Closeable {
    val job = CoroutineScope(Dispatchers.Main).launch {
        state.collect { onChange(it) }
    }
    return object : Closeable {
        override fun close() { job.cancel() }
    }
}

Then in Swift:

private var observer: Closeable?

private func observeState() {
    observer = timer.observeState { [weak self] state in
        DispatchQueue.main.async {
            self?.remainingSeconds = state.remainingSeconds
            self?.totalSeconds = state.totalSeconds
            self?.sessionCount = state.sessionCount
            self?.isRunning = state.isRunning
            self?.phase = state.phase
        }
    }
}

Problem 2: The PomodoroTimer.Phase enum values were exported to Swift as PomodoroTimerPhase.working, not PomodoroTimer.Phase.working. Kotlin enums get flattened in the Objective-C interop layer. I had to update all the switch cases.

Problem 3: The Int type in Kotlin maps to Int32 in Swift, not Int. Claude used Int in the SwiftUI code, but the actual values from the shared module were Int32. This caused type mismatch errors that were confusing.

Time spent: 62 minutes (significant time on Kotlin-Swift interop issues)

Prompt 4: Timer Notifications (Minute 185)

Add timer notifications using expect/actual:

Common interface:
expect class NotificationManager {
    fun requestPermission()
    fun showTimerComplete(phase: String, nextPhase: String)
    fun showTimerRunning(remaining: String)
    fun cancelAll()
}

Android (androidMain):
- Use NotificationCompat with a notification channel
- Show ongoing notification while timer is running
- Show completion notification with sound when phase ends
- Action buttons: Pause, Skip

iOS (iosMain):
- Use UNUserNotificationCenter
- Request notification permission on first launch
- Show local notification when phase ends
- Show timer running as a background task notification

What Claude generated:

Android implementation:

actual class NotificationManager(private val context: Context) {
    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE)
        as android.app.NotificationManager

    init {
        createChannel()
    }

    private fun createChannel() {
        val channel = NotificationChannel(
            CHANNEL_ID,
            "FocusFlow Timer",
            android.app.NotificationManager.IMPORTANCE_HIGH,
        ).apply {
            description = "Timer notifications"
            enableVibration(true)
        }
        notificationManager.createNotificationChannel(channel)
    }

    actual fun requestPermission() {
        // Android 13+ needs POST_NOTIFICATIONS permission
        // Handled in Activity
    }

    actual fun showTimerComplete(phase: String, nextPhase: String) {
        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .setContentTitle("$phase Complete")
            .setContentText("Time for $nextPhase")
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setAutoCancel(true)
            .setDefaults(NotificationCompat.DEFAULT_SOUND or NotificationCompat.DEFAULT_VIBRATE)
            .build()

        notificationManager.notify(COMPLETION_ID, notification)
    }

    actual fun showTimerRunning(remaining: String) {
        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(android.R.drawable.ic_media_play)
            .setContentTitle("FocusFlow")
            .setContentText("$remaining remaining")
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .setOngoing(true)
            .setSilent(true)
            .build()

        notificationManager.notify(ONGOING_ID, notification)
    }

    actual fun cancelAll() {
        notificationManager.cancelAll()
    }

    companion object {
        private const val CHANNEL_ID = "focusflow_timer"
        private const val COMPLETION_ID = 1
        private const val ONGOING_ID = 2
    }
}

iOS implementation:

actual class NotificationManager {
    private val center = UNUserNotificationCenter.currentNotificationCenter()

    actual fun requestPermission() {
        center.requestAuthorizationWithOptions(
            UNAuthorizationOptionAlert or UNAuthorizationOptionSound or UNAuthorizationOptionBadge
        ) { granted, error ->
            if (granted) println("Notification permission granted")
            else println("Notification permission denied: $error")
        }
    }

    actual fun showTimerComplete(phase: String, nextPhase: String) {
        val content = UNMutableNotificationContent().apply {
            setTitle("$phase Complete")
            setBody("Time for $nextPhase")
            setSound(UNNotificationSound.defaultSound())
        }

        val request = UNNotificationRequest.requestWithIdentifier(
            identifier = "timer-complete",
            content = content,
            trigger = null, // Immediate
        )

        center.addNotificationRequest(request) { error ->
            if (error != null) println("Notification error: $error")
        }
    }

    actual fun showTimerRunning(remaining: String) {
        // iOS does not support ongoing notifications like Android
        // Timer updates are shown via the app's UI
    }

    actual fun cancelAll() {
        center.removeAllPendingNotificationRequests()
        center.removeAllDeliveredNotifications()
    }
}

My review: The expect/actual pattern worked correctly. Android notifications used channels (required for API 26+), and iOS used UNUserNotificationCenter. Claude correctly noted that iOS does not support Android-style ongoing notifications.

Problem: The iOS notification implementation used Kotlin/Native APIs for iOS frameworks, but some of the Objective-C interop was wrong. UNAuthorizationOptionAlert or UNAuthorizationOptionSound needed to use the or operator for bitwise OR in Kotlin/Native, but the actual syntax is:

UNAuthorizationOptionAlert.or(UNAuthorizationOptionSound).or(UNAuthorizationOptionBadge)

Or using the ULong bit flag directly. This took some debugging to get right.

Time spent: 35 minutes

Prompt 5: Data Export/Import (Minute 220)

Add data export and import:

1. Export: serialize all tasks and stats to JSON, share via platform share sheet
2. Import: read JSON file, insert tasks and stats into database
3. Use Kotlinx Serialization in the shared module
4. Export format: { version: 1, exportDate: "...", tasks: [...], stats: [...] }
5. Android: use Intent.ACTION_SEND for export, Intent.ACTION_OPEN_DOCUMENT for import
6. iOS: use UIActivityViewController for export, UIDocumentPickerViewController for import

What Claude generated:

The shared serialization:

@Serializable
data class ExportData(
    val version: Int = 1,
    val exportDate: String,
    val tasks: List<TaskData>,
    val stats: List<StatsData>,
)

@Serializable
data class TaskData(
    val title: String,
    val description: String,
    val isCompleted: Boolean,
    val completedAt: String? = null,
    val createdAt: String,
)

@Serializable
data class StatsData(
    val date: String,
    val tasksCompleted: Int,
    val focusMinutes: Int,
    val sessionsCompleted: Int,
)

class DataExporter(
    private val taskRepository: TaskRepository,
    private val statsRepository: StatsRepository,
) {
    suspend fun export(): String {
        val tasks = taskRepository.getAll().map { task ->
            TaskData(
                title = task.title,
                description = task.description,
                isCompleted = task.isCompleted == 1L,
                completedAt = task.completedAt,
                createdAt = task.createdAt,
            )
        }

        val stats = statsRepository.getAll().map { stat ->
            StatsData(
                date = stat.date,
                tasksCompleted = stat.tasksCompleted.toInt(),
                focusMinutes = stat.focusMinutes.toInt(),
                sessionsCompleted = stat.sessionsCompleted.toInt(),
            )
        }

        val exportData = ExportData(
            exportDate = Clock.System.now().toString(),
            tasks = tasks,
            stats = stats,
        )

        return Json.encodeToString(exportData)
    }

    suspend fun import(json: String) {
        val data = Json.decodeFromString<ExportData>(json)

        data.tasks.forEach { task ->
            taskRepository.add(task.title, task.description)
        }

        data.stats.forEach { stat ->
            statsRepository.upsert(stat.date, stat.tasksCompleted, stat.focusMinutes, stat.sessionsCompleted)
        }
    }
}

My review: The export/import logic was clean. Kotlinx Serialization handled the JSON conversion, and the platform-specific share sheet integration was correct for both Android and iOS.

No major issues. This was the simplest prompt in the session.

Time spent: 20 minutes

Prompt 6: Settings and Polish (Minute 240)

Add settings screen:

1. Timer durations: work (15-60 min), short break (3-15 min), long break (10-30 min)
   - Use sliders or number pickers
2. Daily goal: target number of focus sessions per day (1-12)
3. Notifications toggle
4. Dark mode toggle (Android follows system by default)
5. About section with version number

Save settings using the SettingsRepository (SQLDelight key-value store).
Apply timer duration changes immediately (restart current timer with new duration).

Also add:
6. Stats screen: show a bar chart of focus minutes per day for the last 7 days
7. Streak counter: consecutive days with at least 1 completed session

What Claude generated:

The Android settings screen:

@Composable
fun SettingsScreen(viewModel: SettingsViewModel = koinViewModel()) {
    val settings by viewModel.settings.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(16.dp),
    ) {
        Text("Settings", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(24.dp))

        // Timer durations
        SettingsSection(title = "Timer Durations") {
            DurationSetting(
                label = "Work",
                value = settings.workMinutes,
                range = 15f..60f,
                onValueChange = { viewModel.updateWorkMinutes(it) },
            )
            DurationSetting(
                label = "Short Break",
                value = settings.shortBreakMinutes,
                range = 3f..15f,
                onValueChange = { viewModel.updateShortBreakMinutes(it) },
            )
            DurationSetting(
                label = "Long Break",
                value = settings.longBreakMinutes,
                range = 10f..30f,
                onValueChange = { viewModel.updateLongBreakMinutes(it) },
            )
        }

        // Daily goal
        SettingsSection(title = "Daily Goal") {
            DurationSetting(
                label = "Focus sessions",
                value = settings.dailyGoal,
                range = 1f..12f,
                onValueChange = { viewModel.updateDailyGoal(it) },
            )
        }

        // Notifications
        SettingsSection(title = "Notifications") {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text("Enable notifications")
                Switch(
                    checked = settings.notificationsEnabled,
                    onCheckedChange = { viewModel.toggleNotifications(it) },
                )
            }
        }

        // Export/Import
        SettingsSection(title = "Data") {
            OutlinedButton(
                onClick = { viewModel.exportData() },
                modifier = Modifier.fillMaxWidth(),
            ) {
                Text("Export Data")
            }
            Spacer(modifier = Modifier.height(8.dp))
            OutlinedButton(
                onClick = { viewModel.importData() },
                modifier = Modifier.fillMaxWidth(),
            ) {
                Text("Import Data")
            }
        }
    }
}

@Composable
fun DurationSetting(
    label: String,
    value: Int,
    range: ClosedFloatingPointRange<Float>,
    onValueChange: (Int) -> Unit,
) {
    Column(modifier = Modifier.padding(vertical = 8.dp)) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
        ) {
            Text(label, style = MaterialTheme.typography.bodyLarge)
            Text("$value min", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary)
        }
        Slider(
            value = value.toFloat(),
            onValueChange = { onValueChange(it.toInt()) },
            valueRange = range,
            steps = (range.endInclusive - range.start).toInt() - 1,
        )
    }
}

My review: The settings screen was functional. Sliders for timer durations, switch for notifications, and buttons for export/import. The stats screen with a basic bar chart for the last 7 days also worked.

Problem: The streak calculation was wrong. Claude counted consecutive days with any stats record, but it should count consecutive days with at least one completed session. The fix:

fun getStreak(): Int {
    val stats = database.dailyStatsQueries.getAll().executeAsList()
    var streak = 0
    var currentDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date

    while (true) {
        val dateStr = currentDate.toString()
        val dayStat = stats.find { it.date == dateStr }

        if (dayStat != null && dayStat.sessionsCompleted > 0) {
            streak++
            currentDate = currentDate.minus(1, DateTimeUnit.DAY)
        } else {
            break
        }
    }

    return streak
}

Time spent: 38 minutes

The remaining time (about 1 hour 53 minutes) was spent on:

  • Fixing Gradle sync issues (3 more attempts after Prompt 1)
  • Getting the iOS Xcode project to find the shared framework
  • Fixing Kotlin/Native memory model issues (freeze/unfreeze)
  • Testing on Android emulator and iOS simulator
  • Adding proper error handling for database operations
  • Making the timer survive app background on Android (foreground service)
  • Fixing the StateFlow observation from Swift
  • Dark mode testing on both platforms

Total time: 5 hours 11 minutes.

What Went Right

  • The timer state machine was clean and testable. Putting all timer logic in the shared module meant it worked identically on both platforms. No platform-specific timer bugs.
  • SQLDelight worked well for local storage. The type-safe queries, generated Kotlin code, and cross-platform support made database operations straightforward.
  • Both UIs looked native. The Android UI with Material 3 dynamic colors and the iOS UI with SwiftUI both looked like they belonged on their respective platforms.
  • Data export/import was simple with Kotlinx Serialization. The shared serialization logic meant the export format was identical on both platforms.

What Went Wrong

1. Gradle Configuration (40% of Debug Time)

KMP Gradle configuration is the hardest part of any KMP project. Version catalogs, source set dependencies, iOS framework exports, and plugin compatibility all needed manual fixes. Claude got the structure right but missed details like dependsOn for intermediate source sets.

Lesson: For KMP projects, always specify exact dependency versions in your prompt. Include the Gradle plugin versions, Kotlin version, and any known compatibility constraints.

2. Kotlin-Swift Interop

StateFlow, enums, and number types all have interop quirks. StateFlow cannot be directly observed from Swift. Kotlin enums get flattened. Int becomes Int32. Claude generated Swift code that assumed Kotlin types mapped directly, which they do not.

Lesson: Kotlin-Swift interop is the biggest pain point in KMP. Create helper functions in iosMain that expose simple types and callbacks instead of complex Kotlin types.

3. iOS Bitwise Operators

Kotlin/Native uses different syntax for Objective-C bitwise flag operations. Claude used Kotlin’s or operator, but the correct syntax for ULong flags is different.

Lesson: Kotlin/Native interop with Objective-C frameworks has syntax differences. Always test platform-specific code on the actual platform, not just in the IDE.

4. No Foreground Service for Timer

The timer stopped when the Android app went to the background. Claude did not implement a foreground service. On iOS, background execution is even more limited. The timer needs platform-specific background execution strategies.

Lesson: Timers in mobile apps require foreground services (Android) and background task management (iOS). This is always more complex than expected and is a critical feature that should be in the first prompt.

The Final Result

A cross-platform productivity app with:

  • Pomodoro timer with work/break cycles
  • Task management with SQLDelight
  • Daily stats and streak tracking
  • Timer notifications on both platforms
  • Settings with customizable durations
  • Data export/import
  • Material 3 on Android, native SwiftUI on iOS

To run it yourself:

git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout mobile-app

# Android
# Open in Android Studio, run on emulator

# iOS
cd iosApp
pod install  # if using CocoaPods
# Open .xcworkspace in Xcode, run on simulator

Time and Cost

  • Total time: 5 hours 11 minutes
  • Claude prompts: 6 (plus 10+ fixes)
  • Estimated token usage: ~95,000 tokens
  • Gradle debugging: ~2 hours (40% of total time)
  • Hosting cost: $0 (mobile app, no server)

Lessons Learned

1. KMP is at the edge of what vibe coding can handle. The build system complexity, platform interop quirks, and multi-target compilation make it the hardest project type for AI-assisted development. You need KMP experience to debug the issues Claude creates.

2. Shared business logic is the sweet spot. The timer state machine, repository pattern, and serialization logic were perfect for the shared module. Platform-specific UI was better written with platform-specific prompts.

3. Gradle is the enemy. Two hours of a five-hour session were spent on Gradle. Version conflicts, plugin compatibility, and source set configuration are not things Claude handles well because the error messages are cryptic.

4. Test on real devices early. Running on the emulator/simulator from Prompt 2 onward would have caught interop issues earlier. I waited until the end and had to fix multiple issues in sequence.

5. KMP + Claude works if you know KMP. If you are an experienced KMP developer, Claude speeds up the boilerplate significantly. If you are learning KMP, Claude will confuse you with plausible but broken configurations.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: mobile-app)

What’s Next?

The final article: From Idea to Production in One Day — Complete Workflow Guide. This is the capstone. We take a real product idea from concept to deployed production app in a single day. Everything we learned from 14 projects comes together.