AI Expense Tracker with Flutter
Build an AI-powered expense tracker that parses natural language with Gemini.
Introduction
30 Second Summary
Tracking expenses is one of those things everyone knows they should do, but nobody wants to tap through dropdown menus and number pads for every coffee or cab ride. What if you could just type "Spent ₹450 on biryani from Swiggy" and have AI figure out the rest?
In this project, you will build SpendSense, a smart expense tracker Android app where Gemini AI parses your natural language entries into structured data and a visual dashboard shows your spending patterns in real time.
What You'll Build
SpendSense is a Flutter Android app that combines natural language AI parsing with interactive chart visualizations and modern state management.
By the end of this project, you'll have:
- A natural language expense input powered by Gemini 3.5 Flash that extracts amounts, categories, merchants, and dates from plain text.
- An interactive spending dashboard with pie charts and bar charts showing category breakdowns and daily spending trends.
- A production-ready Flutter architecture using Riverpod 3, sealed classes, Material 3 dark theming, and Firebase AI Logic.
- Secret Mission: Add social expense splitting with friends and system share sheet integration.
Are there any prerequisites?
You need programming experience in any language and a Google account for Firebase. No prior Flutter or Dart experience is required.
Install Flutter and Firebase Tools
SpendSense needs Firebase AI Logic to turn natural language expense entries into structured data. Before writing any app code, you need a Firebase project with the Gemini Developer API enabled, a Flutter project wired to that Firebase project, and all the dependencies installed.
In this step, you will create the Firebase project, scaffold the Flutter app, connect the two together, and pull in every package the project needs.
In this step, get ready to:
- Create a Firebase project on the Spark plan and enable the Gemini Developer API.
- Create a new Flutter project and connect it to Firebase.
- Add all project dependencies and verify the app runs on your device.
Install Flutter, the Firebase CLI, and the FlutterFire CLI
This project needs three command-line tools: the Flutter SDK to build the app, the Firebase CLI to manage your Firebase project from the terminal, and the FlutterFire CLI to wire them together.
- Install the prerequisite packages Flutter needs by running this command:
sudo apt-get update && sudo apt-get install -y curl git unzip xz-utils zip libglu1-mesa
- Download and extract the Flutter SDK by running these commands:
cd ~/development
mkdir -p ~/development
cd ~/development
curl -LO https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz
tar -xf flutter_linux_3.44.0-stable.tar.xz
- Add Flutter to your PATH by running:
echo 'export PATH="$HOME/development/flutter/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
- Confirm Flutter is installed by running:
flutter --version
✔️ I see version 3.44 or higher
Flutter is ready. Move on to the next command below.
ⓧ I see an older version
You have an older Flutter SDK on your system. Upgrade to the latest stable version by running:
flutter upgrade
After upgrading, run flutter --version again to confirm you see 3.44 or higher.
ⓧ Command not found
Flutter is not on your PATH yet. Make sure the echo and source commands above ran without errors. Then close and reopen your terminal and try flutter --version again.
If it still fails, verify the SDK exists at ~/development/flutter/bin/flutter. If not, re-run the download and extract steps above.
- Install the Firebase CLI by running this command:
curl -sL https://firebase.tools | bash
This script auto-detects your system and installs the latest Firebase CLI. You should see a success message once it finishes.
- Verify the Firebase CLI is working by running:
firebase --version
You should see a version number like 15.18.0 or higher.
- Log in to Firebase with your Google account by running:
firebase login
A browser window opens and asks you to sign in with Google. Use the same account you will use in the Firebase console. After you grant access, your terminal confirms the login.
- Install the FlutterFire CLI by running:
dart pub global activate flutterfire_cli
What are these three tools?
- flutter builds, runs, and manages your Dart/Flutter app.
- firebase manages Firebase projects, deploys resources, and handles authentication from the terminal.
- flutterfire generates the config file that connects your Flutter app to your Firebase project.
🙋♀️ Seeing permission errors or command not found?
- If dart pub global activate warns that the Pub cache bin is not on your PATH, add it by running: echo 'export PATH="$HOME/.pub-cache/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc.
- If firebase login fails to open a browser, copy the URL printed in the terminal and paste it into your browser manually.
- If the apt-get install fails, make sure you are running with sudo and that your package lists are up to date.
help me fix Flutter or Firebase CLI installation issues on Linux
Create a Firebase project and enable the Gemini Developer API
Firebase AI Logic gives your app access to Gemini models without embedding an API key in your client code. The key is managed server-side by Firebase's proxy service.
- Open the Firebase console at https://console.firebase.google.com in your browser.
- Click Add project.
- Enter SpendSense as the project name.
- When prompted, accept the Firebase terms and click Continue.
- Skip Google Analytics for now (select No when asked).
- Click Create project.
- Wait for Firebase to provision your project, then click Continue to enter the project dashboard.
Why Spark plan and not Blaze?
The Spark plan is Firebase's free tier. It requires no payment method and gives you full access to the Gemini Developer API at no cost. If you accidentally link a Cloud Billing account, your plan auto-upgrades to Blaze (pay-as-you-go). Do not link billing during this project.
Now enable the Gemini Developer API so your app can call Gemini models.
- In the left sidebar of the Firebase console, click AI Services.
- Click AI Logic.
- Click Get started.
- Select Gemini Developer API as the provider and confirm.
You should see a confirmation that the Gemini Developer API is enabled for your project.
Why Firebase AI Logic instead of the standalone Gemini package?
The standalone google_generative_ai Dart package is officially deprecated as of Gemini 2.0. Google unified all SDKs into the firebase_ai package that routes calls through Firebase's proxy.
This means your API key never appears in client code. Firebase attaches it server-side. This is a strong security architecture talking point for interviews.
🙋♀️ Don't see AI Services in the sidebar?
Make sure you are inside your SpendSense project (check the project name at the top of the console). If the option still does not appear, refresh the page. Firebase occasionally takes a few seconds to provision the sidebar for a new project.
help me find AI Services in the Firebase console
Create the Flutter project and connect it to Firebase
With Firebase ready, you can now scaffold the Flutter app and link the two together using the FlutterFire CLI you just installed.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Flutter project by running:
flutter create spendsense
- Move into the new project folder by running:
cd spendsense
- Confirm the project was created by running:
ls
You should see folders like android, ios, lib, and files like pubspec.yaml in the output.
- Open the project in VS Code by running:
code .
Now connect your Flutter project to the Firebase project you just created.
- In the VS Code terminal, run the FlutterFire configuration command:
flutterfire configure
What will flutterfire configure ask you?
The CLI will prompt you to select a Firebase project. Choose the SpendSense project you just created.
It will then ask which platforms to configure. Use the arrow keys and spacebar to select Android only (deselect iOS and web if they are pre-selected). Press Enter to confirm.
When it finishes, it generates a firebase_options.dart file in your lib/ directory containing your project's configuration.
You should see a success message and the new lib/firebase_options.dart file in your project.
Seeing a "No Firebase project found" error?
Make sure the Firebase CLI is logged in to the same Google account that owns your SpendSense project. You can verify by running firebase projects:list and checking that SpendSense appears.
help me fix flutterfire configure not finding my project
Add dependencies and verify the app runs
SpendSense uses five key packages: firebase_core for Firebase initialization, firebase_ai for Gemini access, flutter_riverpod for state management, fl_chart for visualizations, and go_router for navigation.
- Add all five dependencies by running this command:
flutter pub add firebase_core firebase_ai flutter_riverpod fl_chart go_router
What does this command do?
The flutter pub add command adds each named package to your pubspec.yaml file and runs flutter pub get to download them. After this, your project has access to Riverpod for state management, fl_chart for charts, and GoRouter for routing.
Next, update main.dart to initialize Firebase when the app starts.
- In VS Code, open lib/main.dart from the Explorer sidebar.
- Select all the existing code and replace it with the following:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
// Ensure Flutter bindings are ready before calling Firebase
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase with the generated config
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SpendSense',
home: Scaffold(
appBar: AppBar(title: const Text('SpendSense')),
body: const Center(child: Text('Hello SpendSense')),
),
);
}
}
What does this code do?
- WidgetsFlutterBinding.ensureInitialized() makes sure the Flutter engine is ready before any async work runs.
- Firebase.initializeApp() connects your app to the Firebase project using the config from firebase_options.dart.
- The MyApp widget renders a minimal scaffold with the app title. This confirms Firebase initializes without errors before you build real screens.
- Save lib/main.dart.
- Run the app on your connected Android device or emulator:
flutter run
You should see the SpendSense app launch on your device with an app bar reading "SpendSense" and the text "Hello SpendSense" in the center of the screen.
App crashes on launch or shows a Firebase error?
- Make sure you ran flutterfire configure from inside the spendsense folder and selected Android as the platform.
- Check that lib/firebase_options.dart exists. If it is missing, re-run flutterfire configure.
- Verify your import line says import 'firebase_options.dart'; (no package: prefix).
help me fix a Firebase initialization error in my Flutter app
✔️ Awesome, I've got everything!
Great. Make sure you have saved lib/main.dart and that the app is running on your device without errors.
ⓧ I'd like to double check the full code
Here is what lib/main.dart should look like:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SpendSense',
home: Scaffold(
appBar: AppBar(title: const Text('SpendSense')),
body: const Center(child: Text('Hello SpendSense')),
),
);
}
}
And here is what the dependencies section of your pubspec.yaml should contain (version numbers may differ slightly):
name: spendsense
description: AI-Powered Smart Expense Tracker
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.0.0
firebase_ai: ^2.0.0
flutter_riverpod: ^2.6.1
fl_chart: ^0.69.0
go_router: ^14.0.0
cupertino_icons: ^1.0.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true
The lib/firebase_options.dart file is auto-generated by FlutterFire CLI and contains your Firebase project configuration for Android. You do not need to edit it manually.
Your environment is wired up and ready. Next up, you will build the AI-powered expense input screen that turns natural language into structured data.
Build the Expense Input with AI Parsing
Your Flutter project is connected to Firebase and running on your device. Now it's time to build the core feature that makes SpendSense genuinely useful.
Instead of filling out tedious forms with dropdowns and date pickers, you'll let users type expenses in plain language. Gemini AI will parse that text into structured data automatically. This step builds the data models, the AI service, and the input screen that ties them together.
In this step, get ready to:
- Define the Expense data model and sealed state classes.
- Build a Gemini AI service that extracts expense fields from natural language.
- Create an input screen that shows AI-parsed results using pattern matching.
Create the expense data models
Every Flutter app needs clear data structures. You'll define an Expense class to hold parsed expense data, and a sealed class hierarchy to represent the different states your AI parsing can be in (idle, loading, success, or error).
- In VS Code's Explorer sidebar, right-click the lib folder inside your spendsense project.
- Select New Folder and name it models.
- Right-click the new models folder and select New File. Name it expense.dart.
- Add the Expense class by pasting the following code into expense.dart:
import 'package:flutter/foundation.dart';
// Data class representing a single parsed expense
class Expense {
final String id;
final double amount;
final String category;
final String merchant;
final DateTime date;
final String originalText;
const Expense({
required this.id,
required this.amount,
required this.category,
required this.merchant,
required this.date,
required this.originalText,
});
}
What does this code do?
The Expense class is a simple data container. Each field holds one piece of information that Gemini will extract from natural language text.
- id gives each expense a unique identifier based on the timestamp.
- originalText preserves what the user originally typed, so you can display it later in history.
- All fields are final because expenses are immutable once parsed.
Now you need state classes to represent what's happening during AI parsing. The sealed class hierarchy gives the compiler full knowledge of every possible state.
- Add the sealed class hierarchy below the Expense class in the same file:
// Sealed class representing all possible states of expense parsing
sealed class ExpenseParseState {}
class ExpenseParseIdle extends ExpenseParseState {}
class ExpenseParseLoading extends ExpenseParseState {}
class ExpenseParseSuccess extends ExpenseParseState {
final Expense expense;
ExpenseParseSuccess(this.expense);
}
class ExpenseParseError extends ExpenseParseState {
final String message;
ExpenseParseError(this.message);
}
What is a sealed class?
A sealed class restricts all its subtypes to the same file. The Dart compiler knows every possible variant, so when you use a switch expression on an ExpenseParseState, it enforces that you handle every case. If you forget one, the code won't compile.
This is safer than using strings or enums for state. Each variant can carry its own data (like the parsed Expense on success or an error message on failure).
- Save expense.dart.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/foundation.dart';
class Expense {
final String id;
final double amount;
final String category;
final String merchant;
final DateTime date;
final String originalText;
const Expense({
required this.id,
required this.amount,
required this.category,
required this.merchant,
required this.date,
required this.originalText,
});
}
sealed class ExpenseParseState {}
class ExpenseParseIdle extends ExpenseParseState {}
class ExpenseParseLoading extends ExpenseParseState {}
class ExpenseParseSuccess extends ExpenseParseState {
final Expense expense;
ExpenseParseSuccess(this.expense);
}
class ExpenseParseError extends ExpenseParseState {
final String message;
ExpenseParseError(this.message);
}
Build the Gemini AI service
The AI service is the brain of SpendSense. It sends the user's natural language text to Firebase AI Logic and gets back structured JSON with the extracted expense fields.
- Right-click the lib folder and create a new folder called services.
- Inside services, create a new file called gemini_service.dart.
- Paste the following code into gemini_service.dart:
import 'dart:convert';
import 'package:firebase_ai/firebase_ai.dart';
import '../models/expense.dart';
class GeminiService {
late final GenerativeModel _model;
GeminiService() {
_model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-3.5-flash',
systemInstruction: Content.system(
'You are an expense parser for Indian users. '
'Extract amount (number), category (Food/Transport/Shopping/Entertainment/Health/Other), '
'merchant (string), and date (ISO8601, default today if not mentioned) '
'from the user text. Return ONLY valid JSON: '
'{"amount": 0.0, "category": "", "merchant": "", "date": ""}',
),
);
}
// Sends natural language text to Gemini and returns a structured Expense
Future<Expense> parseExpense(String text) async {
final response = await _model.generateContent([Content.text(text)]);
final json = jsonDecode(response.text ?? '{}') as Map<String, dynamic>;
return Expense(
id: DateTime.now().millisecondsSinceEpoch.toString(),
amount: (json['amount'] as num).toDouble(),
category: json['category'] as String,
merchant: json['merchant'] as String,
date: DateTime.parse(json['date'] as String),
originalText: text,
);
}
}
What does this service do?
- FirebaseAI.googleAI().generativeModel(model: 'gemini-3.5-flash') creates a connection to the Gemini 3.5 Flash model through Firebase's proxy service. Your API key is handled server-side and never appears in your app code.
- systemInstruction tells Gemini its role: parse Indian expense text and return only valid JSON with specific fields.
- parseExpense() sends the user's text, decodes the JSON response, and constructs a typed Expense object.
💡 Why Firebase AI Logic instead of calling Gemini directly?
Firebase AI Logic acts as a proxy between your app and the Gemini API. The API key never leaves the server, so there's nothing for an attacker to extract from your APK. This is a strong interview talking point about security architecture.
- Save gemini_service.dart.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
import 'dart:convert';
import 'package:firebase_ai/firebase_ai.dart';
import '../models/expense.dart';
class GeminiService {
late final GenerativeModel _model;
GeminiService() {
_model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-3.5-flash',
systemInstruction: Content.system(
'You are an expense parser for Indian users. '
'Extract amount (number), category (Food/Transport/Shopping/Entertainment/Health/Other), '
'merchant (string), and date (ISO8601, default today if not mentioned) '
'from the user text. Return ONLY valid JSON: '
'{"amount": 0.0, "category": "", "merchant": "", "date": ""}',
),
);
}
Future<Expense> parseExpense(String text) async {
final response = await _model.generateContent([Content.text(text)]);
final json = jsonDecode(response.text ?? '{}') as Map<String, dynamic>;
return Expense(
id: DateTime.now().millisecondsSinceEpoch.toString(),
amount: (json['amount'] as num).toDouble(),
category: json['category'] as String,
merchant: json['merchant'] as String,
date: DateTime.parse(json['date'] as String),
originalText: text,
);
}
}
Now you need a Riverpod provider to make the Gemini service accessible across your app. Providers are how Riverpod manages shared state and dependencies.
- Right-click the lib folder and create a new folder called providers.
- Inside providers, create a new file called gemini_provider.dart.
- Paste the following code into gemini_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/gemini_service.dart';
import '../models/expense.dart';
// Provides a single instance of GeminiService throughout the app
final geminiServiceProvider = Provider<GeminiService>((ref) => GeminiService());
// Holds the current state of expense parsing (idle, loading, success, error)
final expenseParseStateProvider =
StateProvider<ExpenseParseState>((ref) => ExpenseParseIdle());
What does this code do?
- geminiServiceProvider creates one GeminiService instance that any widget can access. No manual wiring needed.
- expenseParseStateProvider is a mutable state container. The UI watches it to rebuild whenever the parsing state changes.
- Save gemini_provider.dart.
Seeing red underlines in the provider file?
If VS Code shows import errors, make sure you saved expense.dart and gemini_service.dart first. The provider file depends on both.
help me fix import errors in my Riverpod provider file
Create the expense input screen
The input screen ties everything together. It gives the user a text field, sends their input to Gemini, and uses pattern matching on the sealed state to show the right UI for each scenario.
- Right-click the lib folder and create a new folder called screens.
- Inside screens, create a new file called add_expense_screen.dart.
- Paste the following code into add_expense_screen.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/expense.dart';
import '../providers/gemini_provider.dart';
class AddExpenseScreen extends ConsumerStatefulWidget {
const AddExpenseScreen({super.key});
@override
ConsumerState<AddExpenseScreen> createState() => _AddExpenseScreenState();
}
class _AddExpenseScreenState extends ConsumerState<AddExpenseScreen> {
final _controller = TextEditingController();
// Sends user text to Gemini and updates the parse state
Future<void> _logExpense() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
ref.read(expenseParseStateProvider.notifier).state = ExpenseParseLoading();
try {
final expense = await ref.read(geminiServiceProvider).parseExpense(text);
ref.read(expenseParseStateProvider.notifier).state =
ExpenseParseSuccess(expense);
} catch (e) {
ref.read(expenseParseStateProvider.notifier).state =
ExpenseParseError(e.toString());
}
}
@override
Widget build(BuildContext context) {
final parseState = ref.watch(expenseParseStateProvider);
return Scaffold(
appBar: AppBar(title: const Text('Add Expense')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Describe your expense',
hintText: 'e.g. Spent \u20b9250 on auto to office',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _logExpense,
child: const Text('Log Expense'),
),
const SizedBox(height: 24),
// Pattern match on all possible parse states
switch (parseState) {
ExpenseParseIdle() => const SizedBox.shrink(),
ExpenseParseLoading() =>
const CircularProgressIndicator(),
ExpenseParseSuccess(:final expense) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('\u20b9${expense.amount}',
style: Theme.of(context).textTheme.headlineMedium),
Chip(label: Text(expense.category)),
Text(expense.merchant),
],
),
ExpenseParseError(:final message) =>
Text('Error: $message',
style: const TextStyle(color: Colors.red)),
},
],
),
),
);
}
}
What does this screen do?
- ConsumerStatefulWidget is a Riverpod widget that can read and watch providers while also holding local state (the text controller).
- _logExpense() sets the state to loading, calls Gemini, then sets success or error based on the result.
- The switch (parseState) expression uses Dart's exhaustive pattern matching. Every sealed class variant gets its own UI branch. The compiler will warn you if you miss one.
- On success, the screen shows the extracted amount with ₹, a category chip, and the merchant name.
- Save add_expense_screen.dart.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before the next step.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/expense.dart';
import '../providers/gemini_provider.dart';
class AddExpenseScreen extends ConsumerStatefulWidget {
const AddExpenseScreen({super.key});
@override
ConsumerState<AddExpenseScreen> createState() => _AddExpenseScreenState();
}
class _AddExpenseScreenState extends ConsumerState<AddExpenseScreen> {
final _controller = TextEditingController();
Future<void> _logExpense() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
ref.read(expenseParseStateProvider.notifier).state = ExpenseParseLoading();
try {
final expense = await ref.read(geminiServiceProvider).parseExpense(text);
ref.read(expenseParseStateProvider.notifier).state =
ExpenseParseSuccess(expense);
} catch (e) {
ref.read(expenseParseStateProvider.notifier).state =
ExpenseParseError(e.toString());
}
}
@override
Widget build(BuildContext context) {
final parseState = ref.watch(expenseParseStateProvider);
return Scaffold(
appBar: AppBar(title: const Text('Add Expense')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Describe your expense',
hintText: 'e.g. Spent \u20b9250 on auto to office',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _logExpense,
child: const Text('Log Expense'),
),
const SizedBox(height: 24),
switch (parseState) {
ExpenseParseIdle() => const SizedBox.shrink(),
ExpenseParseLoading() =>
const CircularProgressIndicator(),
ExpenseParseSuccess(:final expense) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('\u20b9${expense.amount}',
style: Theme.of(context).textTheme.headlineMedium),
Chip(label: Text(expense.category)),
Text(expense.merchant),
],
),
ExpenseParseError(:final message) =>
Text('Error: $message',
style: const TextStyle(color: Colors.red)),
},
],
),
),
);
}
}
The last step is to wire the new screen into your app's entry point. You need to wrap the app with Riverpod's ProviderScope and point the home route to the new screen.
- Open lib/main.dart in VS Code.
- Replace the entire contents of the file with the following code:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart';
import 'screens/add_expense_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// ProviderScope makes Riverpod providers available to the entire widget tree
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SpendSense',
home: const AddExpenseScreen(),
);
}
}
What changed in main.dart?
- Added imports for flutter_riverpod and the new AddExpenseScreen.
- ProviderScope wraps the entire app so that any widget can access Riverpod providers.
- The home property now points to AddExpenseScreen instead of the placeholder Scaffold.
- Save main.dart.
- Hot-restart your app by pressing Shift+R in the terminal where flutter run is active (or stop and re-run with flutter run).
- You should see the Add Expense screen with a text field and a Log Expense button.
- Type Spent ₹250 on auto to office into the text field and tap Log Expense.
- A loading spinner appears briefly, then you should see the extracted amount (₹250), a Transport category chip, and the merchant name.
Seeing an error instead of parsed results?
- Check that you enabled the Gemini Developer API in your Firebase console under AI Services > AI Logic. The API must be active for calls to succeed.
- Make sure your device has an internet connection. Firebase AI Logic calls go through Google's servers.
- Verify your Firebase project is on the Spark plan. If you accidentally enabled Blaze billing, the free-tier model access may behave differently.
help me debug my Gemini AI expense parsing error
✔️ Awesome, I've got everything!
Great. Double check you've saved all files before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart';
import 'screens/add_expense_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SpendSense',
home: const AddExpenseScreen(),
);
}
}
Your AI-powered input is live. You can type expenses in plain English and Gemini handles the parsing. Next up, you'll build a visual dashboard that charts your spending patterns with pie and bar charts.
Build the Spending Dashboard
Your app can parse natural language expenses into structured data. Now you need a way to make sense of that data at a glance.
In this step, you'll build a spending dashboard that aggregates all logged expenses into real-time visualizations. A pie chart shows where your money goes by category, a bar chart reveals your daily spending pattern, and a velocity card tells you whether you're on track for your monthly budget.
In this step, get ready to:
- Create a Riverpod Notifier that holds expenses and computes budget metrics.
- Build a dashboard screen with a pie chart and bar chart using fl_chart.
- Add a budget velocity indicator showing daily spend pace vs. monthly target.
Create the expense state manager
Before you can visualize spending, you need a central place to store all logged expenses and compute aggregate values like totals, category breakdowns, and daily averages.
- Create a new file at lib/providers/expense_provider.dart by right-clicking the providers folder in VS Code's Explorer sidebar and selecting New File.
- Add the ExpenseNotifier class by pasting the following code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/expense.dart';
class ExpenseNotifier extends Notifier<List<Expense>> {
@override
List<Expense> build() => [];
void addExpense(Expense expense) {
state = [...state, expense];
}
double get totalSpent =>
state.fold(0.0, (sum, e) => sum + e.amount);
Map<String, double> get categoryBreakdown {
final map = <String, double>{};
for (final e in state) {
map[e.category] = (map[e.category] ?? 0) + e.amount;
}
return map;
}
Map<DateTime, double> get dailySpendingThisWeek {
final map = <DateTime, double>{};
final now = DateTime.now();
for (var i = 6; i >= 0; i--) {
final day = DateTime(now.year, now.month, now.day - i);
map[day] = 0;
}
for (final e in state) {
final day = DateTime(e.date.year, e.date.month, e.date.day);
if (map.containsKey(day)) {
map[day] = (map[day] ?? 0) + e.amount;
}
}
return map;
}
double get budgetVelocity {
final now = DateTime.now();
final dayOfMonth = now.day;
if (dayOfMonth == 0) return 0;
return totalSpent / dayOfMonth;
}
}
What does this code do?
The ExpenseNotifier is a Riverpod Notifier that manages a list of Expense objects as its state.
- build() returns an empty list as the initial state.
- addExpense() creates a new list with the added expense (immutable state updates).
- totalSpent sums all expense amounts using fold.
- categoryBreakdown groups expenses by category and sums each category's total.
- dailySpendingThisWeek pre-fills the last 7 days with zeros, then adds matching expense amounts.
- budgetVelocity divides total spending by the current day of the month to get a daily average.
- Now add the provider declarations below the class in the same file:
final expenseNotifierProvider =
NotifierProvider<ExpenseNotifier, List<Expense>>(ExpenseNotifier.new);
final totalSpentProvider = Provider<double>((ref) {
return ref.watch(expenseNotifierProvider.notifier).totalSpent;
});
final categoryBreakdownProvider = Provider<Map<String, double>>((ref) {
ref.watch(expenseNotifierProvider);
return ref.read(expenseNotifierProvider.notifier).categoryBreakdown;
});
final dailySpendingProvider = Provider<Map<DateTime, double>>((ref) {
ref.watch(expenseNotifierProvider);
return ref.read(expenseNotifierProvider.notifier).dailySpendingThisWeek;
});
final budgetVelocityProvider = Provider<double>((ref) {
ref.watch(expenseNotifierProvider);
return ref.read(expenseNotifierProvider.notifier).budgetVelocity;
});
Why separate providers for each computed value?
Each provider watches expenseNotifierProvider so it recalculates whenever the expense list changes. This lets different widgets subscribe to only the data they need. The pie chart watches categoryBreakdownProvider while the velocity card watches budgetVelocityProvider.
Using ref.watch triggers a rebuild when the expense list changes, and ref.read then grabs the freshly computed value from the notifier.
- Save lib/providers/expense_provider.dart.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/expense.dart';
class ExpenseNotifier extends Notifier<List<Expense>> {
@override
List<Expense> build() => [];
void addExpense(Expense expense) {
state = [...state, expense];
}
double get totalSpent =>
state.fold(0.0, (sum, e) => sum + e.amount);
Map<String, double> get categoryBreakdown {
final map = <String, double>{};
for (final e in state) {
map[e.category] = (map[e.category] ?? 0) + e.amount;
}
return map;
}
Map<DateTime, double> get dailySpendingThisWeek {
final map = <DateTime, double>{};
final now = DateTime.now();
for (var i = 6; i >= 0; i--) {
final day = DateTime(now.year, now.month, now.day - i);
map[day] = 0;
}
for (final e in state) {
final day = DateTime(e.date.year, e.date.month, e.date.day);
if (map.containsKey(day)) {
map[day] = (map[day] ?? 0) + e.amount;
}
}
return map;
}
double get budgetVelocity {
final now = DateTime.now();
final dayOfMonth = now.day;
if (dayOfMonth == 0) return 0;
return totalSpent / dayOfMonth;
}
}
final expenseNotifierProvider =
NotifierProvider<ExpenseNotifier, List<Expense>>(ExpenseNotifier.new);
final totalSpentProvider = Provider<double>((ref) {
return ref.watch(expenseNotifierProvider.notifier).totalSpent;
});
final categoryBreakdownProvider = Provider<Map<String, double>>((ref) {
ref.watch(expenseNotifierProvider);
return ref.read(expenseNotifierProvider.notifier).categoryBreakdown;
});
final dailySpendingProvider = Provider<Map<DateTime, double>>((ref) {
ref.watch(expenseNotifierProvider);
return ref.read(expenseNotifierProvider.notifier).dailySpendingThisWeek;
});
final budgetVelocityProvider = Provider<double>((ref) {
ref.watch(expenseNotifierProvider);
return ref.read(expenseNotifierProvider.notifier).budgetVelocity;
});
Next, connect the expense provider to the add expense screen so that parsed expenses actually get stored.
- In lib/screens/add_expense_screen.dart, add this import at the top with the other imports:
import '../providers/expense_provider.dart';
- In the _logExpense method, find this line inside the try block:
ref.read(expenseParseStateProvider.notifier).state =
ExpenseParseSuccess(expense);
- Add this line directly below it to save the expense to the notifier:
ref.read(expenseNotifierProvider.notifier).addExpense(expense);
What does this line do?
After Gemini successfully parses the expense, this line stores it in the ExpenseNotifier. Every provider that watches the expense list (total, category breakdown, daily spending, velocity) will automatically recalculate.
- Save lib/screens/add_expense_screen.dart.
✔️ Awesome, I've got everything!
Great. The add expense screen now stores parsed expenses in Riverpod state.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/expense.dart';
import '../providers/gemini_provider.dart';
import '../providers/expense_provider.dart';
class AddExpenseScreen extends ConsumerStatefulWidget {
const AddExpenseScreen({super.key});
@override
ConsumerState<AddExpenseScreen> createState() => _AddExpenseScreenState();
}
class _AddExpenseScreenState extends ConsumerState<AddExpenseScreen> {
final _controller = TextEditingController();
Future<void> _logExpense() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
ref.read(expenseParseStateProvider.notifier).state = ExpenseParseLoading();
try {
final expense = await ref.read(geminiServiceProvider).parseExpense(text);
ref.read(expenseParseStateProvider.notifier).state =
ExpenseParseSuccess(expense);
ref.read(expenseNotifierProvider.notifier).addExpense(expense);
} catch (e) {
ref.read(expenseParseStateProvider.notifier).state =
ExpenseParseError(e.toString());
}
}
@override
Widget build(BuildContext context) {
final parseState = ref.watch(expenseParseStateProvider);
return Scaffold(
appBar: AppBar(title: const Text('Add Expense')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Describe your expense',
hintText: 'e.g. Spent \u20b9250 on auto to office',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _logExpense,
child: const Text('Log Expense'),
),
const SizedBox(height: 24),
switch (parseState) {
ExpenseParseIdle() => const SizedBox.shrink(),
ExpenseParseLoading() => const CircularProgressIndicator(),
ExpenseParseSuccess(:final expense) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('\u20b9${expense.amount}',
style: Theme.of(context).textTheme.headlineMedium),
Chip(label: Text(expense.category)),
Text(expense.merchant),
],
),
ExpenseParseError(:final message) =>
Text('Error: $message',
style: const TextStyle(color: Colors.red)),
},
],
),
),
);
}
}
Seeing a compile error?
Make sure the import for expense_provider.dart is at the top of add_expense_screen.dart. If you see an error about expenseNotifierProvider being undefined, the import is missing or the file path is incorrect.
help me fix the compile error in add_expense_screen.dart
Build the dashboard screen with charts
With the expense state manager in place, you can now build a visual dashboard that reads from those providers and renders charts.
- Create a new file at lib/screens/dashboard_screen.dart by right-clicking the screens folder and selecting New File.
- Add the dashboard screen with pie chart and bar chart by pasting the following code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fl_chart/fl_chart.dart';
import '../providers/expense_provider.dart';
const _monthlyBudget = 30000.0;
const _categoryColors = {
'Food': Colors.orange,
'Transport': Colors.blue,
'Shopping': Colors.purple,
'Entertainment': Colors.pink,
'Health': Colors.green,
'Other': Colors.grey,
};
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final velocity = ref.watch(budgetVelocityProvider);
final totalSpent = ref.watch(totalSpentProvider);
final categoryBreakdown = ref.watch(categoryBreakdownProvider);
final dailySpending = ref.watch(dailySpendingProvider);
Color velocityColor;
if (velocity < 1000) {
velocityColor = Colors.green;
} else if (velocity <= 1500) {
velocityColor = Colors.yellow;
} else {
velocityColor = Colors.red;
}
// Build pie chart sections from category breakdown
final pieChartSections = categoryBreakdown.entries.map((entry) {
return PieChartSectionData(
value: entry.value,
title: entry.key,
color: _categoryColors[entry.key] ?? Colors.grey,
radius: 80,
);
}).toList();
// Build bar chart groups for daily spending
final dailyEntries = dailySpending.entries.toList();
final barGroups = List.generate(dailyEntries.length, (i) {
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: dailyEntries[i].value,
color: Colors.tealAccent,
width: 16,
),
],
);
});
return Scaffold(
appBar: AppBar(title: const Text('Dashboard')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Budget velocity card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Budget Velocity',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'\u20b9${velocity.toStringAsFixed(0)}/day',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: velocityColor),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: (totalSpent / _monthlyBudget).clamp(0.0, 1.0),
color: velocityColor,
backgroundColor: Colors.grey.shade800,
),
const SizedBox(height: 4),
Text(
'\u20b9${totalSpent.toStringAsFixed(0)} of \u20b9${_monthlyBudget.toStringAsFixed(0)} monthly budget'),
],
),
),
),
const SizedBox(height: 24),
// Pie chart for category breakdown
Text('Spending by Category',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 220,
child: pieChartSections.isEmpty
? const Center(child: Text('No expenses yet'))
: PieChart(PieChartData(sections: pieChartSections)),
),
const SizedBox(height: 24),
// Bar chart for daily spending
Text('Daily Spending (Last 7 Days)',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 200,
child: BarChart(
BarChartData(
barGroups: barGroups,
borderData: FlBorderData(show: false),
gridData: const FlGridData(show: false),
),
),
),
],
),
),
);
}
}
What does this code do?
This screen is a ConsumerWidget that watches all four expense providers and rebuilds whenever expenses change.
- _categoryColors maps each expense category to a distinct color for the pie chart.
- The PieChart receives a list of PieChartSectionData objects, each with a value, title, color, and radius. The fl_chart library automatically sizes each slice proportionally.
- The BarChart uses BarChartGroupData with a single BarChartRodData per day, plotting 7 bars for the past week.
- The velocity card at the top shows daily average spending with a color-coded label and a LinearProgressIndicator showing how much of the monthly budget has been consumed.
- Save lib/screens/dashboard_screen.dart.
Now update main.dart to show the dashboard as the home screen.
- In lib/main.dart, add this import at the top with the other imports:
import 'screens/dashboard_screen.dart';
- In the build method of MyApp, find this line:
home: const AddExpenseScreen(),
- Replace it with:
home: const DashboardScreen(),
- Save lib/main.dart.
- Hot restart your app (press Shift+R in the terminal where flutter run is active).
You should see the Dashboard screen with a Budget Velocity card showing ₹0/day (green), a "No expenses yet" message where the pie chart will go, and an empty bar chart.
✔️ Awesome, I've got everything!
Great. Your dashboard is rendering. Next you'll verify it updates with real data.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart';
import 'screens/dashboard_screen.dart';
import 'screens/add_expense_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SpendSense',
home: const DashboardScreen(),
);
}
}
Don't see the Dashboard screen?
Make sure you used Shift+R (hot restart) and not just r (hot reload). Changing the home widget requires a full restart. Also check that the dashboard_screen.dart import in main.dart matches the file path exactly.
help me debug why the DashboardScreen is not showing
Test the dashboard with live expenses
The dashboard is rendering, but it's empty because the app now opens to the Dashboard instead of the Add Expense screen. You'll use hot reload to test the full flow without needing navigation (that comes in the next step).
- In lib/main.dart, temporarily change the home back to test adding expenses:
home: const AddExpenseScreen(),
- Hot restart the app with Shift+R.
- Add 3-4 test expenses across different categories. For example:
- Type "Spent ₹250 on auto to office" and tap Log Expense.
- Type "₹450 biryani from Swiggy" and tap Log Expense.
- Type "Bought headphones for ₹1200 on Flipkart" and tap Log Expense.
- Now switch back to the Dashboard. In lib/main.dart, change home back to:
home: const DashboardScreen(),
- This time use r (hot reload, not restart) to preserve the expenses you just added.
You should see the pie chart with colored slices for Transport, Food, and Shopping. The bar chart shows today's total spending as a teal bar. The velocity card displays your daily average in green (under ₹1,000/day).
Why hot reload preserves state
Hot reload (r) injects updated code without resetting Riverpod's in-memory state. Your expenses survive the screen swap because they live in the ExpenseNotifier provider, not in a widget's local state. Hot restart (Shift+R) resets everything.
- Leave main.dart with home: const DashboardScreen() and save the file.
Pie chart still says 'No expenses yet'?
If you used hot restart (Shift+R) instead of hot reload (r), your in-memory expenses were cleared. Go back to AddExpenseScreen by temporarily swapping home again, add expenses, then use only r to switch back.
Also check that the addExpense line was added correctly in add_expense_screen.dart. Without it, parsed expenses are never stored.
help me figure out why my dashboard charts are empty
Your spending dashboard is live. In the next step, you'll add a transaction history screen and wire up proper navigation between all three screens so you don't need to swap code to switch views.
Build Transaction History and Navigation
You now have a working dashboard that visualizes spending and an AI-powered input screen that parses expenses. The missing piece is the ability to review past expenses and navigate between screens without manually swapping code.
In this step, you will build a transaction history screen with filtering, wire up a bottom navigation bar using GoRouter, and apply a polished Material 3 dark theme across the entire app.
In this step, get ready to:
- Build a transaction history screen with category filter chips and expense cards.
- Set up GoRouter with a ShellRoute that provides a BottomNavigationBar.
- Apply a Material 3 dark theme with a teal/green accent.
Create the transaction history screen
The history screen gives users a scrollable list of every logged expense, plus filter chips to narrow results by category.
- Create a new file at lib/screens/history_screen.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/expense.dart';
import '../providers/expense_provider.dart';
// Available categories for filtering
const _categories = ['All', 'Food', 'Transport', 'Shopping', 'Entertainment', 'Health', 'Other'];
// Map each category to a recognizable icon
const _categoryIcons = {
'Food': Icons.restaurant,
'Transport': Icons.directions_car,
'Shopping': Icons.shopping_bag,
'Entertainment': Icons.movie,
'Health': Icons.health_and_safety,
'Other': Icons.category,
};
class HistoryScreen extends ConsumerStatefulWidget {
const HistoryScreen({super.key});
@override
ConsumerState<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends ConsumerState<HistoryScreen> {
// Tracks which category filter chip is selected
String _selectedCategory = 'All';
@override
Widget build(BuildContext context) {
final expenses = ref.watch(expenseNotifierProvider);
// Filter expenses based on the selected category chip
final filtered = _selectedCategory == 'All'
? expenses
: expenses.where((e) => e.category == _selectedCategory).toList();
return Scaffold(
appBar: AppBar(title: const Text('History')),
body: Column(
children: [
// Horizontal scrollable row of filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: _categories.map((cat) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(cat),
selected: _selectedCategory == cat,
onSelected: (_) => setState(() => _selectedCategory = cat),
),
);
}).toList(),
),
),
// Expense list showing each transaction as a ListTile
Expanded(
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final expense = filtered[index];
return ListTile(
leading: Icon(
_categoryIcons[expense.category] ?? Icons.category),
title: Text(expense.merchant),
subtitle: Text(expense.originalText),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('\u20b9${expense.amount.toStringAsFixed(0)}',
style: const TextStyle(fontWeight: FontWeight.bold)),
Text(
'${expense.date.day}/${expense.date.month}/${expense.date.year}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
},
),
),
],
),
);
}
}
What does this code do?
The _categories list defines filter options. The _categoryIcons map gives each category a visual icon for the leading edge of each list tile.
Inside the build method, ref.watch(expenseNotifierProvider) subscribes to the expense list. When a filter chip is tapped, setState triggers a rebuild and the filtered variable narrows the displayed items.
Each ListTile shows the merchant name, original text, amount in rupees, and date. This gives users full context on each expense at a glance.
- Save lib/screens/history_screen.dart.
Seeing a red underline on the imports?
Make sure your file is saved in the correct folder path. The import ../providers/expense_provider.dart resolves relative to the screens/ directory.
help me fix import errors in my history_screen.dart file
Set up GoRouter with a ShellRoute
Right now there is no way to switch between the Dashboard, Add Expense, and History screens. A ShellRoute wraps all three screens with a persistent bottom navigation bar so the nav stays visible regardless of which screen is active.
- Create a new file at lib/router.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'screens/dashboard_screen.dart';
import 'screens/add_expense_screen.dart';
import 'screens/history_screen.dart';
// Top-level router configuration with ShellRoute for persistent nav
final router = GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) {
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
path: '/add',
builder: (context, state) => const AddExpenseScreen(),
),
GoRoute(
path: '/history',
builder: (context, state) => const HistoryScreen(),
),
],
),
],
);
// Shell widget that renders the BottomNavigationBar around child screens
class ScaffoldWithNavBar extends StatelessWidget {
final Widget child;
const ScaffoldWithNavBar({super.key, required this.child});
@override
Widget build(BuildContext context) {
// Read the current route URI to highlight the correct tab
final location = GoRouterState.of(context).uri.toString();
int currentIndex = 0;
if (location.startsWith('/add')) currentIndex = 1;
if (location.startsWith('/history')) currentIndex = 2;
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) {
switch (index) {
case 0: context.go('/'); break;
case 1: context.go('/add'); break;
case 2: context.go('/history'); break;
}
},
destinations: const [
NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'),
NavigationDestination(icon: Icon(Icons.add_circle), label: 'Add'),
NavigationDestination(icon: Icon(Icons.history), label: 'History'),
],
),
);
}
}
What does this code do?
The GoRouter declares three routes wrapped inside a ShellRoute. The shell's builder returns ScaffoldWithNavBar, which stays on screen while the child (the matched route's screen) swaps in and out.
Inside ScaffoldWithNavBar, GoRouterState.of(context).uri.toString() reads the current path. This determines which navigation tab to highlight. Tapping a tab calls context.go() to navigate declaratively.
- Save lib/router.dart.
Why GoRouter over Navigator 2.0?
GoRouter is a first-party package published by the Flutter team. It simplifies declarative routing and provides a clean ShellRoute API for persistent navigation shells like bottom bars.
Using raw Navigator 2.0 would require significantly more boilerplate for the same result. In interviews, choosing GoRouter shows you know the ecosystem's preferred patterns.
Apply Material 3 dark theme and connect the router
The app currently uses the default Flutter theme and a basic MaterialApp with a home property. You need to switch to MaterialApp.router so GoRouter controls navigation, and define a custom dark color scheme.
- Open lib/main.dart and replace the entire contents with the following code:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart';
import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'SpendSense',
routerConfig: router,
// Material 3 dark theme with teal/green fintech accent
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF00BFA5),
brightness: Brightness.dark,
),
),
);
}
}
What changed here?
The old screen imports are replaced with a single import 'router.dart' since GoRouter now owns screen routing.
MaterialApp.router replaces MaterialApp so that GoRouter's routerConfig drives all navigation. The home property is removed because the router's initialLocation handles the start screen.
ColorScheme.fromSeed with 0xFF00BFA5 (a teal-green) generates a harmonious dark palette. This gives SpendSense a polished fintech look that carries across every screen automatically.
- Save lib/main.dart.
- In your terminal, run flutter run (or hot-restart if the app is already running).
- You should see a dark-themed app with a bottom navigation bar showing three tabs: Dashboard, Add, and History.
- Tap each tab to confirm navigation works. The bar stays visible across all screens.
App crashes on launch?
Check that lib/router.dart imports all three screens correctly. A missing import will cause a compile error.
Verify that lib/main.dart no longer imports screens/dashboard_screen.dart or screens/add_expense_screen.dart. Those are now handled by the router.
help me fix a crash in my SpendSense app after adding GoRouter
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files.
ⓧ I'd like to double check the full code
Here is the full code for each file at this point:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart';
import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'SpendSense',
routerConfig: router,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF00BFA5),
brightness: Brightness.dark,
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'screens/dashboard_screen.dart';
import 'screens/add_expense_screen.dart';
import 'screens/history_screen.dart';
final router = GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) {
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
path: '/add',
builder: (context, state) => const AddExpenseScreen(),
),
GoRoute(
path: '/history',
builder: (context, state) => const HistoryScreen(),
),
],
),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
final Widget child;
const ScaffoldWithNavBar({super.key, required this.child});
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
int currentIndex = 0;
if (location.startsWith('/add')) currentIndex = 1;
if (location.startsWith('/history')) currentIndex = 2;
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) {
switch (index) {
case 0: context.go('/'); break;
case 1: context.go('/add'); break;
case 2: context.go('/history'); break;
}
},
destinations: const [
NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'),
NavigationDestination(icon: Icon(Icons.add_circle), label: 'Add'),
NavigationDestination(icon: Icon(Icons.history), label: 'History'),
],
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/expense.dart';
import '../providers/expense_provider.dart';
const _categories = ['All', 'Food', 'Transport', 'Shopping', 'Entertainment', 'Health', 'Other'];
const _categoryIcons = {
'Food': Icons.restaurant,
'Transport': Icons.directions_car,
'Shopping': Icons.shopping_bag,
'Entertainment': Icons.movie,
'Health': Icons.health_and_safety,
'Other': Icons.category,
};
class HistoryScreen extends ConsumerStatefulWidget {
const HistoryScreen({super.key});
@override
ConsumerState<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends ConsumerState<HistoryScreen> {
String _selectedCategory = 'All';
@override
Widget build(BuildContext context) {
final expenses = ref.watch(expenseNotifierProvider);
final filtered = _selectedCategory == 'All'
? expenses
: expenses.where((e) => e.category == _selectedCategory).toList();
return Scaffold(
appBar: AppBar(title: const Text('History')),
body: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: _categories.map((cat) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(cat),
selected: _selectedCategory == cat,
onSelected: (_) => setState(() => _selectedCategory = cat),
),
);
}).toList(),
),
),
Expanded(
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final expense = filtered[index];
return ListTile(
leading: Icon(
_categoryIcons[expense.category] ?? Icons.category),
title: Text(expense.merchant),
subtitle: Text(expense.originalText),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('\u20b9${expense.amount.toStringAsFixed(0)}',
style: const TextStyle(fontWeight: FontWeight.bold)),
Text(
'${expense.date.day}/${expense.date.month}/${expense.date.year}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
},
),
),
],
),
);
}
}
Your app now has full navigation and a complete transaction history. Next up, you will tackle the Secret Mission and add expense splitting with friends.
Secret mission
Social Expense Splitting
Your expense tracker works great for solo spending. But what about dinner with friends, shared cabs, or splitting that biryani order? Add a split feature that calculates per-person shares and lets you send payment requests through WhatsApp, UPI apps, or any messaging tool on your phone.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on the Firebase Spark plan and your local machine. There are no ongoing costs or metered resources that will surprise you with a bill.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Firebase project (Spark plan, no billing attached).
- Gemini Developer API access (managed by Firebase, no key in your code).
- Local spendsense project folder on your machine.
Keep everything running
No action needed. Choose this if you're still actively building or want to revisit SpendSense later.
- Your Firebase project stays on the free Spark plan indefinitely. No charges will accrue.
- The Gemini Developer API access remains available at no cost through your Firebase project.
- Your local project files stay on your machine, ready to open in VS Code whenever you want.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and Firebase project remain intact for when you return.
- Press Ctrl+C in any terminal running flutter run to stop the dev server.
- Close the Android emulator if you have one running.
- Your Firebase project and local files stay exactly as they are. No cleanup needed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
Delete your Firebase project:
- Open the Firebase Console at console.firebase.google.com.
- Select your SpendSense project.
- Click the gear icon next to Project Overview in the left sidebar, then select Project settings.
- Scroll to the bottom of the General tab and click Delete project.
- Check the acknowledgement boxes and confirm deletion.
What about the Gemini API key?
Firebase AI Logic manages the Gemini API key server-side. Deleting the Firebase project removes your API access automatically. There is no separate key to revoke.
Delete your local project files:
- Stop any running Flutter processes with Ctrl+C in the terminal.
- Remove the project folder by running:
cd ~/Desktop
rm -rf spendsense
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see spendsense in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-ready AI-powered expense tracker from scratch using Flutter and Firebase.
SpendSense is now running on your Android device, parsing natural language expenses with Gemini AI and visualizing your spending in real time.
You've learned how to:
- Integrate Gemini 3.5 Flash via Firebase AI Logic to parse natural language expense descriptions into structured data with amount, category, merchant, and date.
- Implement Riverpod state management with sealed classes and exhaustive pattern matching for type-safe UI state handling.
- Build interactive spending visualizations using fl_chart (pie chart and bar chart) alongside a budget velocity algorithm that calculates real-time daily spending pace against a monthly target.
- Add social expense splitting with per-person amount calculation and system share sheet integration using Flutter's Share API (Secret Mission).
Ready to quiz yourself?