KtDevLog
  • Home
  • Jetpack Compose
  • Kotlin Fundamentals
  • Android Studio
No Result
View All Result
KtDevLog
  • Home
  • Jetpack Compose
  • Kotlin Fundamentals
  • Android Studio
No Result
View All Result
KtDevLog
No Result
View All Result
expense tracker app android project

Create an Expense Tracker App Android Project (Step-by-Step)

Md Sharif Mia by Md Sharif Mia
May 14, 2026
in App Projects
0
0
Share on FacebookShare on PinterestShare on X

Here’s a truth about Android app tutorials that most developers discover too late: the apps that teach you the most aren’t the ones with the most features. They’re the ones where every feature matters.

An expense tracker app Android project is the perfect example. At first glance it’s simple — add a transaction, see your balance. But under the surface it touches every skill that employers and clients actually care about: data modelling with multiple fields, category-based filtering, calculated aggregates from a database, a summary dashboard, and a form with real input validation. Build this well and you have a genuine portfolio piece, not just another to-do list.

This guide builds a complete expense tracker from scratch in Android Studio using Kotlin — Room Database 2.6.1 with KSP, MVVM with StateFlow, and Jetpack Compose for a clean Material 3 UI. Every line of code is production-ready and ready to extend.

Related Posts

Build a Wish List App: Room Database with Jetpack Compose

Build a Wish List App: Room Database with Jetpack Compose

May 23, 2026
android quiz app tutorial

Android Quiz App Tutorial: Build a Multiple Choice Game in Kotlin

May 15, 2026
Build a To-Do List App in Android Studio

Build a To-Do List App in Android Studio with Room

May 12, 2026
Build a Weather App in Kotlin

Build a Weather App in Kotlin with Retrofit API

May 11, 2026

Table of Contents

  • What You’ll Build
  • Step 1 — Project Setup and Dependencies
  • Step 2 — Data Model: The Transaction Entity
  • Step 3 — DAO With Summary Queries
  • Step 4 — Room Database and Repository
  • Step 5 — UI State and ViewModel
  • Step 6 — Balance Summary Card
  • Step 7 — Transaction List Item
  • Step 8 — Add Transaction Dialog
  • Step 9 — Main Screen and Wiring
  • Why This App Teaches You More Than a To-Do List
  • Extending the App — What to Build Next
  • Frequently Asked Questions
    • Project Setup
      • How do I store enum values in Room Database?
      • Why use COALESCE(SUM(amount), 0.0) instead of just SUM(amount) in Room?
    • Architecture
      • What is the combine() operator and why use it in a financial app ViewModel?
      • How should I store money amounts in the database — Double or Int?
  • Conclusion

What You’ll Build

A fully functional personal finance tracker with:

  • Add transactions — amount, title, category, type (Income or Expense), and date
  • Balance dashboard — total balance, total income, total expense calculated live
  • Transaction list — all transactions sorted by date, newest first
  • Category system — Food, Transport, Shopping, Salary, Health, and more
  • Delete transactions — with balance auto-recalculated
  • Persistent local storage — Room Database, works completely offline
  • MVVM + StateFlow — reactive architecture, no manual refresh
  • Jetpack Compose + Material 3 — clean, modern financial UI

Step 1 — Project Setup and Dependencies

Create a new Android project with Kotlin and Jetpack Compose. Then update your app-level build.gradle.kts:

Kotlin
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}

