This is the hardest project in Part 2. Kotlin Multiplatform has a complex build system, platform-specific code patterns, and a Gradle configuration that can break in surprising ways. Can Claude handle it?

I was skeptical going in. KMP projects have dozens of Gradle files, expect/actual declarations, and platform-specific dependencies that need to match exact versions. One wrong version number and nothing compiles.

Total time: 4 hours 31 minutes. 6 prompts. Most of the time was fighting Gradle.

What We’re Building

A cross-platform budget tracker running on Android and iOS with:

  • Add transactions — income or expense with amount, category, date, and note
  • Transaction list grouped by date with running balance
  • Category management — preset categories with icons, custom categories
  • Monthly summary — total income, expenses, and balance for current month
  • Pie chart showing expenses by category
  • Bar chart showing income vs expenses for last 6 months
  • CSV export of transactions
  • Dashboard with summary, recent transactions, and pie chart
  • Dark mode support with Material 3

Tech stack: Kotlin, Compose Multiplatform (shared UI), Room for database, Koin for dependency injection, Vico for charts

Prerequisites

  • Claude Code installed and configured
  • Android Studio (latest stable) installed
  • Xcode installed (for iOS, macOS only)
  • JDK 17+

The Build Session

Total time: 4 hours 31 minutes. 6 prompts.

Prompt 1: Project Setup and Core Data Layer (Minute 0)

KMP project setup is the most critical step. If the Gradle configuration is wrong, nothing else matters.

Create a Compose Multiplatform (KMP) project for a budget tracker app.

Project setup:
- Use the Kotlin Multiplatform Wizard template structure
- Shared UI with Compose Multiplatform (not shared-logic-only)
- Targets: Android + iOS
- Gradle version catalog (libs.versions.toml)
- Kotlin 2.0.21, Compose Multiplatform 1.7.3

Data layer:
- Room database with KMP support
- Entities: Transaction (id, amount, type (income/expense), categoryId,
  note, date, createdAt), Category (id, name, icon, color, isCustom)
- DAOs with Flow return types for reactive updates
- Koin for dependency injection

Do NOT use SQLDelight. Use Room with KMP support (room-runtime 2.7.0-alpha).

What Claude generated:

A full project structure:

budget-tracker/
  composeApp/
    src/
      commonMain/
        kotlin/
          com/kemalcodes/budget/
            App.kt
            data/
              database/
                AppDatabase.kt
                TransactionDao.kt
                CategoryDao.kt
              model/
                Transaction.kt
                Category.kt
              repository/
                TransactionRepository.kt
                CategoryRepository.kt
            di/
              AppModule.kt
            ui/
              theme/
                Theme.kt
                Color.kt
                Type.kt
              screens/
                DashboardScreen.kt
                TransactionsScreen.kt
                AddTransactionScreen.kt
      androidMain/
        kotlin/
          com/kemalcodes/budget/
            MainApplication.kt
            DatabaseBuilder.kt
      iosMain/
        kotlin/
          com/kemalcodes/budget/
            MainViewController.kt
            DatabaseBuilder.kt
  iosApp/
    iosApp/
      iOSApp.swift
      ContentView.swift
  gradle/
    libs.versions.toml
  build.gradle.kts
  settings.gradle.kts

The version catalog:

[versions]
kotlin = "2.0.21"
compose-multiplatform = "1.7.3"
room = "2.7.0-alpha12"
koin = "4.0.0"
ksp = "2.0.21-1.0.28"
coroutines = "1.9.0"
navigation = "2.8.0-alpha10"

[libraries]
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" }

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" }

The Room database setup:

// commonMain - AppDatabase.kt
@Database(
    entities = [Transaction::class, Category::class],
    version = 1
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun transactionDao(): TransactionDao
    abstract fun categoryDao(): CategoryDao
}

// commonMain - Transaction.kt
@Entity(tableName = "transactions")
data class Transaction(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val amount: Double,
    val type: TransactionType,
    val categoryId: Long,
    val note: String = "",
    val date: Long, // epoch millis
    val createdAt: Long = System.currentTimeMillis()
)

enum class TransactionType {
    INCOME, EXPENSE
}

// commonMain - Category.kt
@Entity(tableName = "categories")
data class Category(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val icon: String, // emoji
    val color: Long, // ARGB color as Long
    val isCustom: Boolean = false
)

The DAO with Flow:

@Dao
interface TransactionDao {
    @Query("SELECT * FROM transactions ORDER BY date DESC")
    fun getAllTransactions(): Flow<List<Transaction>>

    @Query("""
        SELECT * FROM transactions
        WHERE date >= :startOfMonth AND date < :endOfMonth
        ORDER BY date DESC
    """)
    fun getTransactionsForMonth(startOfMonth: Long, endOfMonth: Long): Flow<List<Transaction>>

    @Query("""
        SELECT SUM(amount) FROM transactions
        WHERE type = 'INCOME' AND date >= :startOfMonth AND date < :endOfMonth
    """)
    fun getTotalIncome(startOfMonth: Long, endOfMonth: Long): Flow<Double?>

    @Query("""
        SELECT SUM(amount) FROM transactions
        WHERE type = 'EXPENSE' AND date >= :startOfMonth AND date < :endOfMonth
    """)
    fun getTotalExpenses(startOfMonth: Long, endOfMonth: Long): Flow<Double?>

    @Insert
    suspend fun insert(transaction: Transaction)

    @Delete
    suspend fun delete(transaction: Transaction)
}

Platform-specific database builders:

// androidMain - DatabaseBuilder.kt
fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<AppDatabase> {
    val dbFile = context.getDatabasePath("budget.db")
    return Room.databaseBuilder<AppDatabase>(
        context = context,
        name = dbFile.absolutePath
    )
}

// iosMain - DatabaseBuilder.kt
fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFile = NSHomeDirectory() + "/Documents/budget.db"
    return Room.databaseBuilder<AppDatabase>(
        name = dbFile
    )
}

My review: The project structure looked correct. The version catalog had reasonable versions. The Room setup used the KMP-compatible API with expect/actual for database builders.

I tried to build:

./gradlew :composeApp:assembleDebug

It failed. The Room KMP compiler plugin was not configured correctly. The ksp plugin was applied but the Room compiler was not added to the KSP configuration.

This was the first of many Gradle issues.

The Gradle Fix Marathon (Minutes 8-45)

I spent 37 minutes on Gradle configuration. Here is a summary of every issue:

The build fails. Room KMP requires the room Gradle plugin and KSP
configured for each target. Fix the build.gradle.kts for the composeApp
module:
1. Apply the room plugin
2. Configure KSP to generate Room code for all targets
3. The Room schema directory must be set via the room extension
4. Make sure type converters are set up for TransactionType enum

Issue 1: Room KMP needs both the room Gradle plugin AND KSP. Claude applied KSP but not the Room plugin. Fix:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.compose.multiplatform)
    alias(libs.plugins.compose.compiler)
    alias(libs.plugins.ksp)
    alias(libs.plugins.room)
}

room {
    schemaDirectory("$projectDir/schemas")
}

Issue 2: KSP configuration for Room KMP required adding the compiler to specific targets:

dependencies {
    ksp(libs.room.compiler)
}

But this did not work because KSP for KMP needs target-specific configuration. Claude had to restructure the dependency block.

Issue 3: The TransactionType enum needed a type converter for Room. Claude forgot to create it:

class Converters {
    @TypeConverter
    fun fromTransactionType(value: TransactionType): String = value.name

    @TypeConverter
    fun toTransactionType(value: String): TransactionType = TransactionType.valueOf(value)
}

And add it to the database:

@Database(
    entities = [Transaction::class, Category::class],
    version = 1
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { ... }

Issue 4: The System.currentTimeMillis() call in the Transaction data class is not available in KMP common code. It works on Android but not iOS. Fix:

// commonMain
expect fun currentTimeMillis(): Long

// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()

// iosMain
actual fun currentTimeMillis(): Long =
    (NSDate().timeIntervalSince1970 * 1000).toLong()

After all four fixes, the project compiled. This was the hardest part of the entire build.

Time spent: 45 minutes (including initial setup)

Prompt 2: Dashboard Screen (Minute 45)

Build the dashboard home screen with Compose Multiplatform:
1. Top section: current month name + year (e.g., "August 2026")
2. Summary cards in a row: Total Income (green), Total Expenses (red),
   Balance (blue or red depending on positive/negative)
3. Recent transactions list (last 5) with category icon, name, amount,
   and date
4. Each transaction shows green for income, red for expense
5. Floating action button to add a new transaction
6. Use Material 3 with dynamic colors on Android

What Claude generated:

@Composable
fun DashboardScreen(
    viewModel: DashboardViewModel = koinViewModel(),
    onAddTransaction: () -> Unit,
    onViewAll: () -> Unit
) {
    val state by viewModel.state.collectAsState()

    Scaffold(
        floatingActionButton = {
            FloatingActionButton(
                onClick = onAddTransaction,
                containerColor = MaterialTheme.colorScheme.primary
            ) {
                Icon(Icons.Default.Add, contentDescription = "Add transaction")
            }
        }
    ) { padding ->
        LazyColumn(
            modifier = Modifier.fillMaxSize().padding(padding),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            item {
                Text(
                    text = state.currentMonth,
                    style = MaterialTheme.typography.headlineMedium,
                    fontWeight = FontWeight.Bold
                )
            }

            item {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    SummaryCard(
                        title = "Income",
                        amount = state.totalIncome,
                        color = Color(0xFF4CAF50),
                        modifier = Modifier.weight(1f)
                    )
                    SummaryCard(
                        title = "Expenses",
                        amount = state.totalExpenses,
                        color = Color(0xFFE53935),
                        modifier = Modifier.weight(1f)
                    )
                    SummaryCard(
                        title = "Balance",
                        amount = state.balance,
                        color = if (state.balance >= 0) Color(0xFF1E88E5) else Color(0xFFE53935),
                        modifier = Modifier.weight(1f)
                    )
                }
            }

            item {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        "Recent Transactions",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.SemiBold
                    )
                    TextButton(onClick = onViewAll) {
                        Text("View All")
                    }
                }
            }

            items(state.recentTransactions) { transaction ->
                TransactionItem(transaction = transaction)
            }

            if (state.recentTransactions.isEmpty()) {
                item {
                    Text(
                        "No transactions yet. Tap + to add one.",
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        modifier = Modifier.padding(vertical = 24.dp)
                    )
                }
            }
        }
    }
}

@Composable
fun SummaryCard(
    title: String,
    amount: Double,
    color: Color,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier,
        colors = CardDefaults.cardColors(
            containerColor = color.copy(alpha = 0.1f)
        )
    ) {
        Column(modifier = Modifier.padding(12.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.labelSmall,
                color = color
            )
            Text(
                text = formatCurrency(amount),
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                color = color,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
        }
    }
}

@Composable
fun TransactionItem(transaction: TransactionWithCategory) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        // Category icon
        Surface(
            shape = CircleShape,
            color = Color(transaction.category.color).copy(alpha = 0.1f),
            modifier = Modifier.size(44.dp)
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text(
                    text = transaction.category.icon,
                    fontSize = 20.sp
                )
            }
        }

        Spacer(modifier = Modifier.width(12.dp))

        Column(modifier = Modifier.weight(1f)) {
            Text(
                text = transaction.category.name,
                style = MaterialTheme.typography.bodyLarge,
                fontWeight = FontWeight.Medium
            )
            Text(
                text = transaction.transaction.note.ifEmpty { formatDate(transaction.transaction.date) },
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }

        Text(
            text = "${if (transaction.transaction.type == TransactionType.INCOME) "+" else "-"}${formatCurrency(transaction.transaction.amount)}",
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.SemiBold,
            color = if (transaction.transaction.type == TransactionType.INCOME)
                Color(0xFF4CAF50) else Color(0xFFE53935)
        )
    }
}

The ViewModel:

class DashboardViewModel(
    private val transactionRepository: TransactionRepository,
    private val categoryRepository: CategoryRepository
) : ViewModel() {

    data class DashboardState(
        val currentMonth: String = "",
        val totalIncome: Double = 0.0,
        val totalExpenses: Double = 0.0,
        val balance: Double = 0.0,
        val recentTransactions: List<TransactionWithCategory> = emptyList()
    )

    private val _state = MutableStateFlow(DashboardState())
    val state: StateFlow<DashboardState> = _state.asStateFlow()

    init {
        viewModelScope.launch {
            val calendar = Calendar.getInstance()
            val monthName = SimpleDateFormat("MMMM yyyy", Locale.getDefault())
                .format(calendar.time)

            val startOfMonth = calendar.apply {
                set(Calendar.DAY_OF_MONTH, 1)
                set(Calendar.HOUR_OF_DAY, 0)
                set(Calendar.MINUTE, 0)
                set(Calendar.SECOND, 0)
                set(Calendar.MILLISECOND, 0)
            }.timeInMillis

            calendar.add(Calendar.MONTH, 1)
            val endOfMonth = calendar.timeInMillis

            combine(
                transactionRepository.getTransactionsForMonth(startOfMonth, endOfMonth),
                transactionRepository.getTotalIncome(startOfMonth, endOfMonth),
                transactionRepository.getTotalExpenses(startOfMonth, endOfMonth),
            ) { transactions, income, expenses ->
                DashboardState(
                    currentMonth = monthName,
                    totalIncome = income ?: 0.0,
                    totalExpenses = expenses ?: 0.0,
                    balance = (income ?: 0.0) - (expenses ?: 0.0),
                    recentTransactions = transactions.take(5)
                )
            }.collect { _state.value = it }
        }
    }
}

