Project 7: Expense Tracker with Room Totals

Android Development with Kotlin: 21 Real Projects · Building Real Apps · 12 min read · Updated

Let the database add it up

  1. The user adds Lunch 12.50. The text becomes 1250 cents.
  2. The DAO inserts the row into the table.
  3. The SUM and GROUP BY queries are Flows, so they run again after the change.
  4. The screen receives the new total and category sums and redraws.

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.2 is not exactly 0.3 in floating-point numbers. Store money as an integer number of cents in a Long, and only format it as dollars or rupees for display.
  • SQL aggregates: SUM(amountCents) and GROUP BY category let 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: FilterChip lets the user pick a category, and FlowRow wraps 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

  1. New Empty Activity project Expenses, package com.example.expenses. Use the same Gradle additions as project 5.
  2. 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

  1. Build the app and reproduce the numbers above exactly.
  2. Add a date column and a screen that lists this month only, using a WHERE clause with a range.
  3. Add a monthly budget setting and colour the total red when it is above the budget.
  4. Write a unit test for parseCents with 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.

Frequently asked questions

Why not use Double for money?

Binary floating-point cannot store many decimal fractions exactly, so small errors appear. Whole cents in a Long are exact and fast.

Is SQL faster than loops in Kotlin?

For totals over many rows, usually yes. The database is built for sums and groups and only returns the answer, not every row.

How do I support several currencies?

Store the amount in the smallest unit plus a currency code, for example 1250 and INR. Convert only when displaying, using a rate source you trust.