dependencies {
    // Room 2.6.1 with KSP (replaces deprecated kapt)
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")

    // ViewModel + StateFlow
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")

    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
Kotlin

Step 2 — Data Model: The Transaction Entity

The Transaction data class is the heart of the entire app. Every design decision here affects everything downstream — the DAO queries, the UI calculations, the category filtering:

Kotlin
// Transaction.kt
import androidx.room.Entity
import androidx.room.PrimaryKey

enum class TransactionType { INCOME, EXPENSE }

enum class Category(val label: String, val emoji: String) {
    FOOD("Food & Dining", "🍔"),
    TRANSPORT("Transport", "🚗"),
    SHOPPING("Shopping", "🛍️"),
    SALARY("Salary", "💼"),
    HEALTH("Health", "💊"),
    ENTERTAINMENT("Entertainment", "🎬"),
    UTILITIES("Bills & Utilities", "🏠"),
    OTHER("Other", "📦")
}

@Entity(tableName = "transactions")
data class Transaction(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val amount: Double,
    val type: TransactionType,
    val category: Category,
    val note: String = "",
    val date: Long = System.currentTimeMillis()  // Stored as timestamp, displayed formatted
)
Kotlin

The Category enum with label and emoji is the detail that makes this app feel genuinely polished — category chips in the UI show both the emoji and the label, making transactions scannable at a glance. The TransactionType enum cleanly separates income from expenses for all balance calculations.

Room and Enums: Room stores enums as their name string by default. No type converters needed for simple enums — Room handles TransactionType and Category automatically.

Step 3 — DAO With Summary Queries

The DAO is where the real power of Room’s SQL integration shows. Beyond basic CRUD, financial apps need aggregate queries — total income, total expenses, balance:

Kotlin
// TransactionDao.kt
import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface TransactionDao {

    // READ — all transactions, newest first
    @Query("SELECT * FROM transactions ORDER BY date DESC")
    fun getAllTransactions(): Flow<List<Transaction>>

    // READ — transactions by type (for filtering Income vs Expense)
    @Query("SELECT * FROM transactions WHERE type = :type ORDER BY date DESC")
    fun getTransactionsByType(type: String): Flow<List<Transaction>>

    // READ — transactions by category
    @Query("SELECT * FROM transactions WHERE category = :category ORDER BY date DESC")
    fun getTransactionsByCategory(category: String): Flow<List<Transaction>>

    // AGGREGATE — total income
    @Query("SELECT COALESCE(SUM(amount), 0.0) FROM transactions WHERE type = 'INCOME'")
    fun getTotalIncome(): Flow<Double>

    // AGGREGATE — total expense
    @Query("SELECT COALESCE(SUM(amount), 0.0) FROM transactions WHERE type = 'EXPENSE'")
    fun getTotalExpense(): Flow<Double>

    // CREATE
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTransaction(transaction: Transaction)

    // DELETE
    @Delete
    suspend fun deleteTransaction(transaction: Transaction)

    // DELETE — clear all (useful for testing or reset feature)
    @Query("DELETE FROM transactions")
    suspend fun deleteAllTransactions()
}
Kotlin

COALESCE(SUM(amount), 0.0) — critical SQL detail. SUM() on an empty table returns NULL in SQLite, not 0. COALESCE replaces NULL with 0.0 — without this, your balance card shows blank when there are no transactions. This is the kind of database edge case that causes silent crashes in production apps when a new user opens the app for the first time.

Step 4 — Room Database and Repository

Kotlin
// AppDatabase.kt
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context

@Database(
    entities = [Transaction::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun transactionDao(): TransactionDao

    companion object {
        @Volatile private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "expense_tracker_db"
                ).build().also { INSTANCE = it }
            }
        }
    }
}
Kotlin
Kotlin
// TransactionRepository.kt
import kotlinx.coroutines.flow.Flow

class TransactionRepository(private val dao: TransactionDao) {

    val allTransactions: Flow<List<Transaction>> = dao.getAllTransactions()
    val totalIncome: Flow<Double>  = dao.getTotalIncome()
    val totalExpense: Flow<Double> = dao.getTotalExpense()

    suspend fun insert(transaction: Transaction) = dao.insertTransaction(transaction)
    suspend fun delete(transaction: Transaction) = dao.deleteTransaction(transaction)
}
Kotlin

Step 5 — UI State and ViewModel

Kotlin
// ExpenseViewModel.kt
import androidx.lifecycle.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

data class ExpenseUiState(
    val transactions: List<Transaction> = emptyList(),
    val totalIncome: Double  = 0.0,
    val totalExpense: Double = 0.0,
    val balance: Double      = 0.0,   // Calculated: income - expense
    val isAddDialogOpen: Boolean = false,
    val errorMessage: String?    = null
)

class ExpenseViewModel(private val repository: TransactionRepository) : ViewModel() {

    private val _uiState = MutableStateFlow(ExpenseUiState())
    val uiState: StateFlow<ExpenseUiState> = _uiState

