Build a Monthly Expense Tracker App
Build an Android expense tracker with Room database and MVVM in Java.
Introduction
30 Second Summary
Tracking where your money goes each month is one of those things everyone knows they should do, but rarely sticks with. The apps that work best are the ones that feel personal and live right on your phone.
In this project, you will build "Budget Buddy," a fully functional expense tracker that runs on your Android phone. You will store expenses in a local database, display them in a scrollable list with Philippine Peso formatting, and track your progress toward a monthly savings goal.
What You'll Build
When you finish, you will open Budget Buddy on your phone, tap the add button, enter an expense, and watch it appear in your list with the monthly total updating instantly. Close the app, reopen it, and everything is still there.
By the end of this project, you'll have:
- A tap-to-add expense flow where you enter an amount, description, and category, then see it appear in your scrollable list formatted in Philippine Peso.
- A Room database that persists your expenses across app restarts, so nothing disappears when you close the app.
- A live savings goal tracker that shows whether you are on track or over budget as you add and remove expenses throughout the month.
- Month navigation arrows that let you flip through previous months and a yearly overview dialog that totals up spending across all twelve months.
- Secret Mission: Add category filtering so you can see exactly how much you spend on Food, Transport, Bills, and more.
Are there any prerequisites?
None. This guide walks you through installing Android Studio and setting up everything from scratch. You will need a physical Android phone connected via USB to run the app.
Before We Start
Before you write any code, take a moment to lock in what you are building and why it matters.
Create the Project and Add Dependencies
Before you can build any features, you need a properly configured Android project. The app logic, database layer, and UI all depend on specific libraries being available.
In this step, you will create a new project in Android Studio, add the dependencies your app needs, and confirm everything works by running the default app on your phone.
In this step, get ready to:
- Create a new BudgetBuddy project configured for Java.
- Add Room, RecyclerView, and Lifecycle dependencies to the project.
- Run the default app on your physical Android device.
Create a new Android project
The New Project wizard in Android Studio scaffolds all the files and folders your app needs. You will choose a template that creates a traditional XML-based layout with a Java Activity.
- In Android Studio, click File in the top menu bar.
- Click New, then New Project.
- In the template list, select Empty Activity and click Next.
- Set the Name field to BudgetBuddy.
- Set the Package name field to com.example.budgetbuddy.
- In the Language dropdown, select Java.
- In the Minimum SDK dropdown, select API 24 ("Android 7.0").
- In the Build configuration language dropdown, select Groovy DSL (build.gradle).
- Click Finish.
Android Studio creates the project and opens it. Wait for the initial Gradle sync to complete before continuing.
Stuck on Gradle sync?
The first sync can take a few minutes while Android Studio downloads components. If it stays stuck for more than five minutes, check your internet connection and try clicking the Sync Now banner at the top of the editor.
Help me fix a stuck Gradle sync
Add dependencies and enable View Binding
Your project needs three library groups: Room for database storage, RecyclerView for scrollable lists, and Lifecycle (ViewModel + LiveData) for reactive UI updates.
You also need View Binding enabled so you can reference layout elements without manual findViewById calls.
- In the Project panel on the left, expand Gradle Scripts.
- Open the file labelled build.gradle (Module :app).
- Find the android { } block and add the following buildFeatures section inside it, before its closing }:
buildFeatures {
viewBinding true
}
What does View Binding do?
View Binding generates a binding class for each XML layout file. Instead of calling findViewById(R.id.some_view) repeatedly, you access views directly through a type-safe binding object. This eliminates null pointer errors from mistyped IDs.
- Scroll down to the dependencies { } block and replace its entire contents with the following:
dependencies {
def room_version = "2.8.4"
def lifecycle_version = "2.11.0"
implementation "androidx.appcompat:appcompat:1.7.1"
implementation "com.google.android.material:material:1.13.0"
implementation "androidx.constraintlayout:constraintlayout:2.2.1"
implementation "androidx.recyclerview:recyclerview:1.4.0"
// Room
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
// Lifecycle - ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
What do these dependencies do?
- room-runtime provides the database engine. room-compiler (via annotationProcessor) generates implementation code from your annotations at compile time.
- lifecycle-viewmodel lets your data survive screen rotations. lifecycle-livedata makes the UI update automatically when data changes.
- recyclerview provides the efficient scrollable list widget you will use to display expenses.
- Save the file by pressing Ctrl+S.
- Click the Sync Now banner that appears at the top of the editor.
Wait for the sync to finish. You should see a green checkmark or no errors in the Build output panel at the bottom.
Seeing a sync error?
Double-check that you pasted the buildFeatures block inside the android { } block, not outside it. Also confirm the dependencies { } block is at the top level of the file, not nested inside android { }.
Help me debug this Gradle sync error
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and the sync completed without errors.
ⓧ I'd like to double check the full code
Your build.gradle (Module :app) should have these two additions. The buildFeatures block sits inside android { }:
android {
// ... existing config ...
buildFeatures {
viewBinding true
}
}
And the dependencies { } block at the top level of the file:
dependencies {
def room_version = "2.8.4"
def lifecycle_version = "2.11.0"
implementation "androidx.appcompat:appcompat:1.7.1"
implementation "com.google.android.material:material:1.13.0"
implementation "androidx.constraintlayout:constraintlayout:2.2.1"
implementation "androidx.recyclerview:recyclerview:1.4.0"
// Room
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
// Lifecycle - ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
Connect your device and run the app
The fastest way to test your app is on a real phone. You need USB Debugging turned on so Android Studio can deploy the app directly.
- On your Android phone, open Settings.
- Navigate to About phone (or About device).
- Tap Build number seven times to enable Developer Options.
- Go back to Settings, then open Developer options.
- Toggle USB debugging on.
- Connect your phone to your computer with a USB cable.
- If a prompt appears on your phone asking to allow USB debugging, tap Allow.
Where is Developer Options?
The exact location varies by manufacturer. On Samsung devices, look under Settings > About phone > Software information for the Build number. On most other phones, it is directly under Settings > About phone.
Before you hit Run, what do you expect to see on your phone? Think about what the Empty Activity template creates by default.
- In Android Studio, check the device dropdown in the toolbar. Your phone should appear by name.
- Click the green Run button (the play icon) in the toolbar.
After a moment, your phone displays a white screen with "Hello World!" in the center. That confirms your project, dependencies, and device connection all work.
Device not showing in the dropdown?
Make sure USB debugging is enabled and you tapped Allow on the phone's permission prompt. Try unplugging and replugging the cable. On Windows, you may need to install your phone manufacturer's USB driver.
Help me get my phone recognized by Android Studio
Your project skeleton is ready with all the libraries it needs. Next up, you will build the expense list UI and see real data on screen.
Build the Expense List UI and Display Hardcoded Data
In the previous step you set up BudgetBuddy with all its dependencies. Right now your app just shows a blank "Hello World" screen on your phone.
To turn this into an expense tracker, you need a visible list of expenses on screen. Getting that working early proves the whole UI pipeline (layout, adapter, data) is connected before you tackle database storage.
In this step, get ready to:
- Build the main screen layout with a spending header, expense list, and add button.
- Create a row layout for individual expense items.
- Wire up a RecyclerView with hardcoded data to prove the list displays correctly.
Create the main screen layout
Your app's main screen needs three things: a card at the top showing the monthly spending total, a scrollable list for expenses, and a floating button to add new ones.
- In the Project window, expand app > res > layout.
- Double-click activity_main.xml to open it.
- Switch to the Code view (top-right tab in the editor).
- Select all the existing content and replace it with this header card structure:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#F5F5F5">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Monthly Spending"
android:textSize="14sp"
android:textColor="#666666" />
What does this code do?
This sets up a vertical LinearLayout as the root of your screen with a light gray background. Inside it, a MaterialCardView creates a raised card with rounded corners.
The card contains a small label reading "Monthly Spending" that sits above where the total amount will go.
- Directly below the "Monthly Spending" TextView, add the total display and budget status elements, then close the card and add the expense list and add button:
<TextView
android:id="@+id/text_monthly_total"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u20b10.00"
android:textSize="32sp"
android:textStyle="bold"
android:textColor="#333333"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/text_budget_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:layout_marginTop="8dp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_expenses"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:padding="16dp" />
What are these elements?
The text_monthly_total TextView shows the Philippine Peso total in large bold text. The text_budget_status is hidden for now (visibility="gone") and will show budget status in a later step.
The RecyclerView uses layout_weight="1" to fill all remaining vertical space, creating a scrollable area for your expense list.
- Add the FloatingActionButton and close the root layout by pasting this after the RecyclerView:
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_expense"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="Add expense"
app:srcCompat="@android:drawable/ic_input_add" />
</LinearLayout>
What is a FloatingActionButton?
A FloatingActionButton (FAB) is a circular button that floats above the content. It uses a system-provided plus icon (ic_input_add) to signal "add a new item."
- Save activity_main.xml with Ctrl+S (Windows) or Cmd+S (macOS).
- Run the app on your phone by clicking the green play button in the toolbar.
- You should see a white card with "Monthly Spending" and "₱0.00" at the top, an empty area below it, and a circular add button.
Seeing a build error?
Make sure the XML has exactly one root element (<LinearLayout>) and all tags are properly closed. Check that the xmlns:app namespace is on the root element.
If the FAB does not appear, confirm that com.google.android.material:material is in your build.gradle dependencies from the previous step.
Why is my layout not showing the card or FAB?
Create the expense item layout
Each expense in the list needs its own layout defining how a single row looks. This is a separate XML file that the RecyclerView inflates for every item.
- Right-click the layout folder in the Project window.
- Select New then Layout Resource File.
- Enter item_expense as the file name and click OK.
- Switch to Code view and replace the generated content with this card row structure:
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="#333333" />
What does this layout define?
Each expense row is a small card. Inside it, a horizontal LinearLayout places the description and category on the left (using layout_weight="1" to claim available space) and the amount on the right.
- Below the text_description TextView, add the category label, close the inner layouts, and add the amount on the right:
<TextView
android:id="@+id/text_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#888888"
android:layout_marginTop="4dp" />
</LinearLayout>
<TextView
android:id="@+id/text_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="#E53935" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
Why is the amount red?
The text_amount uses #E53935 (a Material red) to make expense amounts stand out visually. Money going out is typically shown in red in financial apps.
- Save item_expense.xml.
- Confirm both layout files now appear under res > layout in the Project window: activity_main.xml and item_expense.xml.
Don't see item_expense.xml?
Make sure you right-clicked the layout folder (not the res folder) and chose Layout Resource File. The file must live at app/src/main/res/layout/item_expense.xml.
Help me find where my layout file was created
Create the Expense model and adapter, then wire up MainActivity
The layouts are ready. Now you need three Java pieces: a model class to represent an expense, an adapter that binds expense data to the item layout, and an update to MainActivity that connects everything.
- In the Project window, right-click the com.example.budgetbuddy package under app > java.
- Select New then Java Class.
- Name it Expense and press Enter.
- Replace the generated content with this model class:
package com.example.budgetbuddy;
public class Expense {
public int id;
public String description;
public double amount;
public String category;
public long date;
public Expense(String description, double amount, String category, long date) {
this.description = description;
this.amount = amount;
this.category = category;
this.date = date;
}
}
What does this class represent?
Each Expense holds an id (for future database use), a description, a numeric amount, a category label, and a date stored as a timestamp in milliseconds.
This is a plain Java class for now. In the next step, you will add Room database annotations to it.
- Save Expense.java.
- Create another Java class in the same package. Right-click com.example.budgetbuddy, select New then Java Class, and name it ExpenseAdapter.
- Replace the generated content with the adapter's class declaration, fields, interface, and constructor:
package com.example.budgetbuddy;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class ExpenseAdapter extends RecyclerView.Adapter<ExpenseAdapter.ExpenseViewHolder> {
private List<Expense> expenses = new ArrayList<>();
private OnExpenseClickListener listener;
public interface OnExpenseClickListener {
void onExpenseClick(Expense expense);
}
public ExpenseAdapter(OnExpenseClickListener listener) {
this.listener = listener;
}
What is this adapter structure?
The adapter extends RecyclerView.Adapter and holds a list of expenses plus a click listener interface. The OnExpenseClickListener lets the Activity handle taps on individual items.
- Below the constructor, add the view creation and binding methods:
@NonNull
@Override
public ExpenseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_expense, parent, false);
return new ExpenseViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ExpenseViewHolder holder, int position) {
Expense expense = expenses.get(position);
holder.textDescription.setText(expense.description);
holder.textCategory.setText(expense.category);
NumberFormat format = NumberFormat.getCurrencyInstance(new Locale("en", "PH"));
holder.textAmount.setText(format.format(expense.amount));
holder.itemView.setOnClickListener(v -> {
if (listener != null) {
listener.onExpenseClick(expense);
}
});
}
How does Philippine Peso formatting work?
The NumberFormat.getCurrencyInstance(new Locale("en", "PH")) creates a formatter that outputs amounts with the ₱ symbol. For example, 250.0 becomes "₱250.00".
- Finally, add the helper methods and the ViewHolder class to complete the adapter:
@Override
public int getItemCount() {
return expenses.size();
}
public void setExpenses(List<Expense> expenses) {
this.expenses = expenses;
notifyDataSetChanged();
}
static class ExpenseViewHolder extends RecyclerView.ViewHolder {
TextView textDescription;
TextView textCategory;
TextView textAmount;
public ExpenseViewHolder(@NonNull View itemView) {
super(itemView);
textDescription = itemView.findViewById(R.id.text_description);
textCategory = itemView.findViewById(R.id.text_category);
textAmount = itemView.findViewById(R.id.text_amount);
}
}
}
What does the ViewHolder do?
The ExpenseViewHolder caches references to each row's TextViews so Android does not need to call findViewById every time a row scrolls into view. This keeps scrolling smooth even with many items.
- Save ExpenseAdapter.java.
- Verify the project builds by clicking Build then Make Project (or press Ctrl+F9 on Windows, Cmd+F9 on macOS).
- You should see "BUILD SUCCESSFUL" in the Build output window at the bottom.
Build failing with unresolved symbols?
If you see errors about R.layout.item_expense or R.id.text_description, confirm that item_expense.xml is saved in the correct location and has no XML syntax errors.
If RecyclerView cannot be resolved, check that androidx.recyclerview:recyclerview:1.4.0 is in your module's build.gradle dependencies.
Help me fix build errors in my adapter
- Now open MainActivity.java (under com.example.budgetbuddy in the Project window).
- Replace its entire content with this setup that connects the RecyclerView to your adapter:
package com.example.budgetbuddy;
import android.os.Bundle;
import java.util.ArrayList;
import java.util.List;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.budgetbuddy.databinding.ActivityMainBinding;
public class MainActivity extends AppCompatActivity implements ExpenseAdapter.OnExpenseClickListener {
private ActivityMainBinding binding;
private ExpenseAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setupRecyclerView();
loadHardcodedData();
}
private void setupRecyclerView() {
adapter = new ExpenseAdapter(this);
binding.recyclerExpenses.setLayoutManager(new LinearLayoutManager(this));
binding.recyclerExpenses.setAdapter(adapter);
}
What is View Binding doing here?
Instead of calling findViewById for every view, View Binding generates a class (ActivityMainBinding) that holds direct references to all views with IDs. You access them as binding.recyclerExpenses or binding.fabAddExpense.
- Below the setupRecyclerView() method, add the hardcoded data and click handler to complete the file:
private void loadHardcodedData() {
List<Expense> expenses = new ArrayList<>();
expenses.add(new Expense("Lunch at Jollibee", 250.00, "Food", System.currentTimeMillis()));
expenses.add(new Expense("Grab ride to work", 180.50, "Transport", System.currentTimeMillis()));
expenses.add(new Expense("Netflix subscription", 549.00, "Entertainment", System.currentTimeMillis()));
adapter.setExpenses(expenses);
}
@Override
public void onExpenseClick(Expense expense) {
// Will implement delete functionality later
}
}
Why hardcoded data?
These three sample expenses prove the full UI pipeline works: layout, adapter, model, and currency formatting all render correctly. In the next step you will replace this with a real Room database.
- Save MainActivity.java.
Before you run the app, take a moment to predict what you expect to see. Will the three expenses appear with their peso amounts? Will the monthly total update?
- Run the app on your phone by clicking the green play button.
- You should see a card reading "₱0.00" at the top (the total is still hardcoded), three expense cards below it showing "Lunch at Jollibee" (₱250.00), "Grab ride to work" (₱180.50), and "Netflix subscription" (₱549.00), and the circular add button at the bottom.
Why doesn't the monthly total update?
The header still shows "₱0.00" because it is hardcoded in the XML. The total will update dynamically once you add LiveData in Step 4.
Not seeing expenses on your phone?
Make sure MainActivity.java calls both setupRecyclerView() and loadHardcodedData() inside onCreate. If the list appears empty, check that setExpenses() calls notifyDataSetChanged() in your adapter.
If the app crashes, open Logcat at the bottom of Android Studio and look for a red stack trace. The error message usually names the exact file and line.
My expense list is not showing up on my phone
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: activity_main.xml, item_expense.xml, Expense.java, ExpenseAdapter.java, and MainActivity.java.
ⓧ I'd like to double check the full code
Scroll up and confirm each file's code blocks are pasted in order. Your files should contain:
- activity_main.xml: All three code blocks combined (header card opening, totals + RecyclerView, FAB + closing tag).
- item_expense.xml: Both code blocks combined (card + description, category + amount + closing tags).
- Expense.java: The single code block with the plain model class.
- ExpenseAdapter.java: All three code blocks combined (class header + interface, onCreateViewHolder + onBindViewHolder, getItemCount + setExpenses + ViewHolder).
- MainActivity.java: Both code blocks combined (class + setupRecyclerView, loadHardcodedData + onExpenseClick + closing brace).
Your expense tracker now has a working list on screen with Philippine Peso formatting. Next up, you will replace those hardcoded expenses with a real Room database so your data persists when the app closes.
Add Room Database for Persistent Storage
Your app shows three expenses right now, but they are frozen in code. Every time the app launches, it recreates those same three objects from scratch.
What happens if a real user wants to add their own expense? There is nowhere to save it. In this step, you will add a Room database so expenses persist on the device across app restarts.
In this step, get ready to:
- Convert the Expense class into a Room Entity with database annotations.
- Create a DAO interface with queries for monthly expenses and totals.
- Build the AppDatabase class and wire it into MainActivity to replace hardcoded data.
Experience the limitation of hardcoded data
Before you build the database, take a moment to see the current limitation for yourself.
- On your phone, force-close the BudgetBuddy app by swiping it away from the recent apps screen.
- Reopen BudgetBuddy.
What do you think will happen? Will you see the same three expenses, different ones, or nothing at all?
You see the exact same three expenses. They are not "saved" anywhere. The code just rebuilds them every launch. A real user could never add, edit, or remove an expense because nothing is stored. Room fixes this by giving the app a real SQLite database on the device.
Convert Expense to a Room Entity
Room maps Java classes to database tables using annotations. You will add annotations to your existing Expense class so Room knows how to store it.
- Open Expense.java in the com.example.budgetbuddy package.
- Replace the entire file contents with this annotated version:
package com.example.budgetbuddy;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity(tableName = "expenses")
public class Expense {
@PrimaryKey(autoGenerate = true)
public int id;
@ColumnInfo(name = "description")
public String description;
@ColumnInfo(name = "amount")
public double amount;
@ColumnInfo(name = "category")
public String category;
@ColumnInfo(name = "date")
public long date;
public Expense(String description, double amount, String category, long date) {
this.description = description;
this.amount = amount;
this.category = category;
this.date = date;
}
}
What do these annotations do?
- @Entity(tableName = "expenses") tells Room to create a table called expenses in the database.
- @PrimaryKey(autoGenerate = true) makes Room assign a unique ID to each expense automatically.
- @ColumnInfo(name = "description") maps the Java field to a named column. This gives you explicit control over the database schema.
- Save Expense.java.
Seeing red underlines on the imports?
Make sure you added the Room dependencies in Step 1. If the imports are unresolved, open your module-level build.gradle and confirm androidx.room:room-runtime:2.8.4 is listed, then click Sync Now.
help me fix Room import errors in my Expense Entity class
Create the ExpenseDao interface
A DAO (Data Access Object) defines the SQL operations your app can perform on the database. Room generates the implementation at compile time, so you only write the interface.
- Right-click the com.example.budgetbuddy package in the Project panel.
- Select New, then Java Class.
- Name it ExpenseDao and change the kind to Interface.
- Replace the generated content with the following code:
package com.example.budgetbuddy;
import androidx.lifecycle.LiveData;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import java.util.List;
@Dao
public interface ExpenseDao {
@Insert
void insert(Expense expense);
@Delete
void delete(Expense expense);
@Query("SELECT * FROM expenses WHERE date >= :startOfMonth AND date < :endOfMonth ORDER BY date DESC")
LiveData<List<Expense>> getExpensesForMonth(long startOfMonth, long endOfMonth);
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM expenses WHERE date >= :startOfMonth AND date < :endOfMonth")
LiveData<Double> getMonthlyTotal(long startOfMonth, long endOfMonth);
}
What do these queries do?
- insert() and delete() handle creating and removing expenses. Room generates the SQL for you.
- getExpensesForMonth() returns all expenses within a date range, sorted newest first. It returns LiveData so the UI updates automatically when data changes.
- COALESCE(SUM(amount), 0.0) calculates the monthly total. COALESCE prevents a null result when the table has no expenses yet.
Now add two more queries that filter expenses by category. These will power the Secret Mission later.
- Add the following methods inside the ExpenseDao interface, below the getMonthlyTotal method:
@Query("SELECT * FROM expenses WHERE category = :category AND date >= :startOfMonth AND date < :endOfMonth ORDER BY date DESC")
LiveData<List<Expense>> getExpensesByCategory(String category, long startOfMonth, long endOfMonth);
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM expenses WHERE category = :category AND date >= :startOfMonth AND date < :endOfMonth")
LiveData<Double> getCategoryTotal(String category, long startOfMonth, long endOfMonth);
Why add category queries now?
These methods filter by a specific category string. Adding them now means you will not need to modify the DAO later when you tackle the Secret Mission. The database layer is complete in one pass.
- Save ExpenseDao.java.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
Here is the complete ExpenseDao.java file:
package com.example.budgetbuddy;
import androidx.lifecycle.LiveData;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import java.util.List;
@Dao
public interface ExpenseDao {
@Insert
void insert(Expense expense);
@Delete
void delete(Expense expense);
@Query("SELECT * FROM expenses WHERE date >= :startOfMonth AND date < :endOfMonth ORDER BY date DESC")
LiveData<List<Expense>> getExpensesForMonth(long startOfMonth, long endOfMonth);
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM expenses WHERE date >= :startOfMonth AND date < :endOfMonth")
LiveData<Double> getMonthlyTotal(long startOfMonth, long endOfMonth);
@Query("SELECT * FROM expenses WHERE category = :category AND date >= :startOfMonth AND date < :endOfMonth ORDER BY date DESC")
LiveData<List<Expense>> getExpensesByCategory(String category, long startOfMonth, long endOfMonth);
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM expenses WHERE category = :category AND date >= :startOfMonth AND date < :endOfMonth")
LiveData<Double> getCategoryTotal(String category, long startOfMonth, long endOfMonth);
}
Create the AppDatabase class and wire it to MainActivity
The database class ties the Entity and DAO together. It also uses a singleton pattern so only one database connection exists at a time.
- Right-click the com.example.budgetbuddy package.
- Select New then Java Class.
- Name it AppDatabase and keep the kind as Class.
- Replace the generated content with this code:
package com.example.budgetbuddy;
import android.content.Context;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
@Database(entities = {Expense.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract ExpenseDao expenseDao();
private static volatile AppDatabase INSTANCE;
public static AppDatabase getDatabase(Context context) {
if (INSTANCE == null) {
synchronized (AppDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(),
AppDatabase.class,
"budget_buddy_db"
).build();
}
}
}
return INSTANCE;
}
}
What is the singleton pattern doing here?
The double-checked locking in getDatabase() ensures only one instance of the database exists. Opening multiple database connections simultaneously can corrupt your data. The volatile keyword guarantees all threads see the same instance.
- Save AppDatabase.java.
Now update MainActivity.java to use the database instead of hardcoded expenses.
- Open MainActivity.java.
- Replace the entire onCreate method and add the helper method below it. Your file should use the database to load expenses:
// Add these imports at the top of MainActivity.java
import java.util.Calendar;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
- Add these fields at the top of the MainActivity class, below the existing binding field:
private ExpenseDao expenseDao;
private ExecutorService executorService;
- Replace the body of your onCreate method with this version that uses the database:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// Get database and DAO
AppDatabase db = AppDatabase.getDatabase(this);
expenseDao = db.expenseDao();
executorService = Executors.newSingleThreadExecutor();
// Set up RecyclerView
adapter = new ExpenseAdapter(this);
binding.recyclerExpenses.setLayoutManager(new LinearLayoutManager(this));
binding.recyclerExpenses.setAdapter(adapter);
// Calculate month boundaries
long[] monthBounds = getMonthBounds();
// Observe expenses from database
expenseDao.getExpensesForMonth(monthBounds[0], monthBounds[1])
.observe(this, expenses -> adapter.setExpenses(expenses));
// Wire FAB to insert a test expense
binding.fabAddExpense.setOnClickListener(v -> {
Expense test = new Expense("Test Expense", 150.0,
"Food", System.currentTimeMillis());
executorService.execute(() -> expenseDao.insert(test));
});
}
- Add the getMonthBounds() helper method below onCreate:
private long[] getMonthBounds() {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
long startOfMonth = calendar.getTimeInMillis();
calendar.add(Calendar.MONTH, 1);
long endOfMonth = calendar.getTimeInMillis();
return new long[]{startOfMonth, endOfMonth};
}
What changed in MainActivity?
- The three hardcoded Expense objects are gone. The RecyclerView now displays whatever the database returns.
- expenseDao.getExpensesForMonth() returns LiveData, so the list updates automatically whenever you insert or delete an expense.
- executorService.execute() runs database writes on a background thread. Android crashes your app if you access the database on the main thread.
- getMonthBounds() calculates the first and last millisecond of the current month, so queries only return this month's expenses.
- Remove any leftover imports or hardcoded Expense creation code that is no longer used.
- Save MainActivity.java.
Build errors after updating MainActivity?
If you see errors about unused imports or missing symbols, make sure you removed the hardcoded Expense lines and added all three new imports (Calendar, ExecutorService, Executors). Also confirm that AppDatabase.java and ExpenseDao.java are saved in the same package.
help me fix build errors after wiring Room database to my MainActivity
Verify that expenses persist across restarts
Time to prove the database works. Before you run the app, think about what you expect to see. The hardcoded expenses are gone. The database starts empty. What will the list show?
- Click the green Run button to build and deploy to your phone.
- The app opens with an empty list. This is correct. The old hardcoded data is gone and the database has no entries yet.
- Tap the floating action button (the + icon) three times to insert three test expenses.
- Each tap adds a "Test Expense" for ₱150.00 under the Food category. You should see three items appear in the list.
Now for the real test. Do you think the expenses will survive a restart?
- Force-close the app by swiping it away from the recent apps screen.
- Reopen BudgetBuddy.
Your three test expenses are still there. The Room database stored them in a SQLite file on the device. Closing and reopening the app reads them back from disk.
List is empty after reopening?
Make sure you are not accidentally running a fresh install (which wipes the database). Click Run once, add expenses, then force-close from the phone itself. Do not click Run again before checking persistence, because some configurations reinstall the APK.
help me troubleshoot why my Room database expenses are not persisting after I close the app
Your app now has real persistence. Expenses survive restarts, force-closes, and even phone reboots. Next up, you will build a proper add-expense dialog and wire up LiveData with a ViewModel so the architecture is production-ready.
Add Expense Entry with a Dialog and Wire Up LiveData
Your app can store and display expenses, but there is no way for users to actually add one through the interface. Right now, the only way to get data in is by writing code directly against the DAO.
In this step, you will create an ExpenseViewModel that manages data on a background thread, build a dialog form for entering expenses, and wire LiveData so the list updates the instant you save something.
In this step, get ready to:
- Create an ExpenseViewModel class that handles database operations off the main thread.
- Build a dialog layout for entering expense details with a category picker.
- Wire LiveData observation so the RecyclerView and monthly total update live.
Create the ExpenseViewModel class
A ViewModel sits between your Activity and the database. It survives screen rotations, keeps data loading off the main thread, and exposes LiveData that your UI can observe for automatic updates.
- In Android Studio, right-click the com.example.budgetbuddy package in the Project panel.
- Select New then Java Class.
- Name it ExpenseViewModel and press Enter.
- Replace the generated file contents with this code:
package com.example.budgetbuddy;
import android.app.Application;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import java.util.Calendar;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExpenseViewModel extends AndroidViewModel {
private final ExpenseDao expenseDao;
private final ExecutorService executorService;
private final LiveData<List<Expense>> monthlyExpenses;
private final LiveData<Double> monthlyTotal;
public ExpenseViewModel(Application application) {
super(application);
AppDatabase db = AppDatabase.getDatabase(application);
expenseDao = db.expenseDao();
executorService = Executors.newSingleThreadExecutor();
long[] monthBounds = getMonthBounds();
monthlyExpenses = expenseDao.getExpensesForMonth(monthBounds[0], monthBounds[1]);
monthlyTotal = expenseDao.getMonthlyTotal(monthBounds[0], monthBounds[1]);
}
What does this code do?
AndroidViewModel is a special ViewModel that receives the Application context, which lets it safely access the database singleton without holding a reference to any Activity.
- ExecutorService creates a background thread for database writes so the main (UI) thread never blocks.
- The constructor calls getMonthBounds() to calculate the start and end timestamps of the current month, then uses those to query only this month's expenses.
- Below the constructor, add the public getter and action methods:
public LiveData<List<Expense>> getMonthlyExpenses() {
return monthlyExpenses;
}
public LiveData<Double> getMonthlyTotal() {
return monthlyTotal;
}
public LiveData<List<Expense>> getExpensesByCategory(String category) {
long[] monthBounds = getMonthBounds();
return expenseDao.getExpensesByCategory(category, monthBounds[0], monthBounds[1]);
}
public LiveData<Double> getCategoryTotal(String category) {
long[] monthBounds = getMonthBounds();
return expenseDao.getCategoryTotal(category, monthBounds[0], monthBounds[1]);
}
public void insert(Expense expense) {
executorService.execute(() -> expenseDao.insert(expense));
}
public void delete(Expense expense) {
executorService.execute(() -> expenseDao.delete(expense));
}
What do these methods do?
- getMonthlyExpenses() and getMonthlyTotal() return LiveData objects that automatically notify observers when the underlying data changes.
- insert() and delete() wrap DAO calls inside the executor so they run on a background thread. Calling these from your Activity is safe.
- getExpensesByCategory() and getCategoryTotal() are prepared for a future filtering feature.
- Finally, add the private helper method that calculates month boundaries, then close the class:
private long[] getMonthBounds() {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
long startOfMonth = calendar.getTimeInMillis();
calendar.add(Calendar.MONTH, 1);
long endOfMonth = calendar.getTimeInMillis();
return new long[]{startOfMonth, endOfMonth};
}
}
What does getMonthBounds do?
It resets a Calendar to midnight on the 1st of the current month (startOfMonth), then advances one month to get endOfMonth. Your DAO queries use these two timestamps to filter expenses to the current month only.
- Save ExpenseViewModel.java.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file with no red underlines in the editor.
ⓧ I'd like to double check the full code
Your ExpenseViewModel.java should contain three sections in this order:
- Package, imports, class declaration, four private fields, and the constructor that initialises the DAO, executor, and both LiveData objects.
- Six public methods: two getters for monthly data, two for category filtering, and insert() / delete() using the executor.
- The private getMonthBounds() helper and the closing class brace.
Seeing red underlines in ExpenseViewModel?
- Make sure Expense.java, ExpenseDao.java, and AppDatabase.java are all in the same com.example.budgetbuddy package.
- Confirm your build.gradle includes lifecycle-viewmodel:2.11.0 and lifecycle-livedata:2.11.0 in dependencies.
Still stuck? Help me fix red underlines in my ExpenseViewModel class.
Create the add expense dialog and update MainActivity
Now you need a form that pops up when the user taps the add button. You will create a dialog layout with fields for amount, description, and category, then rewrite MainActivity to use the new ExpenseViewModel.
- Right-click the res/layout folder, select New then Layout Resource File.
- Name it dialog_add_expense and press Enter.
- Replace the file contents with this layout:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<EditText
android:id="@+id/edit_amount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Amount"
android:inputType="numberDecimal" />
<EditText
android:id="@+id/edit_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Description"
android:inputType="text"
android:layout_marginTop="8dp" />
<Spinner
android:id="@+id/spinner_category"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp" />
</LinearLayout>
What does this layout contain?
- edit_amount accepts decimal numbers (for peso amounts like 150.50).
- edit_description accepts free text (what the expense was for).
- spinner_category is a dropdown menu you will populate with category options in code.
- Save dialog_add_expense.xml.
- Now open MainActivity.java and replace its entire contents with the following code, starting with the imports and class declaration:
package com.example.budgetbuddy;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.Spinner;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.budgetbuddy.databinding.ActivityMainBinding;
import java.text.NumberFormat;
import java.util.Locale;
public class MainActivity extends AppCompatActivity implements ExpenseAdapter.OnExpenseClickListener {
private ActivityMainBinding binding;
private ExpenseViewModel viewModel;
private ExpenseAdapter adapter;
private NumberFormat pesoFormat;
What changed from before?
The class no longer references AppDatabase or ExpenseDao directly. Instead, it holds a reference to ExpenseViewModel which handles all database access. The pesoFormat field lets you format currency consistently across the Activity.
- Directly below the fields, add onCreate, setupRecyclerView, and setupViewModel:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
pesoFormat = NumberFormat.getCurrencyInstance(new Locale("en", "PH"));
setupRecyclerView();
setupViewModel();
setupFab();
setupBudgetHeader();
}
private void setupRecyclerView() {
adapter = new ExpenseAdapter(this);
binding.recyclerExpenses.setLayoutManager(new LinearLayoutManager(this));
binding.recyclerExpenses.setAdapter(adapter);
}
private void setupViewModel() {
viewModel = new ViewModelProvider(this).get(ExpenseViewModel.class);
viewModel.getMonthlyExpenses().observe(this, expenses -> {
adapter.setExpenses(expenses);
});
viewModel.getMonthlyTotal().observe(this, total -> {
binding.textMonthlyTotal.setText(pesoFormat.format(total));
});
}
What does setupViewModel do?
ViewModelProvider creates or retrieves the existing ViewModel instance. It survives configuration changes like screen rotations.
The two .observe() calls register listeners. Whenever the database data changes, Room automatically pushes the new values through LiveData, which triggers your lambda to update the adapter and total text.
- Below setupViewModel, add setupFab and the showAddExpenseDialog method:
private void setupFab() {
binding.fabAddExpense.setOnClickListener(v -> showAddExpenseDialog());
}
private void showAddExpenseDialog() {
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_expense, null);
EditText editAmount = dialogView.findViewById(R.id.edit_amount);
EditText editDescription = dialogView.findViewById(R.id.edit_description);
Spinner spinnerCategory = dialogView.findViewById(R.id.spinner_category);
String[] categories = {"Food", "Transport", "Bills", "Entertainment", "Other"};
ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_dropdown_item, categories);
spinnerCategory.setAdapter(spinnerAdapter);
new AlertDialog.Builder(this)
.setTitle("Add Expense")
.setView(dialogView)
.setPositiveButton("Save", (dialog, which) -> {
String amountStr = editAmount.getText().toString().trim();
String description = editDescription.getText().toString().trim();
String category = spinnerCategory.getSelectedItem().toString();
if (!amountStr.isEmpty() && !description.isEmpty()) {
double amount = Double.parseDouble(amountStr);
Expense expense = new Expense(description, amount, category,
System.currentTimeMillis());
viewModel.insert(expense);
}
})
.setNegativeButton("Cancel", null)
.show();
}
How does the dialog work?
- The method inflates dialog_add_expense.xml as a custom view inside an AlertDialog.
- The Spinner is populated with five category strings using an ArrayAdapter.
- When the user taps Save, the code validates that both fields are filled, creates an Expense with the current timestamp, and inserts it via the ViewModel.
- Finally, add setupBudgetHeader (a placeholder for the next step) and close the class:
private void setupBudgetHeader() {
}
}
- Save MainActivity.java.
- Run the app on your device by pressing the green play button in the toolbar.
Tap the floating action button (the + icon in the bottom-right corner). You should see a dialog with an amount field, a description field, and a category dropdown.
Enter 150 for the amount, Lunch for the description, leave the category as Food, and tap Save. The expense should appear immediately in the list, and the monthly total at the top should show ₱150.00.
App crashes when tapping the FAB?
- Check that the file is named dialog_add_expense.xml (not dialog_add_expenses.xml) so R.layout.dialog_add_expense resolves correctly.
- Verify the IDs in your dialog XML match what MainActivity expects: edit_amount, edit_description, spinner_category.
- Make sure ExpenseViewModel.java compiles without errors. A broken ViewModel prevents the Activity from starting.
Still stuck? Help me fix a crash when tapping the FAB in my BudgetBuddy app.
Add tap-to-delete and verify live updates
Adding expenses works. Now you need a way to remove them. When the user taps an expense in the list, a confirmation dialog should appear and delete it from the database.
- In MainActivity.java, find the closing brace } of the class at the very bottom.
- Add the following onExpenseClick method just above that closing brace:
@Override
public void onExpenseClick(Expense expense) {
new AlertDialog.Builder(this)
.setTitle("Delete Expense")
.setMessage("Delete \"" + expense.description + "\"?")
.setPositiveButton("Delete", (dialog, which) -> viewModel.delete(expense))
.setNegativeButton("Cancel", null)
.show();
}
How does tap-to-delete work?
The ExpenseAdapter you created in a previous step calls onExpenseClick whenever a list item is tapped. This method shows a confirmation dialog, and if the user confirms, it calls viewModel.delete() which removes the expense on a background thread. LiveData then automatically notifies the RecyclerView to refresh.
- Save MainActivity.java and run the app again.
Before you test, think about what you expect to happen. If you add two expenses and then tap one to delete it, what should the monthly total show?
- Tap the FAB and add an expense: amount 200, description Grab ride, category Transport.
- Tap Save. The expense appears in the list and the total updates.
- Tap the Grab ride item in the list.
- Tap Delete in the confirmation dialog.
The expense disappears from the list and the monthly total drops by ₱200.00 instantly. No page refresh, no restart. LiveData handles the entire reactive update behind the scenes.
- To confirm persistence, force-close the app from your phone's recent apps, then reopen it.
- Any expenses you kept should still be there with the correct monthly total.
Expenses not disappearing after delete?
- Confirm your onExpenseClick method calls viewModel.delete(expense) inside the positive button listener, not outside it.
- Check that ExpenseAdapter calls listener.onExpenseClick(expense) in the onBindViewHolder item click lambda.
Still stuck? Help me debug why deleting expenses does not update my RecyclerView.
Your app now has a full add-and-delete flow with live-updating totals. Next up, you will add a savings goal tracker so users can set a monthly budget and see whether they are on track.
Add Savings Goal Tracking with SharedPreferences
Your expense tracker records spending and shows a live total. But tracking what you spent is only half the picture.
The real value comes from knowing whether you are on track to meet your savings goal each month. In this step, you will add a budget settings dialog where users enter their monthly income and savings target. The app will then calculate whether spending is within budget and display that status in real time.
In this step, get ready to:
- Create a budget settings dialog that saves income and savings goal to SharedPreferences.
- Display a live on-track or over-budget status indicator on the main screen.
- Add green and red conditional formatting that updates as expenses change.
Create the budget settings dialog layout
The budget settings dialog needs two input fields: one for monthly income and one for the savings goal. Both values will be in Philippine Peso.
- In Android Studio, right-click your res/layout folder in the Project panel.
- Select New then Layout Resource File.
- Name the file dialog_budget_settings and click OK.
- Replace the contents of dialog_budget_settings.xml with the following layout:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<EditText
android:id="@+id/edit_income"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Monthly Income (PHP)"
android:inputType="numberDecimal" />
<EditText
android:id="@+id/edit_savings_goal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Savings Goal (PHP)"
android:inputType="numberDecimal"
android:layout_marginTop="8dp" />
</LinearLayout>
What does this layout do?
This layout mirrors the structure of your dialog_add_expense.xml but with just two decimal-number inputs. The user types their monthly income and how much they want to save. Both use numberDecimal input type so only numeric values can be entered.
- Confirm the file appears in the res/layout folder by expanding it in the Project panel. You should see dialog_budget_settings.xml listed alongside your other layout files.
Wire up the budget settings dialog in MainActivity
The app already has a stub for setupBudgetHeader() that wires the monthly total tap to showBudgetSettingsDialog(). Now you need to implement that method so it actually opens a dialog and saves values to SharedPreferences.
- In MainActivity.java, find the field declarations at the top of the class (where binding, viewModel, adapter, and pesoFormat are declared).
- Add these three constants directly below the field declarations:
// SharedPreferences file name and keys for budget settings
private static final String PREFS_NAME = "budget_buddy_prefs";
private static final String KEY_MONTHLY_INCOME = "monthly_income";
private static final String KEY_SAVINGS_GOAL = "savings_goal";
What are these constants?
SharedPreferences stores data as key-value pairs in an XML file on the device. PREFS_NAME is the file name, while KEY_MONTHLY_INCOME and KEY_SAVINGS_GOAL are the keys you will use to store and retrieve the two budget values.
- Now find the showBudgetSettingsDialog() stub method and replace its entire body with this implementation:
private void showBudgetSettingsDialog() {
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_budget_settings, null);
EditText editIncome = dialogView.findViewById(R.id.edit_income);
EditText editGoal = dialogView.findViewById(R.id.edit_savings_goal);
// Load existing budget settings
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
double income = prefs.getFloat(KEY_MONTHLY_INCOME, 0f);
double goal = prefs.getFloat(KEY_SAVINGS_GOAL, 0f);
if (income > 0) editIncome.setText(String.valueOf((int) income));
if (goal > 0) editGoal.setText(String.valueOf((int) goal));
// Build and show the settings dialog
new AlertDialog.Builder(this)
.setTitle("Budget Settings")
.setView(dialogView)
.setPositiveButton("Save", (dialog, which) -> {
String incomeStr = editIncome.getText().toString().trim();
String goalStr = editGoal.getText().toString().trim();
float newIncome = incomeStr.isEmpty() ? 0f : Float.parseFloat(incomeStr);
float newGoal = goalStr.isEmpty() ? 0f : Float.parseFloat(goalStr);
prefs.edit()
.putFloat(KEY_MONTHLY_INCOME, newIncome)
.putFloat(KEY_SAVINGS_GOAL, newGoal)
.apply();
Double currentTotal = viewModel.getMonthlyTotal().getValue();
if (currentTotal != null) {
updateBudgetStatus(currentTotal);
}
})
.setNegativeButton("Cancel", null)
.show();
}
What does this method do?
- It inflates the dialog_budget_settings.xml layout and finds both input fields.
- It reads any previously saved income and goal from SharedPreferences and pre-fills the fields so the user sees their current settings.
- When the user taps Save, it parses the text inputs into floats, writes them to SharedPreferences with apply(), and immediately updates the budget status display.
- Make sure you have the SharedPreferences import at the top of your file. Add this with your other imports if it is not already there:
import android.content.SharedPreferences;
- Save MainActivity.java.
- Run the app on your phone by clicking the green play button.
- Tap the monthly total (the large ₱0.00 text) at the top of the screen.
You should see the Budget Settings dialog appear with two empty input fields for monthly income and savings goal.
Dialog not appearing when you tap the total?
- Confirm that setupBudgetHeader() is called inside onCreate(). It should already be there from the previous step.
- Check that the dialog_budget_settings.xml file is saved in the correct folder (res/layout).
- Make sure you are tapping the ₱0.00 text itself, not the card background.
Help me debug why my budget settings dialog is not showing up.
Add the budget status indicator with conditional formatting
The dialog saves values but nothing visible changes on the main screen yet. You need to implement updateBudgetStatus() so it reads the saved budget, compares it to spending, and shows a green or red status message.
- In MainActivity.java, find the updateBudgetStatus method (currently a stub) and replace it with this implementation:
private void updateBudgetStatus(double totalSpent) {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
double income = prefs.getFloat(KEY_MONTHLY_INCOME, 0f);
double goal = prefs.getFloat(KEY_SAVINGS_GOAL, 0f);
if (income > 0) {
// Calculate how much the user can spend after saving
double spendingLimit = income - goal;
double remaining = spendingLimit - totalSpent;
binding.textBudgetStatus.setVisibility(View.VISIBLE);
if (remaining >= 0) {
binding.textBudgetStatus.setText("On track - " + pesoFormat.format(remaining) + " left to spend");
binding.textBudgetStatus.setTextColor(ContextCompat.getColor(this, android.R.color.holo_green_dark));
} else {
binding.textBudgetStatus.setText("Over budget by " + pesoFormat.format(Math.abs(remaining)));
binding.textBudgetStatus.setTextColor(ContextCompat.getColor(this, android.R.color.holo_red_dark));
}
} else {
// No income set, hide the status indicator
binding.textBudgetStatus.setVisibility(View.GONE);
}
}
How does the budget calculation work?
- The spending limit is income minus savings goal. If your income is 30,000 and your goal is 5,000, you can spend up to 25,000.
- Remaining equals the spending limit minus total spent. A positive number means you are on track. Negative means you have overspent.
- ContextCompat.getColor sets the text to green (holo_green_dark) when on track or red (holo_red_dark) when over budget.
- If no income has been set yet (income equals 0), the status text stays hidden so it does not confuse first-time users.
- Add the ContextCompat import at the top of your file if it is not already there:
import androidx.core.content.ContextCompat;
- Save MainActivity.java.
Before you run this, think about what you expect to see. If you set income to 30000 and savings goal to 5000, then add expenses totaling 10000, what should the remaining amount be?
- Run the app on your phone.
- Tap the monthly total to open Budget Settings.
- Enter 30000 for Monthly Income.
- Enter 5000 for Savings Goal.
- Tap Save.
- Use the add button to create expenses that total 10000 (for example, two expenses of 5000 each).
The status indicator below the monthly total should turn green and read "On track" with ₱15,000.00 left to spend. The spending limit is 30000 minus 5000 = 25000, and you spent 10000, so 15000 remains.
- Now add more expenses until your total exceeds 25000.
The status turns red and shows "Over budget by" with the overage amount. Delete some expenses and watch it flip back to green.
Status text not appearing after saving budget settings?
- Make sure updateBudgetStatus() is called from two places: inside the monthly total observer in setupViewModel(), and inside the Save button handler in showBudgetSettingsDialog().
- Check that your activity_main.xml has a TextView with id text_budget_status. It should already exist from Step 2 with visibility="gone".
- Verify you entered a non-zero income value. The status only appears when income is greater than 0.
Help me figure out why my budget status is not showing after I save settings.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete dialog_budget_settings.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<EditText
android:id="@+id/edit_income"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Monthly Income (PHP)"
android:inputType="numberDecimal" />
<EditText
android:id="@+id/edit_savings_goal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Savings Goal (PHP)"
android:inputType="numberDecimal"
android:layout_marginTop="8dp" />
</LinearLayout>
For MainActivity.java, confirm these sections are present. The constants at the top of the class:
private static final String PREFS_NAME = "budget_buddy_prefs";
private static final String KEY_MONTHLY_INCOME = "monthly_income";
private static final String KEY_SAVINGS_GOAL = "savings_goal";
The showBudgetSettingsDialog() method:
private void showBudgetSettingsDialog() {
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_budget_settings, null);
EditText editIncome = dialogView.findViewById(R.id.edit_income);
EditText editGoal = dialogView.findViewById(R.id.edit_savings_goal);
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
double income = prefs.getFloat(KEY_MONTHLY_INCOME, 0f);
double goal = prefs.getFloat(KEY_SAVINGS_GOAL, 0f);
if (income > 0) editIncome.setText(String.valueOf((int) income));
if (goal > 0) editGoal.setText(String.valueOf((int) goal));
new AlertDialog.Builder(this)
.setTitle("Budget Settings")
.setView(dialogView)
.setPositiveButton("Save", (dialog, which) -> {
String incomeStr = editIncome.getText().toString().trim();
String goalStr = editGoal.getText().toString().trim();
float newIncome = incomeStr.isEmpty() ? 0f : Float.parseFloat(incomeStr);
float newGoal = goalStr.isEmpty() ? 0f : Float.parseFloat(goalStr);
prefs.edit()
.putFloat(KEY_MONTHLY_INCOME, newIncome)
.putFloat(KEY_SAVINGS_GOAL, newGoal)
.apply();
Double currentTotal = viewModel.getMonthlyTotal().getValue();
if (currentTotal != null) {
updateBudgetStatus(currentTotal);
}
})
.setNegativeButton("Cancel", null)
.show();
}
The updateBudgetStatus() method:
private void updateBudgetStatus(double totalSpent) {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
double income = prefs.getFloat(KEY_MONTHLY_INCOME, 0f);
double goal = prefs.getFloat(KEY_SAVINGS_GOAL, 0f);
if (income > 0) {
double spendingLimit = income - goal;
double remaining = spendingLimit - totalSpent;
binding.textBudgetStatus.setVisibility(View.VISIBLE);
if (remaining >= 0) {
binding.textBudgetStatus.setText("On track - " + pesoFormat.format(remaining) + " left to spend");
binding.textBudgetStatus.setTextColor(ContextCompat.getColor(this, android.R.color.holo_green_dark));
} else {
binding.textBudgetStatus.setText("Over budget by " + pesoFormat.format(Math.abs(remaining)));
binding.textBudgetStatus.setTextColor(ContextCompat.getColor(this, android.R.color.holo_red_dark));
}
} else {
binding.textBudgetStatus.setVisibility(View.GONE);
}
}
Your expense tracker now helps users plan, not just record. Next up, you will add month navigation so users can flip through past months and see a full yearly spending overview.
Add Month Navigation and Yearly Overview
Your expense tracker now shows whether you are on track or over budget for the current month. That is useful while the month is still in progress.
But what about last month? Right now there is no way to look back at previous spending or get a sense of your habits over the year. In this step, you will add month navigation arrows so users can flip through past months, and a yearly overview dialog that totals up spending across all twelve months.
In this step, get ready to:
- Add month navigation arrows and a month label to the header card.
- Wire the arrows to reload expenses and totals for any selected month.
- Build a yearly overview dialog that shows per-month spending for the entire year.
Add month navigation to the header layout
Users need a way to move between months without restarting the app. You will add a row with left and right arrows flanking the current month name, plus a "Year" button that opens the yearly summary.
- Open activity_main.xml in Android Studio.
- Find the text_budget_status TextView inside the MaterialCardView. Add the following LinearLayout block directly below it, still inside the card:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="8dp">
<ImageButton
android:id="@+id/btn_prev_month"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@android:drawable/ic_media_previous"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Previous month" />
<TextView
android:id="@+id/text_month_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:textSize="14sp" />
<ImageButton
android:id="@+id/btn_next_month"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@android:drawable/ic_media_next"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Next month" />
</LinearLayout>
What does this layout add?
- btn_prev_month and btn_next_month are small arrow buttons that let users tap backward or forward through months.
- text_month_label sits between the arrows and displays the currently viewed month (for example, "August 2026").
- The layout_weight="1" on the label makes it stretch to fill the space between the two fixed-size buttons.
- Save activity_main.xml.
Wire up month navigation in MainActivity
The ViewModel already exposes the DAO's getExpensesForMonth() and getMonthlyTotal() methods that accept start and end timestamps. You just need to add public wrappers that accept arbitrary bounds, then wire the arrows in MainActivity to shift the displayed month.
- Open ExpenseViewModel.java.
- Add these two public methods below the existing getCategoryTotal() method:
// Load expenses for any month using custom start/end timestamps
public LiveData<List<Expense>> getExpensesForMonth(long start, long end) {
return expenseDao.getExpensesForMonth(start, end);
}
// Get the total spending for any month using custom timestamps
public LiveData<Double> getTotalForMonth(long start, long end) {
return expenseDao.getMonthlyTotal(start, end);
}
What do these methods add?
The existing getMonthlyExpenses() and getMonthlyTotal() are hardcoded to the current month (calculated once in the constructor). These new methods accept any start/end pair, so the UI can request data for any month by passing different timestamps.
- Save ExpenseViewModel.java.
- Now open MainActivity.java.
- Find the field declarations at the top of the class (where binding, viewModel, adapter, and pesoFormat are declared). Add these two fields below them:
// Tracks how many months offset from the current month (0 = this month, -1 = last month)
private int monthOffset = 0;
private java.util.Calendar displayedMonth;
- Find the setupBudgetHeader() method. Add a call to setupMonthNavigation() at the end of its body:
private void setupBudgetHeader() {
binding.textMonthlyTotal.setOnClickListener(v -> showBudgetSettingsDialog());
setupMonthNavigation();
}
- Add the following setupMonthNavigation() method below setupBudgetHeader():
private void setupMonthNavigation() {
displayedMonth = java.util.Calendar.getInstance();
updateMonthLabel();
binding.btnPrevMonth.setOnClickListener(v -> {
monthOffset--;
navigateMonth();
});
binding.btnNextMonth.setOnClickListener(v -> {
if (monthOffset < 0) {
monthOffset++;
navigateMonth();
}
});
}
What does this method do?
- It initialises displayedMonth to today's date and shows the current month name in the label.
- The left arrow decreases monthOffset (going further into the past) and reloads the data.
- The right arrow only increments if monthOffset < 0, preventing users from navigating into the future (since there are no future expenses to show).
- Add the navigateMonth(), getMonthBoundsForOffset(), and updateMonthLabel() helper methods directly below setupMonthNavigation():
private void navigateMonth() {
displayedMonth = java.util.Calendar.getInstance();
displayedMonth.add(java.util.Calendar.MONTH, monthOffset);
updateMonthLabel();
long[] bounds = getMonthBoundsForOffset();
viewModel.getExpensesForMonth(bounds[0], bounds[1]).observe(this, expenses -> {
adapter.setExpenses(expenses);
});
viewModel.getTotalForMonth(bounds[0], bounds[1]).observe(this, total -> {
binding.textMonthlyTotal.setText(pesoFormat.format(total));
if (monthOffset == 0) {
updateBudgetStatus(total);
} else {
binding.textBudgetStatus.setVisibility(View.GONE);
}
});
}
private long[] getMonthBoundsForOffset() {
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.add(java.util.Calendar.MONTH, monthOffset);
cal.set(java.util.Calendar.DAY_OF_MONTH, 1);
cal.set(java.util.Calendar.HOUR_OF_DAY, 0);
cal.set(java.util.Calendar.MINUTE, 0);
cal.set(java.util.Calendar.SECOND, 0);
cal.set(java.util.Calendar.MILLISECOND, 0);
long start = cal.getTimeInMillis();
cal.add(java.util.Calendar.MONTH, 1);
long end = cal.getTimeInMillis();
return new long[]{start, end};
}
private void updateMonthLabel() {
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("MMMM yyyy", java.util.Locale.getDefault());
binding.textMonthLabel.setText(sdf.format(displayedMonth.getTime()));
}
How does month navigation work end to end?
- navigateMonth() shifts displayedMonth by the current offset, updates the label, then asks the ViewModel for that month's expenses and total.
- getMonthBoundsForOffset() calculates the start (midnight on the 1st) and end (midnight on the 1st of the next month) timestamps for the offset month. This is the same pattern as getMonthBounds() in the ViewModel, just parameterised.
- The budget status only shows for the current month (monthOffset == 0) because it compares against your income and savings goal which are monthly targets.
- Save MainActivity.java.
- Run the app on your phone by clicking the green play button.
You should see the current month name (for example, "August 2026") displayed between two arrow buttons below the budget status. Tap the left arrow.
The month label changes to the previous month and the expense list clears (since you have no expenses from last month). The monthly total resets to ₱0.00. Tap the right arrow to return to the current month and your expenses reappear.
Arrows not appearing or app crashes?
- Make sure the LinearLayout with the arrows is inside the MaterialCardView, not outside it. It should sit below text_budget_status but still within the card's inner LinearLayout.
- Check that your ids match exactly: btn_prev_month, text_month_label, btn_next_month (with underscores, not hyphens).
- Confirm setupMonthNavigation() is called from within setupBudgetHeader(), which is called in onCreate().
Help me debug why my month navigation arrows are not working in BudgetBuddy.
Build the yearly overview dialog
Flipping through months one at a time is useful, but users also want a big-picture view. You will add a synchronous DAO query that runs on a background thread, then display the results in a simple dialog showing each month's total.
- Open ExpenseDao.java.
- Add this synchronous query method below your existing queries:
// Synchronous version for background-thread use (no LiveData wrapper)
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM expenses WHERE timestamp >= :start AND timestamp < :end")
double getMonthlyTotalSync(long start, long end);
Why a synchronous query?
Your existing queries return LiveData which works great for reactive UI updates. But for the yearly overview, you need to loop through 12 months and collect all the totals at once. A synchronous query lets you do this on a background thread and return all 12 values together.
- Save ExpenseDao.java.
- Open ExpenseViewModel.java.
- Add the following interface and method below the getTotalForMonth() method you added earlier:
public interface YearlyCallback {
void onResult(double[] monthlyTotals);
}
public void getYearlyTotals(YearlyCallback callback) {
executorService.execute(() -> {
double[] totals = new double[12];
Calendar cal = Calendar.getInstance();
int currentYear = cal.get(Calendar.YEAR);
for (int month = 0; month < 12; month++) {
cal.set(currentYear, month, 1, 0, 0, 0);
cal.set(Calendar.MILLISECOND, 0);
long start = cal.getTimeInMillis();
cal.add(Calendar.MONTH, 1);
long end = cal.getTimeInMillis();
totals[month] = expenseDao.getMonthlyTotalSync(start, end);
}
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
callback.onResult(totals);
});
});
}
What does getYearlyTotals do?
- It runs on the background thread (via executorService) so it does not block the UI.
- It loops through all 12 months of the current year, calculating start/end timestamps for each and querying the synchronous DAO method.
- Once all 12 totals are collected, it posts the result back to the main thread using a Handler so the callback can safely update the UI.
- Save ExpenseViewModel.java.
- Open MainActivity.java.
- Add the following showYearlyOverviewDialog() method below updateMonthLabel():
private void showYearlyOverviewDialog() {
viewModel.getYearlyTotals(totals -> {
String[] monthNames = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
StringBuilder sb = new StringBuilder();
double yearTotal = 0;
for (int i = 0; i < 12; i++) {
if (totals[i] > 0) {
sb.append(monthNames[i]).append(": ")
.append(pesoFormat.format(totals[i])).append("\n");
yearTotal += totals[i];
}
}
sb.append("\nTotal: ").append(pesoFormat.format(yearTotal));
new AlertDialog.Builder(this)
.setTitle("Yearly Overview")
.setMessage(sb.toString())
.setPositiveButton("OK", null)
.show();
});
}
How does the yearly overview dialog work?
- It calls getYearlyTotals() on the ViewModel, which runs the 12 queries on a background thread.
- When the callback fires, it builds a readable string showing only months that have spending (skipping months with ₱0).
- The year total is accumulated and displayed at the bottom of the dialog.
- Now wire the month label as a tap target for the yearly overview. In setupMonthNavigation(), add this line after updateMonthLabel():
binding.textMonthLabel.setOnClickListener(v -> showYearlyOverviewDialog());
- Save MainActivity.java.
- Run the app on your phone.
Before you test the yearly overview, think about what you expect to see. You have only added expenses in the current month, so the dialog should show one line with this month's total and nothing else.
- Tap the month label text (for example, "August 2026") between the two arrows.
The Yearly Overview dialog appears showing the current month's name and total, plus the overall year total at the bottom. If you add expenses in different months (by changing your phone's date or waiting until next month), each month with spending will appear in the list.
Yearly overview showing ₱0.00 or not appearing?
- Make sure you added getMonthlyTotalSync() to ExpenseDao.java (not ExpenseViewModel.java). The DAO is where Room generates the SQL implementation.
- Verify that your existing expenses have timestamps within the current year. Expenses added before January 1st of this year will not appear in the yearly overview.
- Check that you connected the tap listener to binding.textMonthLabel (the month name text), not one of the arrow buttons.
Help me debug why my yearly overview is not showing expense totals.
Your expense tracker now lets users explore their full spending history. Next up, tackle the Secret Mission and add category filtering so users can drill down into exactly where their money goes.
Secret mission
Add Category Filtering
Your expense tracker shows everything in one long list. When you have dozens of entries, it is hard to tell where your money is actually going. In this secret mission, you will add filter chips that let you tap a category and instantly see only those expenses, with the total updating to match.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your Android device and computer, so there are no ongoing costs. Decide whether to keep your resources as they are, pause development to come back later, or delete them entirely.
Resources you used:
- BudgetBuddy app installed on your Android device (includes the Room database and SharedPreferences data).
- BudgetBuddy project files on your computer (created by Android Studio).
Keep everything running
No action needed. Choose this if you are still actively building or extending BudgetBuddy.
- The app stays installed on your phone and works offline indefinitely. Your expense data persists in the local Room database.
- Your project files remain in Android Studio, ready for you to open and edit anytime.
- There are no servers, subscriptions, or cloud resources to worry about.
Pause - I'll come back to this later
Free up space on your computer while keeping the app on your phone.
- In Android Studio, close the BudgetBuddy project by clicking File, then Close Project.
- The app remains on your phone with all your expense data intact.
- When you are ready to resume, reopen the project folder in Android Studio.
Delete - I don't want to use this again
Remove all project resources from your phone and computer.
- Uninstall BudgetBuddy from your Android device. Open Settings, tap Apps, find BudgetBuddy, then tap Uninstall. This removes the Room database and SharedPreferences data from your device.
- Delete the project folder from your computer. Find the BudgetBuddy folder wherever Android Studio created it (typically in your user home directory or Desktop) and delete it.
On macOS or Linux, delete the project folder by running:
rm -rf ~/AndroidStudioProjects/BudgetBuddy
On Windows, delete the project folder by running:
rmdir /s /q %USERPROFILE%\AndroidStudioProjects\BudgetBuddy
Nice Work!
Nice Work!
You just built Budget Buddy, a fully functional expense tracker that runs on your Android phone. It stores data locally, updates reactively, and helps you stay on top of your monthly spending.
You've learned how to:
- Build a persistent Android app with Room database using Entity, DAO, and Database classes to store expenses that survive app restarts.
- Create a reactive UI with RecyclerView, ViewModel, and LiveData so the expense list and monthly total update automatically when data changes.
- Track savings goals with SharedPreferences and conditional formatting that shows green when you are on track and red when you are over budget.
- Secret Mission: Added category-based filtering with dynamic queries so users can see spending breakdowns per category.
Ready to quiz yourself?