Skip to content

flutter_building_complete_application

In this chapter, we’ll consolidate many of the concepts learned previously to build a more complete application: an Expense Tracker. This application will allow users to log their expenses, view a list of expenses, see the total amount spent, and potentially edit/delete entries.

We will utilize:

  • Core Flutter Widgets (Scaffold, ListView, ListTile, FloatingActionButton, Form, TextFormField).
  • sqflite for local data persistence.
  • provider for state management.
  • Date formatting using the intl package.
  • Basic form validation.
  1. Create a new Flutter project: flutter create expense_tracker_app

  2. Add dependencies to pubspec.yaml (use latest versions):

  3. Run flutter pub get.

Define the Expense class.

import 'package:intl/intl.dart';
class Expense {
final int? id; // Nullable for inserts
final double amount;
final DateTime date;
final String category;
Expense({
this.id,
required this.amount,
required this.date,
required this.category,
});
// Helper getter for formatted date
String get formattedDate {
return DateFormat('yyyy-MM-dd').format(date);
}
// Convert Expense object to Map for sqflite
Map<String, dynamic> toMap() {
return {
'id': id,
'amount': amount,
// Store date as ISO8601 string in DB
'date': date.toIso8601String(),
'category': category,
};
}
// Create Expense object from Map
factory Expense.fromMap(Map<String, dynamic> map) {
return Expense(
id: map['id'] as int?,
amount: map['amount'] as double,
// Parse ISO8601 string back to DateTime
date: DateTime.parse(map['date'] as String),
category: map['category'] as String,
);
}
}

2. Database Helper (lib/database_helper.dart)

Section titled “2. Database Helper (lib/database_helper.dart)”

Create a singleton DatabaseHelper similar to the one in the Data Persistence chapter, but adapted for the Expense model and table.