    init {
        // Combine all three Flows into a single UI state update
        viewModelScope.launch {
            combine(
                repository.allTransactions,
                repository.totalIncome,
                repository.totalExpense
            ) { transactions, income, expense ->
                ExpenseUiState(
                    transactions  = transactions,
                    totalIncome   = income,
                    totalExpense  = expense,
                    balance       = income - expense
                )
            }.collect { state ->
                _uiState.value = state
            }
        }
    }

    fun openAddDialog()  { _uiState.update { it.copy(isAddDialogOpen = true) } }
    fun closeAddDialog() { _uiState.update { it.copy(isAddDialogOpen = false) } }

    fun addTransaction(
        title: String,
        amount: String,
        type: TransactionType,
        category: Category,
        note: String
    ) {
        // Validate input
        if (title.isBlank()) {
            _uiState.update { it.copy(errorMessage = "Please enter a title") }
            return
        }
        val amountDouble = amount.toDoubleOrNull()
        if (amountDouble == null || amountDouble <= 0) {
            _uiState.update { it.copy(errorMessage = "Please enter a valid amount") }
            return
        }

        viewModelScope.launch {
            repository.insert(
                Transaction(
                    title    = title.trim(),
                    amount   = amountDouble,
                    type     = type,
                    category = category,
                    note     = note.trim()
                )
            )
            _uiState.update { it.copy(isAddDialogOpen = false, errorMessage = null) }
        }
    }

    fun deleteTransaction(transaction: Transaction) {
        viewModelScope.launch { repository.delete(transaction) }
    }

    fun clearError() { _uiState.update { it.copy(errorMessage = null) } }
}
Kotlin

The combine() operator is the architectural highlight of this ViewModel. It subscribes to three separate Flows simultaneously — transactions, income, and expense — and emits a new ExpenseUiState whenever any of them changes. Add a transaction → Room updates the transactions Flow and the totalIncome or totalExpense Flow → combine fires → uiState updates → UI recomposes with the new balance. One operation. Zero manual refresh code.

Step 6 — Balance Summary Card

This is the UI element that makes the app feel like a real financial app. The summary card at the top shows total balance, income, and expenses in a clean dashboard format:

Kotlin
@Composable
fun BalanceSummaryCard(
    balance: Double,
    totalIncome: Double,
    totalExpense: Double
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        shape = RoundedCornerShape(20.dp),
        colors = CardDefaults.cardColors(containerColor = Color(0xFF1A237E))
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = "Total Balance",
                fontSize = 14.sp,
                color = Color(0xFFB0BEC5)
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = formatAmount(balance),
                fontSize = 40.sp,
                fontWeight = FontWeight.Bold,
                color = if (balance >= 0) Color.White else Color(0xFFEF9A9A)
            )

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

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                SummaryItem(
                    label  = "Income",
                    amount = totalIncome,
                    color  = Color(0xFF69F0AE),
                    prefix = "▲"
                )
                // Vertical divider
                Box(
                    modifier = Modifier
                        .width(1.dp)
                        .height(48.dp)
                        .background(Color(0xFF37474F))
                )
                SummaryItem(
                    label  = "Expenses",
                    amount = totalExpense,
                    color  = Color(0xFFFF5252),
                    prefix = "▼"
                )
            }
        }
    }
}

@Composable
fun SummaryItem(label: String, amount: Double, color: Color, prefix: String) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = label, fontSize = 12.sp, color = Color(0xFFB0BEC5))
        Spacer(modifier = Modifier.height(4.dp))
        Text(
            text = "$prefix ${formatAmount(amount)}",
            fontSize = 18.sp,
            fontWeight = FontWeight.Bold,
            color = color
        )
    }
}

fun formatAmount(amount: Double): String {
    return "৳${String.format("%,.2f", amount)}"   // BDT — change currency symbol as needed
}
Kotlin

The balance text turns red when negative — a subtle but powerful visual signal that the user is spending more than they earn. Green income ▲ and red expense ▼ arrows make the two columns immediately distinguishable without reading the labels.

Step 7 — Transaction List Item