Problem: Claude used java.util.Calendar and java.text.SimpleDateFormat, which are not available in KMP common code. These are JVM-only APIs.

Calendar and SimpleDateFormat are JVM-only. Replace with kotlinx-datetime
for KMP compatibility. Use LocalDate and DateTimeFormatter from
kotlinx-datetime.

Claude replaced the calendar logic:

import kotlinx.datetime.*

val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val monthName = "${now.month.name.lowercase().replaceFirstChar { it.uppercase() }} ${now.year}"

val startOfMonth = LocalDateTime(now.year, now.month, 1, 0, 0)
    .toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()

val nextMonth = if (now.monthNumber == 12) {
    LocalDateTime(now.year + 1, 1, 1, 0, 0)
} else {
    LocalDateTime(now.year, now.monthNumber + 1, 1, 0, 0)
}
val endOfMonth = nextMonth.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()

After this fix, the dashboard compiled and ran on both Android and desktop.

Time spent: 22 minutes

Prompt 3: Add Transaction Screen (Minute 67)

Build the Add Transaction screen:
1. Toggle at the top: Income / Expense (tab-style selector)
2. Amount input — large number input, formatted as currency
3. Category picker — grid of category icons (4 columns)
4. Date picker — defaults to today, tap to change
5. Note input — optional text field
6. Save button at the bottom
7. Preset categories:
   Expenses: Food, Transport, Shopping, Bills, Health, Entertainment, Education, Other
   Income: Salary, Freelance, Investment, Gift, Other
   Each with an emoji icon and a distinct color

What Claude generated:

@Composable
fun AddTransactionScreen(
    viewModel: AddTransactionViewModel = koinViewModel(),
    onSaved: () -> Unit
) {
    var type by remember { mutableStateOf(TransactionType.EXPENSE) }
    var amount by remember { mutableStateOf("") }
    var selectedCategory by remember { mutableStateOf<Category?>(null) }
    var note by remember { mutableStateOf("") }
    var selectedDate by remember { mutableStateOf(Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date) }
    var showDatePicker by remember { mutableStateOf(false) }

    val categories by viewModel.getCategoriesForType(type).collectAsState(emptyList())

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
            .verticalScroll(rememberScrollState())
    ) {
        // Income/Expense toggle
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .clip(RoundedCornerShape(12.dp))
                .background(MaterialTheme.colorScheme.surfaceVariant)
                .padding(4.dp)
        ) {
            TransactionTypeTab(
                text = "Expense",
                selected = type == TransactionType.EXPENSE,
                onClick = { type = TransactionType.EXPENSE; selectedCategory = null },
                modifier = Modifier.weight(1f)
            )
            TransactionTypeTab(
                text = "Income",
                selected = type == TransactionType.INCOME,
                onClick = { type = TransactionType.INCOME; selectedCategory = null },
                modifier = Modifier.weight(1f)
            )
        }

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

        // Amount input
        OutlinedTextField(
            value = amount,
            onValueChange = { newValue ->
                if (newValue.isEmpty() || newValue.matches(Regex("^\\d*\\.?\\d{0,2}$"))) {
                    amount = newValue
                }
            },
            label = { Text("Amount") },
            prefix = { Text("$") },
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
            modifier = Modifier.fillMaxWidth(),
            textStyle = MaterialTheme.typography.headlineMedium,
            singleLine = true
        )

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

        // Category picker
        Text(
            "Category",
            style = MaterialTheme.typography.titleSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
        Spacer(modifier = Modifier.height(8.dp))

        LazyVerticalGrid(
            columns = GridCells.Fixed(4),
            modifier = Modifier.height(200.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(categories) { category ->
                CategoryChip(
                    category = category,
                    selected = selectedCategory?.id == category.id,
                    onClick = { selectedCategory = category }
                )
            }
        }

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

        // Date picker
        OutlinedTextField(
            value = formatDate(selectedDate),
            onValueChange = {},
            readOnly = true,
            label = { Text("Date") },
            trailingIcon = {
                IconButton(onClick = { showDatePicker = true }) {
                    Icon(Icons.Default.DateRange, contentDescription = "Pick date")
                }
            },
            modifier = Modifier.fillMaxWidth()
        )

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

        // Note
        OutlinedTextField(
            value = note,
            onValueChange = { note = it },
            label = { Text("Note (optional)") },
            modifier = Modifier.fillMaxWidth(),
            maxLines = 3
        )

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

        // Save button
        Button(
            onClick = {
                val amountValue = amount.toDoubleOrNull() ?: return@Button
                val category = selectedCategory ?: return@Button
                viewModel.saveTransaction(
                    amount = amountValue,
                    type = type,
                    categoryId = category.id,
                    note = note,
                    date = selectedDate
                )
                onSaved()
            },
            enabled = amount.isNotEmpty() && selectedCategory != null,
            modifier = Modifier.fillMaxWidth().height(56.dp),
            shape = RoundedCornerShape(12.dp)
        ) {
            Text("Save Transaction", style = MaterialTheme.typography.titleMedium)
        }
    }
}