import 'dart:async';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'expense.dart';
class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
factory DatabaseHelper() => _instance;
static Database? _database;
DatabaseHelper._internal();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDb();
return _database!;
}
Future<Database> _initDb() async {
String path = join(await getDatabasesPath(), 'expense_database.db');
return await openDatabase(
path,
version: 1,
onCreate: _onCreate,
);
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL,
date TEXT NOT NULL,
category TEXT NOT NULL
)
''');
// Optional: Insert sample data
await db.insert('expenses', {'amount': 15.50, 'date': DateTime.now().toIso8601String(), 'category': 'Food'});
}
Future<int> insertExpense(Expense expense) async {
final db = await database;
Map<String, dynamic> row = expense.toMap();
row.remove('id'); // Let SQLite handle auto-increment
return await db.insert('expenses', row);
}
Future<List<Expense>> getAllExpenses() async {
final db = await database;
// Order by date descending (most recent first)
final List<Map<String, dynamic>> maps = await db.query('expenses', orderBy: 'date DESC');
return List.generate(maps.length, (i) => Expense.fromMap(maps[i]));
}
Future<int> updateExpense(Expense expense) async {
final db = await database;
return await db.update('expenses', expense.toMap(), where: 'id = ?', whereArgs: [expense.id]);
}
Future<int> deleteExpense(int id) async {
final db = await database;
return await db.delete('expenses', where: 'id = ?', whereArgs: [id]);
}
// Optional: Method to calculate total expense
Future<double> getTotalExpenses() async {
final db = await database;
final result = await db.rawQuery('SELECT SUM(amount) as total FROM expenses');
double total = result.first['total'] != null ? (result.first['total'] as num).toDouble() : 0.0;
return total;
}
}

3. State Management with Provider (lib/expense_provider.dart)

Section titled “3. State Management with Provider (lib/expense_provider.dart)”

Create a ChangeNotifier to manage the list of expenses and notify listeners of changes.

import 'package:flutter/foundation.dart';
import 'database_helper.dart';
import 'expense.dart';
class ExpenseProvider with ChangeNotifier {
List<Expense> _expenses = [];
double _totalExpenses = 0.0;
final DatabaseHelper _dbHelper = DatabaseHelper();
List<Expense> get expenses => _expenses;
double get totalExpenses => _totalExpenses;
ExpenseProvider() {
loadExpenses(); // Load initially
}
Future<void> loadExpenses() async {
_expenses = await _dbHelper.getAllExpenses();
_totalExpenses = await _dbHelper.getTotalExpenses();
notifyListeners(); // Notify UI to rebuild
}
Future<void> addExpense(Expense expense) async {
await _dbHelper.insertExpense(expense);
await loadExpenses(); // Reload data and notify
}
Future<void> updateExpense(Expense expense) async {
await _dbHelper.updateExpense(expense);
await loadExpenses(); // Reload data and notify
}
Future<void> deleteExpense(int id) async {
await _dbHelper.deleteExpense(id);
await loadExpenses(); // Reload data and notify
}
}

Set up the root widget and provide the ExpenseProvider.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'expense_provider.dart';
import 'screens/expense_list_screen.dart'; // Create this screen next
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Provide the ExpenseProvider to the widget tree
return ChangeNotifierProvider(
create: (context) => ExpenseProvider(),
child: MaterialApp(
title: 'Expense Tracker',
theme: ThemeData(
primarySwatch: Colors.purple,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: ExpenseListScreen(), // Set the home screen
),
);
}
}

5. Expense List Screen (lib/screens/expense_list_screen.dart)

Section titled “5. Expense List Screen (lib/screens/expense_list_screen.dart)”

Display the list of expenses and the total.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../expense_provider.dart';
import '../expense.dart';
import 'expense_form_screen.dart'; // Create this screen next
import 'package:intl/intl.dart';
class ExpenseListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Access the provider
final expenseProvider = Provider.of<ExpenseProvider>(context);
final expenses = expenseProvider.expenses;
final total = expenseProvider.totalExpenses;
final currencyFormat = NumberFormat.currency(locale: 'en_US', symbol: '\$');
return Scaffold(
appBar: AppBar(
title: Text('Expense Tracker (${currencyFormat.format(total)})'),
),
body: expenses.isEmpty
? Center(child: Text('No expenses yet. Add some!'))
: ListView.builder(
itemCount: expenses.length,
itemBuilder: (ctx, index) {
final expense = expenses[index];
return Dismissible(
key: ValueKey(expense.id), // Unique key for dismissal
background: Container(
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 20),
margin: EdgeInsets.symmetric(horizontal: 15, vertical: 4),
child: Icon(Icons.delete, color: Colors.white, size: 40),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) {
return showDialog( // Ask for confirmation
context: context,
builder: (ctx) => AlertDialog(
title: Text('Are you sure?'),
content: Text('Do you want to remove this expense?'),
actions: <Widget>[
TextButton(child: Text('No'), onPressed: () => Navigator.of(ctx).pop(false)),
TextButton(child: Text('Yes'), onPressed: () => Navigator.of(ctx).pop(true)),
],
),
);
},
onDismissed: (direction) {
if (expense.id != null) {
Provider.of<ExpenseProvider>(context, listen: false)
.deleteExpense(expense.id!);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${expense.category} expense deleted.')),
);
}
},
child: Card(
margin: EdgeInsets.symmetric(horizontal: 15, vertical: 4),
child: ListTile(
leading: CircleAvatar(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: FittedBox(child: Text(currencyFormat.format(expense.amount))),
),
),
title: Text(expense.category,
style: Theme.of(context).textTheme.titleMedium),
subtitle: Text(expense.formattedDate),
onTap: () { // Navigate to edit screen
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ExpenseFormScreen(expense: expense),
),
);
},
),
),
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
// Pass null for adding a new expense
builder: (context) => ExpenseFormScreen(expense: null),
),
);
},
),
);
}
}

6. Expense Form Screen (lib/screens/expense_form_screen.dart)

Section titled “6. Expense Form Screen (lib/screens/expense_form_screen.dart)”

A StatefulWidget to handle user input for adding/editing expenses.

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../expense.dart';
import '../expense_provider.dart';
class ExpenseFormScreen extends StatefulWidget {
final Expense? expense; // Null if adding, non-null if editing
ExpenseFormScreen({this.expense});
@override
_ExpenseFormScreenState createState() => _ExpenseFormScreenState();
}
class _ExpenseFormScreenState extends State<ExpenseFormScreen> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _amountController;
late TextEditingController _categoryController;
late DateTime _selectedDate;
bool _isEditing = false;
@override
void initState() {
super.initState();
_isEditing = widget.expense != null;
_amountController = TextEditingController(
text: _isEditing ? widget.expense!.amount.toString() : '');
_categoryController = TextEditingController(
text: _isEditing ? widget.expense!.category : '');
_selectedDate = _isEditing ? widget.expense!.date : DateTime.now();
}
@override
void dispose() {
_amountController.dispose();
_categoryController.dispose();
super.dispose();
}
Future<void> _presentDatePicker() async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020), // Adjust range as needed
lastDate: DateTime.now(),
);
if (pickedDate != null && pickedDate != _selectedDate) {
setState(() {
_selectedDate = pickedDate;
});
}
}
void _submitData() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); // Calls onSaved for each field
final enteredAmount = double.tryParse(_amountController.text);
final enteredCategory = _categoryController.text;
if (enteredAmount == null || enteredAmount <= 0 || enteredCategory.isEmpty) {
// Basic validation check (already covered by validator, but good practice)
return;
}
final expenseProvider = Provider.of<ExpenseProvider>(context, listen: false);
final newOrUpdatedExpense = Expense(
id: _isEditing ? widget.expense!.id : null, // Keep id if editing
amount: enteredAmount,
date: _selectedDate,
category: enteredCategory,
);
if (_isEditing) {
expenseProvider.updateExpense(newOrUpdatedExpense);
} else {
expenseProvider.addExpense(newOrUpdatedExpense);
}
Navigator.of(context).pop(); // Go back after saving
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Edit Expense' : 'Add Expense'),
actions: [
IconButton(icon: Icon(Icons.save), onPressed: _submitData),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
TextFormField(
controller: _amountController,
decoration: InputDecoration(labelText: 'Amount'),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount.';
}
if (double.tryParse(value) == null || double.parse(value) <= 0) {
return 'Please enter a valid positive amount.';
}
return null;
},
),
TextFormField(
controller: _categoryController,
decoration: InputDecoration(labelText: 'Category'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a category.';
}
return null;
},
),
SizedBox(height: 20),
Row(
children: <Widget>[
Expanded(
child: Text(
'Date: ${DateFormat('yyyy-MM-dd').format(_selectedDate)}',
),
),
TextButton(
onPressed: _presentDatePicker,
child: Text('Choose Date'),
),
],
),
SizedBox(height: 30),
ElevatedButton(
child: Text(_isEditing ? 'Update Expense' : 'Add Expense'),
onPressed: _submitData,
),
],
),
),
),
);
}
}

Run the application (flutter run). You should now be able to:

  • See the initial list of expenses (including sample data if added).
  • See the total expense in the AppBar.
  • Tap the ’+’ button to navigate to the form and add a new expense.
  • Tap an existing expense in the list to navigate to the form and edit it.
  • Swipe an expense from right to left to delete it (with confirmation).
  • The list and total should update automatically after adding, editing, or deleting.

This example demonstrates how to combine data modeling, database interaction, state management, navigation, and UI building to create a functional Flutter application. You can extend this further by adding features like filtering, charting, or cloud synchronization.