Kotlin
@Composable
fun TransactionItem(
    transaction: Transaction,
    onDelete: () -> Unit
) {
    val isExpense = transaction.type == TransactionType.EXPENSE

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 4.dp),
        shape = RoundedCornerShape(12.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            // Category emoji badge
            Box(
                modifier = Modifier
                    .size(48.dp)
                    .clip(CircleShape)
                    .background(
                        if (isExpense) Color(0xFFFFEBEE) else Color(0xFFE8F5E9)
                    ),
                contentAlignment = Alignment.Center
            ) {
                Text(text = transaction.category.emoji, fontSize = 22.sp)
            }

            // Transaction details
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = transaction.title,
                    fontSize = 15.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = Color(0xFF1E293B)
                )
                Text(
                    text = transaction.category.label,
                    fontSize = 12.sp,
                    color = Color(0xFF94A3B8)
                )
            }

            // Amount + delete column
            Column(horizontalAlignment = Alignment.End) {
                Text(
                    text = "${if (isExpense) "-" else "+"}${formatAmount(transaction.amount)}",
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Bold,
                    color = if (isExpense) Color(0xFFEF5350) else Color(0xFF4CAF50)
                )
                Spacer(modifier = Modifier.height(4.dp))
                IconButton(
                    onClick = onDelete,
                    modifier = Modifier.size(20.dp)
                ) {
                    Icon(
                        imageVector = Icons.Default.Delete,
                        contentDescription = "Delete",
                        tint = Color(0xFFCFD8DC),
                        modifier = Modifier.size(16.dp)
                    )
                }
            }
        }
    }
}
Kotlin

Step 8 — Add Transaction Dialog

Kotlin
@Composable
fun AddTransactionDialog(
    onDismiss: () -> Unit,
    onConfirm: (title: String, amount: String, type: TransactionType, category: Category, note: String) -> Unit,
    errorMessage: String?
) {
    var title    by remember { mutableStateOf("") }
    var amount   by remember { mutableStateOf("") }
    var note     by remember { mutableStateOf("") }
    var type     by remember { mutableStateOf(TransactionType.EXPENSE) }
    var category by remember { mutableStateOf(Category.FOOD) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("New Transaction", fontWeight = FontWeight.Bold) },
        text = {
            Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {

                // Income / Expense toggle
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    TransactionType.entries.forEach { t ->
                        FilterChip(
                            selected = type == t,
                            onClick  = { type = t },
                            label    = { Text(t.name.lowercase().replaceFirstChar { it.uppercase() }) },
                            colors   = FilterChipDefaults.filterChipColors(
                                selectedContainerColor = if (t == TransactionType.INCOME)
                                    Color(0xFF4CAF50) else Color(0xFFEF5350),
                                selectedLabelColor = Color.White
                            ),
                            modifier = Modifier.weight(1f)
                        )
                    }
                }

                // Title
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("Title") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )

                // Amount
                OutlinedTextField(
                    value = amount,
                    onValueChange = { amount = it },
                    label = { Text("Amount") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth(),
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
                )

                // Category selector
                Text("Category", fontSize = 13.sp, color = Color.Gray)
                LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    items(Category.entries) { cat ->
                        FilterChip(
                            selected = category == cat,
                            onClick  = { category = cat },
                            label    = { Text("${cat.emoji} ${cat.label}") },
                            colors   = FilterChipDefaults.filterChipColors(
                                selectedContainerColor = Color(0xFF7C3AED),
                                selectedLabelColor     = Color.White
                            )
                        )
                    }
                }

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

                // Error message
                if (errorMessage != null) {
                    Text(
                        text = errorMessage,
                        color = Color(0xFFDC2626),
                        fontSize = 13.sp
                    )
                }
            }
        },
        confirmButton = {
            TextButton(
                onClick = { onConfirm(title, amount, type, category, note) },
                enabled = title.isNotBlank() && amount.isNotBlank()
            ) {
                Text("Add", fontWeight = FontWeight.Bold)
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text("Cancel") }
        }
    )
}
Kotlin

Step 9 — Main Screen and Wiring

