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)
Related Articles
- Build a Markdown Editor with Claude — Previous article
- Jetpack Compose Tutorial Series — Learn Compose fundamentals
- KMP Tutorial Series — Learn Kotlin Multiplatform basics
- Claude Code Mastery — Advanced Claude Code patterns
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.