An expense tracker looks simple, but it teaches two things that matter in every serious app: how to handle money correctly and how to get answers from a database instead of from loops in your code. You will use Room again, now with sums and groups.
Concepts in this project
- Money as whole cents:
0.1 + 0.2is not exactly0.3in floating-point numbers. Store money as an integer number of cents in aLong, and only format it as dollars or rupees for display. - SQL aggregates:
SUM(amountCents)andGROUP BY categorylet the database do the totals quickly. - Flow for totals: the total is a Flow too, so it updates the moment you add or remove an expense.
- Flow rows and chips:
FilterChiplets the user pick a category, andFlowRowwraps the chips onto several lines. - Reuse: this project follows the same layers as project 5, so you can see the pattern settle: entity, DAO, database, ViewModel, screen.
Create the project
- New Empty Activity project
Expenses, packagecom.example.expenses. Use the same Gradle additions as project 5. - Create the six files below.
`Gradle additions (same as project 5)`
// Top of app/build.gradle.kts -> plugins { ... } (add the KSP plugin; match its version to your Kotlin version)
id("com.google.devtools.ksp") version "2.0.21-1.0.28"
// (In the project-level build file, or in settings, declare the same plugin with "apply false" if Android Studio asks.)
// app/build.gradle.kts -> dependencies { ... }
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1") // generates the database code at build time
// Project 6 only (weather):
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// and in the plugins block: id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
Expenses/app/src/main/java/com/example/expenses/
Expense.kt entity, category list, money helpers
ExpenseDao.kt list, total, totals per category
ExpenseDatabase.kt
ExpenseViewModel.kt
ExpenseScreen.kt
MainActivity.kt
The code
`app/src/main/java/com/example/expenses/Expense.kt`
package com.example.expenses
import androidx.room.Entity
import androidx.room.PrimaryKey
// Money is stored as whole cents (a Long), never as Double, so 0.1 + 0.2 never goes wrong.
@Entity(tableName = "expenses")
data class Expense(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val amountCents: Long,
val category: String,
val dateMillis: Long = System.currentTimeMillis()
)
data class CategoryTotal(val category: String, val total: Long)
val categoryNames = listOf("Food", "Transport", "Bills", "Shopping", "Fun", "Other")
fun Long.asMoney(): String = "%d.%02d".format(this / 100, this % 100)
/** Turns text such as "12.5" into 1250 cents. Returns null when the text is not a positive amount. */
fun parseCents(text: String): Long? {
val value = text.trim().toBigDecimalOrNull() ?: return null
if (value.signum() <= 0) return null
return value.movePointRight(2).setScale(0, java.math.RoundingMode.HALF_UP).toLong()
}
parseCents accepts text like "12.50" and returns 1250, or null when the text is invalid. asMoney does the reverse for display. All the money rules live in these two functions, so there is one place to fix or test them.
`app/src/main/java/com/example/expenses/ExpenseDao.kt`
package com.example.expenses
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpenseDao {
@Query("SELECT * FROM expenses ORDER BY dateMillis DESC")
fun observeAll(): Flow<List<Expense>>
// The database does the adding up. The result maps straight onto CategoryTotal.
@Query("SELECT category, SUM(amountCents) AS total FROM expenses GROUP BY category ORDER BY total DESC")
fun observeTotals(): Flow<List<CategoryTotal>>
@Query("SELECT COALESCE(SUM(amountCents), 0) FROM expenses")
fun observeGrandTotal(): Flow<Long>
@Insert
suspend fun insert(expense: Expense)
@Delete
suspend fun delete(expense: Expense)
}
`app/src/main/java/com/example/expenses/ExpenseDatabase.kt`
package com.example.expenses
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Expense::class], version = 1, exportSchema = false)
abstract class ExpenseDatabase : RoomDatabase() {
abstract fun dao(): ExpenseDao
companion object {
@Volatile private var instance: ExpenseDatabase? = null
fun get(context: Context): ExpenseDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(context.applicationContext, ExpenseDatabase::class.java, "expenses.db")
.build().also { instance = it }
}
}
}
`app/src/main/java/com/example/expenses/ExpenseViewModel.kt`
package com.example.expenses
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ExpenseViewModel(app: Application) : AndroidViewModel(app) {
private val dao = ExpenseDatabase.get(app).dao()
private fun <T> kotlinx.coroutines.flow.Flow<T>.asState(initial: T): StateFlow<T> =
stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial)
val expenses = dao.observeAll().asState(emptyList())
val totals = dao.observeTotals().asState(emptyList())
val grandTotal = dao.observeGrandTotal().asState(0L)
/** Returns an error message, or null when the expense was saved. */
fun add(title: String, amountText: String, category: String): String? {
if (title.isBlank()) return "Give the expense a name"
val cents = parseCents(amountText) ?: return "Enter an amount above zero, such as 12.50"
viewModelScope.launch { dao.insert(Expense(title = title.trim(), amountCents = cents, category = category)) }
return null
}
fun delete(expense: Expense) {
viewModelScope.launch { dao.delete(expense) }
}
}
`app/src/main/java/com/example/expenses/ExpenseScreen.kt`
package com.example.expenses
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExpenseScreen(vm: ExpenseViewModel = viewModel()) {
val expenses by vm.expenses.collectAsStateWithLifecycle()
val totals by vm.totals.collectAsStateWithLifecycle()
val grand by vm.grandTotal.collectAsStateWithLifecycle()
var showAdd by remember { mutableStateOf(false) }
Scaffold(
topBar = { TopAppBar(title = { Text("Expenses") }) },
floatingActionButton = {
FloatingActionButton(onClick = { showAdd = true }) { Icon(Icons.Default.Add, "Add expense") }
}
) { padding ->
LazyColumn(Modifier.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text("Total spent", style = MaterialTheme.typography.labelLarge)
Text(grand.asMoney(), style = MaterialTheme.typography.displaySmall)
}
}
}
if (totals.isNotEmpty()) {
item { Text("By category", style = MaterialTheme.typography.titleMedium) }
items(totals, key = { it.category }) { t ->
Column {
Row { Text(t.category, Modifier.weight(1f)); Text(t.total.asMoney()) }
LinearProgressIndicator(
progress = { if (grand == 0L) 0f else t.total.toFloat() / grand },
modifier = Modifier.fillMaxWidth()
)
}
}
}
item { Text("History", style = MaterialTheme.typography.titleMedium) }
if (expenses.isEmpty()) item { Text("No expenses yet. Tap + to add one.") }
items(expenses, key = { it.id }) { e ->
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(e.title, style = MaterialTheme.typography.bodyLarge)
Text("${e.category} · ${DateFormat.getDateInstance().format(Date(e.dateMillis))}",
style = MaterialTheme.typography.bodySmall)
}
Text(e.amountCents.asMoney())
IconButton(onClick = { vm.delete(e) }) { Icon(Icons.Default.Delete, "Delete ${e.title}") }
}
}
}
}
if (showAdd) AddDialog(onDismiss = { showAdd = false }, onSave = { t, a, c -> vm.add(t, a, c) })
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AddDialog(onDismiss: () -> Unit, onSave: (String, String, String) -> String?) {
var title by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var category by remember { mutableStateOf(categoryNames.first()) }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add expense") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(title, { title = it }, label = { Text("What was it?") }, singleLine = true)
OutlinedTextField(amount, { amount = it }, label = { Text("Amount") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal))
Text("Category")
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
categoryNames.forEach { name ->
FilterChip(selected = category == name, onClick = { category = name }, label = { Text(name) })
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(onClick = {
error = onSave(title, amount, category)
if (error == null) onDismiss()
}) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
`app/src/main/java/com/example/expenses/MainActivity.kt`
package com.example.expenses
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaterialTheme { ExpenseScreen() } }
}
}
Expected output
The top of the screen shows a big total, 0.00 at first. Below it, a form has a title box, an amount box and category chips. Add Lunch 12.50 in Food, then Bus 3.20 in Travel, then Coffee 4.00 in Food. The total reads 19.70. A summary shows Food 16.50 and Travel 3.20. The list shows all three expenses. Delete Coffee and the total changes to 15.70 immediately. Invalid amounts such as abc or an empty box are refused with a short message.
Your task
- Build the app and reproduce the numbers above exactly.
- Add a
datecolumn and a screen that lists this month only, using aWHEREclause with a range. - Add a monthly budget setting and colour the total red when it is above the budget.
- Write a unit test for
parseCentswith cases such as "12", "12.5", "12.50", "abc" and "-3".
How to check it worked
- Totals always equal the sum of the list.
- No amount ever shows floating-point errors like 19.700000001.
- You can explain why money is stored as cents.