Kotlin
@Composable
fun ExpenseTrackerScreen(viewModel: ExpenseViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Expense Tracker", fontWeight = FontWeight.Bold) },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor    = Color(0xFF1A237E),
                    titleContentColor = Color.White
                )
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick         = { viewModel.openAddDialog() },
                containerColor  = Color(0xFF7C3AED),
                contentColor    = Color.White
            ) {
                Icon(Icons.Default.Add, contentDescription = "Add transaction")
            }
        }
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
            contentPadding = PaddingValues(bottom = 80.dp)
        ) {
            // Balance summary card
            item {
                BalanceSummaryCard(
                    balance      = uiState.balance,
                    totalIncome  = uiState.totalIncome,
                    totalExpense = uiState.totalExpense
                )
            }

            // Section header
            item {
                Text(
                    text = "Transactions (${uiState.transactions.size})",
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Bold,
                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
                )
            }

            // Empty state
            if (uiState.transactions.isEmpty()) {
                item {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(200.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Column(horizontalAlignment = Alignment.CenterHorizontally) {
                            Text("💰", fontSize = 48.sp)
                            Spacer(modifier = Modifier.height(8.dp))
                            Text("No transactions yet", fontSize = 16.sp, color = Color.Gray)
                            Text("Tap + to add your first one", fontSize = 13.sp, color = Color.LightGray)
                        }
                    }
                }
            }

            // Transaction list
            items(
                items = uiState.transactions,
                key   = { it.id }
            ) { transaction ->
                TransactionItem(
                    transaction = transaction,
                    onDelete    = { viewModel.deleteTransaction(transaction) }
                )
            }
        }

        // Add transaction dialog
        if (uiState.isAddDialogOpen) {
            AddTransactionDialog(
                onDismiss     = { viewModel.closeAddDialog() },
                onConfirm     = { title, amount, type, category, note ->
                    viewModel.addTransaction(title, amount, type, category, note)
                },
                errorMessage  = uiState.errorMessage
            )
        }
    }
}
Kotlin

Wire it in MainActivity:

Kotlin
class MainActivity : ComponentActivity() {
    private val database by lazy { AppDatabase.getInstance(this) }
    private val repository by lazy { TransactionRepository(database.transactionDao()) }
    private val viewModel: ExpenseViewModel by viewModels {
        object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                @Suppress("UNCHECKED_CAST")
                return ExpenseViewModel(repository) as T
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                ExpenseTrackerScreen(viewModel = viewModel)
            }
        }
    }
}
Kotlin

Why This App Teaches You More Than a To-Do List

Every feature in an expense tracker maps to a skill interviewers and clients test for:

Income vs Expense enum → handling multiple states with type safety. Category system → designing extensible data models that don’t require database migrations to add new values. COALESCE(SUM(), 0.0) SQL → production-aware query writing. combine() on multiple Flows → reactive state management with multiple data sources. Balance calculation → derived state from raw data, not stored redundantly. Currency formatting → internationalisation awareness.

This is also why financial app tutorials attract some of the highest-paying AdSense ads in the tech category — the audience building these apps is serious developers working on production projects, and the ads served match that intent.

Extending the App — What to Build Next

Once the core works, these extensions take it from demo to portfolio piece:

Monthly budget limits — add a Budget entity with category and limit amount, query the total spent per category, show a progress bar. Date range filtering — add a date picker and filter transactions by week, month, or custom range. CSV export — use FileOutputStream and Android’s file system APIs to export transactions as a spreadsheet. Charts — integrate the MPAndroidChart library or Compose Charts to show spending by category as a pie chart. Notifications — use WorkManager to send a daily “you’ve spent ৳X today” reminder.

For the database patterns powering all of these extensions, the simple to-do list app with Room Database covers the Room foundations — entities, DAOs, and the @Database class — that this expense tracker builds directly on top of.

Frequently Asked Questions

Project Setup

How do I store enum values in Room Database?

Room stores enum values as their name string by default — no custom type converters needed for standard Kotlin enums. TransactionType.EXPENSE is stored as the string "EXPENSE" and retrieved back as the EXPENSE enum constant automatically. If you have a complex custom type that Room doesn’t recognise, you’d write a @TypeConverter — but for standard Kotlin enums, Room handles the conversion out of the box.