@Composable
fun CategoryChip(
    category: Category,
    selected: Boolean,
    onClick: () -> Unit
) {
    Surface(
        onClick = onClick,
        shape = RoundedCornerShape(12.dp),
        color = if (selected) Color(category.color).copy(alpha = 0.2f)
            else MaterialTheme.colorScheme.surfaceVariant,
        border = if (selected) BorderStroke(2.dp, Color(category.color)) else null
    ) {
        Column(
            modifier = Modifier.padding(8.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(category.icon, fontSize = 24.sp)
            Spacer(modifier = Modifier.height(4.dp))
            Text(
                category.name,
                style = MaterialTheme.typography.labelSmall,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
        }
    }
}

Preset categories seeded in the database:

suspend fun seedCategories(categoryDao: CategoryDao) {
    val expenses = listOf(
        Category(name = "Food", icon = "\uD83C\uDF54", color = 0xFFFF9800, isCustom = false),
        Category(name = "Transport", icon = "\uD83D\uDE97", color = 0xFF2196F3, isCustom = false),
        Category(name = "Shopping", icon = "\uD83D\uDECD\uFE0F", color = 0xFFE91E63, isCustom = false),
        Category(name = "Bills", icon = "\uD83D\uDCB0", color = 0xFF9C27B0, isCustom = false),
        Category(name = "Health", icon = "\uD83C\uDFE5", color = 0xFF4CAF50, isCustom = false),
        Category(name = "Entertainment", icon = "\uD83C\uDFAC", color = 0xFFFF5722, isCustom = false),
        Category(name = "Education", icon = "\uD83D\uDCDA", color = 0xFF607D8B, isCustom = false),
        Category(name = "Other", icon = "\uD83D\uDCCC", color = 0xFF795548, isCustom = false),
    )

    val income = listOf(
        Category(name = "Salary", icon = "\uD83D\uDCBC", color = 0xFF4CAF50, isCustom = false),
        Category(name = "Freelance", icon = "\uD83D\uDCBB", color = 0xFF00BCD4, isCustom = false),
        Category(name = "Investment", icon = "\uD83D\uDCC8", color = 0xFF8BC34A, isCustom = false),
        Category(name = "Gift", icon = "\uD83C\uDF81", color = 0xFFFFC107, isCustom = false),
        Category(name = "Other", icon = "\uD83D\uDCCC", color = 0xFF9E9E9E, isCustom = false),
    )

    (expenses + income).forEach { categoryDao.insert(it) }
}

Problem: The LazyVerticalGrid inside a verticalScroll Column caused a crash: “Nesting scrollable in the same direction is not allowed.” Claude used a scrollable Column containing a LazyVerticalGrid, which is a common Compose mistake.

LazyVerticalGrid inside a scrollable Column crashes. Replace the
LazyVerticalGrid with a regular Grid layout using FlowRow or a fixed
Column/Row structure. The number of categories is small (8-13), so
lazy loading is not needed.

Claude replaced it with a non-lazy grid using FlowRow:

FlowRow(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.spacedBy(8.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),
    maxItemsInEachRow = 4
) {
    categories.forEach { category ->
        CategoryChip(
            category = category,
            selected = selectedCategory?.id == category.id,
            onClick = { selectedCategory = category },
            modifier = Modifier.width(80.dp)
        )
    }
}

Time spent: 18 minutes

Prompt 4: Charts (Minute 85)

Add charts to the dashboard:

1. Expense pie chart showing spending by category
   - Use a custom Canvas composable (no external library)
   - Show category name, amount, and percentage in a legend below
   - Animated arc drawing on first render

2. Income vs Expenses bar chart for last 6 months
   - Side-by-side bars: green for income, red for expenses
   - Month labels on X axis
   - Amount on Y axis
   - Also use custom Canvas composable

Do NOT use Vico or any external chart library. Draw everything with Canvas.

I changed my mind about Vico because external chart libraries often have KMP compatibility issues. Custom Canvas drawing is simpler for basic charts.

What Claude generated:

A pie chart composable:

@Composable
fun ExpensePieChart(
    expenses: List<CategoryExpense>,
    modifier: Modifier = Modifier
) {
    val animationProgress = remember { Animatable(0f) }

    LaunchedEffect(expenses) {
        animationProgress.snapTo(0f)
        animationProgress.animateTo(1f, animationSpec = tween(800, easing = FastOutSlowInEasing))
    }

    val total = expenses.sumOf { it.amount }
    if (total == 0.0) return

    Column(modifier = modifier) {
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .padding(24.dp)
        ) {
            val diameter = size.minDimension
            val radius = diameter / 2
            val center = Offset(size.width / 2, size.height / 2)
            var startAngle = -90f

            expenses.forEach { expense ->
                val sweep = (expense.amount / total * 360f).toFloat() * animationProgress.value

                drawArc(
                    color = Color(expense.color),
                    startAngle = startAngle,
                    sweepAngle = sweep,
                    useCenter = true,
                    topLeft = Offset(center.x - radius, center.y - radius),
                    size = Size(diameter, diameter)
                )

                startAngle += sweep
            }

            // White circle in center (donut style)
            drawCircle(
                color = Color.White,
                radius = radius * 0.55f,
                center = center
            )
        }

        // Legend
        expenses.forEach { expense ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 16.dp, vertical = 4.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Box(
                    modifier = Modifier
                        .size(12.dp)
                        .background(Color(expense.color), CircleShape)
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text(
                    "${expense.icon} ${expense.name}",
                    style = MaterialTheme.typography.bodyMedium,
                    modifier = Modifier.weight(1f)
                )
                Text(
                    formatCurrency(expense.amount),
                    style = MaterialTheme.typography.bodyMedium,
                    fontWeight = FontWeight.Medium
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text(
                    "${(expense.amount / total * 100).toInt()}%",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

The bar chart:

@Composable
fun MonthlyBarChart(
    monthlyData: List<MonthlyData>,
    modifier: Modifier = Modifier
) {
    val animationProgress = remember { Animatable(0f) }

    LaunchedEffect(monthlyData) {
        animationProgress.snapTo(0f)
        animationProgress.animateTo(1f, animationSpec = tween(600))
    }

    val maxAmount = monthlyData.maxOfOrNull { maxOf(it.income, it.expenses) } ?: 1.0

    Column(modifier = modifier) {
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
                .padding(horizontal = 16.dp)
        ) {
            val barGroupWidth = size.width / monthlyData.size
            val barWidth = barGroupWidth * 0.35f
            val gap = barGroupWidth * 0.05f

            monthlyData.forEachIndexed { index, data ->
                val groupX = index * barGroupWidth

                // Income bar
                val incomeHeight = (data.income / maxAmount * size.height).toFloat() * animationProgress.value
                drawRoundRect(
                    color = Color(0xFF4CAF50),
                    topLeft = Offset(groupX + gap, size.height - incomeHeight),
                    size = Size(barWidth, incomeHeight),
                    cornerRadius = CornerRadius(4.dp.toPx())
                )

                // Expense bar
                val expenseHeight = (data.expenses / maxAmount * size.height).toFloat() * animationProgress.value
                drawRoundRect(
                    color = Color(0xFFE53935),
                    topLeft = Offset(groupX + barWidth + gap * 2, size.height - expenseHeight),
                    size = Size(barWidth, expenseHeight),
                    cornerRadius = CornerRadius(4.dp.toPx())
                )
            }
        }

        // Month labels
        Row(
            modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            monthlyData.forEach { data ->
                Text(
                    data.monthLabel,
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

My review: The donut pie chart looked great. The animation was smooth. Drawing with Canvas instead of an external library avoided all KMP compatibility problems.

Problem: The white donut center was hardcoded to Color.White, which looked bad in dark mode. I asked Claude to use MaterialTheme.colorScheme.surface instead.

Time spent: 15 minutes

Prompt 5: CSV Export (Minute 100)

Add CSV export:
1. Add an "Export" button in the transactions screen toolbar
2. Export all transactions as CSV: date, type, category, amount, note
3. On Android: use ShareSheet to share the CSV file
4. On iOS: use UIActivityViewController
5. Use expect/actual for platform-specific sharing

What Claude generated:

The common interface:

// commonMain
expect class ShareHelper {
    fun shareFile(fileName: String, content: String, mimeType: String)
}

fun generateCsv(transactions: List<TransactionWithCategory>): String {
    val header = "Date,Type,Category,Amount,Note"
    val rows = transactions.map { t ->
        val date = Instant.fromEpochMilliseconds(t.transaction.date)
            .toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
        val type = t.transaction.type.name
        val category = t.category.name
        val amount = t.transaction.amount.toString()
        val note = "\"${t.transaction.note.replace("\"", "\"\"")}\""
        "$date,$type,$category,$amount,$note"
    }
    return (listOf(header) + rows).joinToString("\n")
}

Android implementation:

// androidMain
actual class ShareHelper(private val context: Context) {
    actual fun shareFile(fileName: String, content: String, mimeType: String) {
        val file = File(context.cacheDir, fileName)
        file.writeText(content)

        val uri = FileProvider.getUriForFile(
            context,
            "${context.packageName}.fileprovider",
            file
        )

        val intent = Intent(Intent.ACTION_SEND).apply {
            type = mimeType
            putExtra(Intent.EXTRA_STREAM, uri)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }

        context.startActivity(Intent.createChooser(intent, "Export Transactions"))
    }
}

iOS implementation:

// iosMain
actual class ShareHelper {
    actual fun shareFile(fileName: String, content: String, mimeType: String) {
        val tempDir = NSTemporaryDirectory()
        val filePath = "$tempDir$fileName"

        NSString.create(string = content)
            .writeToFile(filePath, atomically = true, encoding = NSUTF8StringEncoding, error = null)

        val fileUrl = NSURL.fileURLWithPath(filePath)
        val activityVC = UIActivityViewController(
            activityItems = listOf(fileUrl),
            applicationActivities = null
        )

        val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController
        rootVC?.presentViewController(activityVC, animated = true, completion = null)
    }
}

Time spent: 14 minutes

Prompt 6: Dark Mode and Material 3 (Minute 114)

Add dark mode support:
1. Detect system theme and follow it
2. Material 3 dynamic colors on Android 12+
3. Fall back to custom dark/light theme on iOS and older Android
4. The pie chart and bar chart should work in both light and dark mode
5. Use Material 3 color tokens everywhere — no hardcoded colors except
   for category colors

What Claude generated:

Theme setup:

@Composable
fun BudgetTrackerTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        darkTheme -> darkColorScheme(
            primary = Color(0xFF90CAF9),
            onPrimary = Color(0xFF003258),
            primaryContainer = Color(0xFF00497D),
            surface = Color(0xFF1C1B1F),
            surfaceVariant = Color(0xFF49454F),
            background = Color(0xFF1C1B1F),
        )
        else -> lightColorScheme(
            primary = Color(0xFF1E88E5),
            onPrimary = Color.White,
            primaryContainer = Color(0xFFD1E4FF),
            surface = Color(0xFFFFFBFE),
            surfaceVariant = Color(0xFFE7E0EC),
            background = Color(0xFFFFFBFE),
        )
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography(),
        content = content
    )
}

Claude also updated the charts to use MaterialTheme.colorScheme.surface for backgrounds and proper contrast colors for text.

Time spent: 10 minutes

The remaining time (about 2 hours 17 minutes) was spent on:

  • Navigation setup with navigation-compose (KMP version) — took multiple attempts
  • Koin module configuration for ViewModels across platforms
  • Room database migration when adding new columns
  • Testing on Android emulator — several UI alignment issues on smaller screens
  • The date picker using a Material 3 DatePickerDialog — which needed platform-specific adjustments
  • Category filtering — expense categories showing for income type and vice versa
  • Fixing the bar chart month calculation (was showing future months)

The Koin configuration was particularly frustrating. The KMP-compatible version of Koin had different APIs than the Android-only version, and Claude kept mixing them up.

Total time: 4 hours 31 minutes.

What Went Right

  • Room KMP worked (after Gradle fixes). The database layer with Flow-based DAOs gave reactive updates across the app. Adding a transaction immediately updated the dashboard.
  • Custom Canvas charts were the right call. No dependency issues, no version conflicts, and full control over the visual output. The animated pie chart looked professional.
  • Expect/actual pattern worked for sharing. The CSV export used platform-specific sharing APIs (ShareSheet on Android, UIActivityViewController on iOS) with clean common code.
  • Material 3 theming was clean. Dark mode worked well, and the color scheme was consistent across the app.

What Went Wrong

1. Gradle Configuration Was a Nightmare

45 minutes on Gradle alone. Room KMP, KSP, Compose Multiplatform, and Koin all needed specific plugin and dependency configurations. Claude got the general structure right but missed details like Room’s Gradle plugin and KSP target configuration.

Lesson: For KMP projects, verify every dependency version against the official compatibility matrices before starting. Tell Claude the exact versions you need. Better yet, start from the Kotlin Multiplatform Wizard and add dependencies one at a time.

2. JVM APIs in Common Code

Claude used java.util.Calendar, System.currentTimeMillis(), and SimpleDateFormat in common code. These only work on Android (JVM). The fix was kotlinx-datetime, but each usage had to be found and replaced.

Lesson: Add this to your KMP prompt: “Use only KMP-compatible APIs in common code. No JVM-specific imports. Use kotlinx-datetime for dates.”

3. LazyVerticalGrid in Scrollable Column

A classic Compose mistake. Claude put a lazy composable inside a scrollable parent, which crashes at runtime. This is documented but easy to forget.

Lesson: For small lists (under 20 items), use regular Column/Row instead of LazyColumn/LazyRow. Mention this in your prompt.

4. Koin API Confusion

Claude mixed up the Android-specific Koin API with the KMP-compatible API. Methods like koinViewModel() needed different imports depending on the platform.

Lesson: Specify the exact Koin version and import paths in your prompt. The KMP Koin API changed significantly between versions.

The Final Result

A cross-platform budget tracker with:

  • Transaction management (add, delete, categorize)
  • Dashboard with summary cards
  • Animated pie chart and bar chart
  • Category management with emoji icons
  • CSV export with platform-specific sharing
  • Dark mode with Material 3
  • Runs on Android and iOS from shared code

To run it yourself:

git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout budget-tracker

# Android
./gradlew :composeApp:assembleDebug

# iOS (macOS only)
cd iosApp && pod install && open iosApp.xcworkspace

Time and Cost

  • Total time: 4 hours 31 minutes
  • Claude prompts: 6 (plus 7 fixes)
  • Estimated token usage: ~90,000 tokens
  • Hosting cost: N/A (mobile app, no hosting)
  • Dependencies: Compose Multiplatform, Room, Koin, kotlinx-datetime, navigation-compose

Lessons Learned

1. KMP is the hardest platform for vibe coding. The build system complexity means more time debugging Gradle than writing features. Budget 30-40% of your time for build issues.

2. Always start with a working template. Instead of generating a KMP project from scratch, start from the Kotlin Multiplatform Wizard and add features incrementally. It reduces Gradle issues significantly.

3. Custom drawing beats external chart libraries for KMP. Canvas composables work on all platforms without dependency issues. The pie chart and bar chart took 15 minutes combined and work everywhere.

4. Test on real devices early. The Android emulator caught UI issues (text overflow, grid alignment) that were not visible in the preview. Run on a real device or emulator after every major feature.

5. KMP projects still save time with AI. Despite the build issues, 4.5 hours for a cross-platform app with database, charts, and sharing is fast. Manually, this would take 20-25 hours.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: budget-tracker)

What’s Next?

Next, we switch to backend: Build a URL Shortener with Claude — Go + Redis + Docker. Go, Redis, Docker Compose, and click analytics. A different language, a different ecosystem, and a test of Claude’s Go skills.