Why use COALESCE(SUM(amount), 0.0) instead of just SUM(amount) in Room?

SQLite’s SUM() function returns NULL when called on an empty table or with no matching rows — not 0. Without COALESCE, your totalIncome and totalExpense Flows emit null when there are no transactions, which causes a crash when you try to use the value in balance calculations. COALESCE(SUM(amount), 0.0) replaces any NULL result with 0.0, making new-user state safe without any special null handling in Kotlin.

Architecture

What is the combine() operator and why use it in a financial app ViewModel?

combine() is a Kotlin Flow operator that subscribes to multiple Flows simultaneously and emits a new value whenever any of them change. In the expense tracker ViewModel, combine(transactions, totalIncome, totalExpense) means that whenever a transaction is added or deleted, the ExpenseUiState updates with the new transaction list AND the new income/expense totals AND the new balance — all in a single recomposition. Without combine, you’d need three separate collect calls and manual state merging.

How should I store money amounts in the database — Double or Int?

For a beginner expense tracker app, Double is acceptable and straightforward. For a production financial app, store monetary amounts as Long integers representing the smallest currency unit — for BDT, store paisa (1 taka = 100 paisa). Integer arithmetic eliminates floating-point precision errors that can cause ৳10.00 - ৳3.33 = ৳6.669999... display issues. For this project, Double works well — just format display values with String.format("%.2f", amount) to control decimal places.

Conclusion

An expense tracker app Android project teaches more Android development skills per line of code than almost any other beginner project. The combination of enum-based type safety, aggregate SQL queries, multi-Flow reactive state, and a real financial UI makes it genuinely portfolio-worthy.

Build the core first: balance card, add transaction dialog, transaction list. Then extend one feature at a time. Each extension reinforces the skills you’ve used here — and gives you talking points in interviews that go far beyond “I built a to-do list.”

The architecture decisions that matter most for extending this app — combine() for multiple reactive sources, COALESCE for safe aggregates, and the repository pattern keeping Room logic out of the ViewModel — are exactly the same patterns used in every professional Android financial app.

The best financial app is the one that makes money feel manageable. Build this one — and then build something better with what you’ve learned.

Tags: expense tracker app android project
SharePinTweet
Md Sharif Mia

Md Sharif Mia

Md Sharif Mia is a Kotlin and Android developer with hands-on experience building real-world Android applications using Kotlin, Jetpack Compose, and Firebase. He created KtDevLog to help aspiring Android developers learn through practical, step-by-step tutorials — from writing their first line of Kotlin to shipping complete apps.Through KtDevLog, Sharif shares what actually works in Android development: clean code patterns, common beginner mistakes to avoid, and project-based lessons that go beyond theory. His writing style is direct and beginner-friendly, making complex Android concepts easy to understand for developers at any stage.When he is not writing tutorials, Sharif is experimenting with new Android features, exploring Kotlin best practices, and building apps that solve everyday problems.

Related Posts

Build a Wish List App: Room Database with Jetpack Compose
App Projects

Build a Wish List App: Room Database with Jetpack Compose

May 23, 2026

If you've been building Android apps for a while, you already know that sooner...

android quiz app tutorial
App Projects

Android Quiz App Tutorial: Build a Multiple Choice Game in Kotlin

May 15, 2026

State management is one of those concepts that sounds abstract until you build something...

Build a To-Do List App in Android Studio
App Projects

Build a To-Do List App in Android Studio with Room

May 12, 2026

Every great Android developer Build a To-Do List App in Android Studio with at...

Build a Weather App in Kotlin
App Projects

Build a Weather App in Kotlin with Retrofit API

May 11, 2026

Here's the moment every Android developer remembers: the first time their app showed real...

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

  • About Us
  • Contact Us
  • Privacy Policy
  • Terms & Conditions

© Copyright 2026 KtDevLog. All Rights Reserved.

Welcome Back!

Login to your account below

Forgotten Password?

Retrieve your password

Please enter your username or email address to reset your password.

Log In
No Result
View All Result
  • Home
  • Jetpack Compose
  • Kotlin Fundamentals
  • Android Studio

© Copyright 2026 KtDevLog. All Rights Reserved.