Creating an expense tracking app can be a great way to manage personal finances. Flutter, with its cross-platform capabilities and rich set of widgets, is an excellent choice for building such an app. Combining Flutter with SQLite, a lightweight and efficient database, provides a robust solution for storing and managing expense data locally. This comprehensive guide will walk you through the process of building an expense tracking app using Flutter and SQLite.
What You Will Learn
- Setting up a new Flutter project
- Integrating the SQLite database
- Creating a database schema for expenses
- Implementing CRUD (Create, Read, Update, Delete) operations
- Designing the user interface for adding, viewing, and managing expenses
- Testing the app on different platforms
Prerequisites
Before you start, make sure you have the following:
- Flutter SDK installed
- Android Studio or VS Code with Flutter extensions
- Basic knowledge of Dart and Flutter
Step 1: Setting Up a New Flutter Project
Create a new Flutter project by running the following command in your terminal:
flutter create expense_tracker_app
cd expense_tracker_app
Step 2: Adding Dependencies
Add the necessary dependencies to your pubspec.yaml
file. We will need sqflite
for SQLite integration, path_provider
to find the location for storing the database, and intl
for formatting dates and currencies.
dependencies:
flutter:
sdk: flutter
sqflite: ^2.3.0
path_provider: ^2.1.0
intl: ^0.18.1
Run flutter pub get
to install these dependencies.
Step 3: Defining the Expense Model
Create a file named expense.dart
to define the Expense
model class.
class Expense {
final int? id;
final String title;
final double amount;
final DateTime date;
final String category;
Expense({
this.id,
required this.title,
required this.amount,
required this.date,
required this.category,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'amount': amount,
'date': date.toIso8601String(),
'category': category,
};
}
static Expense fromMap(Map<String, dynamic> map) {
return Expense(
id: map['id'],
title: map['title'],
amount: map['amount'],
date: DateTime.parse(map['date']),
category: map['category'],
);
}
}
The Expense
class includes properties such as id
, title
, amount
, date
, and category
. The toMap()
method converts an Expense
object to a Map
, and the fromMap()
method converts a Map
to an Expense
object.
Step 4: Setting Up the SQLite Database
Create a file named database_helper.dart
to manage the SQLite database.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'expense.dart';
class DatabaseHelper {
static const _databaseName = "ExpenseDatabase.db";
static const _databaseVersion = 1;
static const table = 'expenses';
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
_initDatabase() async {
final documentsDirectory = await getApplicationDocumentsDirectory();
final path = join(documentsDirectory.path, _databaseName);
return await openDatabase(path,
version: _databaseVersion, onCreate: _onCreate);
}
Future _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
amount REAL NOT NULL,
date TEXT NOT NULL,
category TEXT NOT NULL
)
''');
}
Future<int> insert(Expense expense) async {
final db = await instance.database;
return await db.insert(table, expense.toMap());
}
Future<List<Expense>> getAllExpenses() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query(table);
return List.generate(maps.length, (i) {
return Expense.fromMap(maps[i]);
});
}
Future<int> update(Expense expense) async {
final db = await instance.database;
return await db.update(
table,
expense.toMap(),
where: 'id = ?',
whereArgs: [expense.id],
);
}
Future<int> delete(int id) async {
final db = await instance.database;
return await db.delete(
table,
where: 'id = ?',
whereArgs: [id],
);
}
}
This code sets up the database and provides methods for creating, reading, updating, and deleting expense entries. The database is initialized as a singleton, ensuring that only one instance exists throughout the application’s lifecycle.
Step 5: Building the UI Components
Now, let’s build the UI components for our app. This includes screens for adding expenses, viewing the list of expenses, and editing expenses.
Adding Expenses Screen
Create a file named add_expense_screen.dart
.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'database_helper.dart';
import 'expense.dart';
class AddExpenseScreen extends StatefulWidget {
@override
_AddExpenseScreenState createState() => _AddExpenseScreenState();
}
class _AddExpenseScreenState extends State<AddExpenseScreen> {
final _formKey = GlobalKey<FormState>();
final titleController = TextEditingController();
final amountController = TextEditingController();
DateTime selectedDate = DateTime.now();
String selectedCategory = 'Food';
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2025));
if (picked != null && picked != selectedDate)
setState(() {
selectedDate = picked;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Add Expense'),
),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
TextFormField(
controller: titleController,
decoration: InputDecoration(labelText: 'Title'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
TextFormField(
controller: amountController,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: 'Amount'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid number';
}
return null;
},
),
Row(
children: <Widget>[
Text('Date: ${DateFormat('yyyy-MM-dd').format(selectedDate)}'),
IconButton(
icon: Icon(Icons.calendar_today),
onPressed: () => _selectDate(context),
),
],
),
DropdownButtonFormField(
value: selectedCategory,
items: <String>['Food', 'Transport', 'Shopping', 'Other']
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
decoration: InputDecoration(labelText: 'Category'),
onChanged: (String? newValue) {
setState(() {
selectedCategory = newValue!;
});
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
final title = titleController.text;
final amount = double.parse(amountController.text);
final expense = Expense(
title: title,
amount: amount,
date: selectedDate,
category: selectedCategory,
);
await DatabaseHelper.instance.insert(expense);
Navigator.pop(context, true); // Return to list with refresh signal
}
},
child: Text('Submit'),
),
),
],
),
),
),
);
}
}
This screen provides a form for the user to enter the expense title, amount, date, and category. It uses the intl
package for date formatting and validation for form fields.
Expenses List Screen
Create a file named expenses_list_screen.dart
.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'database_helper.dart';
import 'expense.dart';
import 'add_expense_screen.dart';
class ExpensesListScreen extends StatefulWidget {
@override
_ExpensesListScreenState createState() => _ExpensesListScreenState();
}
class _ExpensesListScreenState extends State<ExpensesListScreen> {
late Future<List<Expense>> expenses;
@override
void initState() {
super.initState();
expenses = DatabaseHelper.instance.getAllExpenses();
}
void refreshExpensesList() {
setState(() {
expenses = DatabaseHelper.instance.getAllExpenses();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Expenses List'),
),
body: FutureBuilder<List<Expense>>(
future: expenses,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return Center(child: Text('No expenses added yet.'));
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final expense = snapshot.data![index];
return Card(
margin: EdgeInsets.all(8.0),
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Title: ${expense.title}',
style: TextStyle(fontWeight: FontWeight.bold)),
Text('Amount: \$${expense.amount.toStringAsFixed(2)}'),
Text(
'Date: ${DateFormat('yyyy-MM-dd').format(expense.date)}'),
Text('Category: ${expense.category}'),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
icon: Icon(Icons.delete),
onPressed: () async {
await DatabaseHelper.instance.delete(expense.id!);
refreshExpensesList();
},
),
],
),
],
),
),
);
},
);
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else {
return Center(child: CircularProgressIndicator());
}
},
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => AddExpenseScreen()),
);
if (result == true) {
refreshExpensesList(); // Refresh list on return from add screen
}
},
child: Icon(Icons.add),
),
);
}
}
This screen displays a list of expenses fetched from the SQLite database. It uses a FutureBuilder
to handle the asynchronous loading of data and displays a loading indicator while the data is being fetched. The list is displayed using a ListView.builder
, and each item includes options to delete the expense.
Step 6: Integrating the Screens
Modify the main.dart
file to set up the main application screen.
import 'package:flutter/material.dart';
import 'expenses_list_screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Expense Tracker',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ExpensesListScreen(),
);
}
}
This sets the ExpensesListScreen
as the home screen for the application.
Step 7: Running the Application
Now, run the application on your device or emulator:
flutter run
The application should now be running, allowing you to add, view, and delete expenses. Ensure that your device or emulator is connected and properly configured for Flutter development.
Step 8: Testing and Debugging
Thoroughly test your app on different devices and platforms to ensure it functions correctly. Use the Flutter debug tools to identify and fix any issues. Pay attention to data persistence, UI responsiveness, and error handling.
Complete Example
Here is the combined, fully functional code for creating the expense tracking app:
// expense.dart
class Expense {
final int? id;
final String title;
final double amount;
final DateTime date;
final String category;
Expense({
this.id,
required this.title,
required this.amount,
required this.date,
required this.category,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'amount': amount,
'date': date.toIso8601String(),
'category': category,
};
}
static Expense fromMap(Map<String, dynamic> map) {
return Expense(
id: map['id'],
title: map['title'],
amount: map['amount'],
date: DateTime.parse(map['date']),
category: map['category'],
);
}
}
// database_helper.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'expense.dart';
class DatabaseHelper {
static const _databaseName = "ExpenseDatabase.db";
static const _databaseVersion = 1;
static const table = 'expenses';
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
_initDatabase() async {
final documentsDirectory = await getApplicationDocumentsDirectory();
final path = join(documentsDirectory.path, _databaseName);
return await openDatabase(path,
version: _databaseVersion, onCreate: _onCreate);
}
Future _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
amount REAL NOT NULL,
date TEXT NOT NULL,
category TEXT NOT NULL
)
''');
}
Future<int> insert(Expense expense) async {
final db = await instance.database;
return await db.insert(table, expense.toMap());
}
Future<List<Expense>> getAllExpenses() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query(table);
return List.generate(maps.length, (i) {
return Expense.fromMap(maps[i]);
});
}
Future<int> update(Expense expense) async {
final db = await instance.database;
return await db.update(
table,
expense.toMap(),
where: 'id = ?',
whereArgs: [expense.id],
);
}
Future<int> delete(int id) async {
final db = await instance.database;
return await db.delete(
table,
where: 'id = ?',
whereArgs: [id],
);
}
}
// add_expense_screen.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'database_helper.dart';
import 'expense.dart';
class AddExpenseScreen extends StatefulWidget {
@override
_AddExpenseScreenState createState() => _AddExpenseScreenState();
}
class _AddExpenseScreenState extends State<AddExpenseScreen> {
final _formKey = GlobalKey<FormState>();
final titleController = TextEditingController();
final amountController = TextEditingController();
DateTime selectedDate = DateTime.now();
String selectedCategory = 'Food';
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2025));
if (picked != null && picked != selectedDate)
setState(() {
selectedDate = picked;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Add Expense'),
),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
TextFormField(
controller: titleController,
decoration: InputDecoration(labelText: 'Title'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
TextFormField(
controller: amountController,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: 'Amount'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid number';
}
return null;
},
),
Row(
children: <Widget>[
Text('Date: ${DateFormat('yyyy-MM-dd').format(selectedDate)}'),
IconButton(
icon: Icon(Icons.calendar_today),
onPressed: () => _selectDate(context),
),
],
),
DropdownButtonFormField(
value: selectedCategory,
items: <String>['Food', 'Transport', 'Shopping', 'Other']
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
decoration: InputDecoration(labelText: 'Category'),
onChanged: (String? newValue) {
setState(() {
selectedCategory = newValue!;
});
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
final title = titleController.text;
final amount = double.parse(amountController.text);
final expense = Expense(
title: title,
amount: amount,
date: selectedDate,
category: selectedCategory,
);
await DatabaseHelper.instance.insert(expense);
Navigator.pop(context, true); // Return to list with refresh signal
}
},
child: Text('Submit'),
),
),
],
),
),
),
);
}
}
// expenses_list_screen.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'database_helper.dart';
import 'expense.dart';
import 'add_expense_screen.dart';
class ExpensesListScreen extends StatefulWidget {
@override
_ExpensesListScreenState createState() => _ExpensesListScreenState();
}
class _ExpensesListScreenState extends State<ExpensesListScreen> {
late Future<List<Expense>> expenses;
@override
void initState() {
super.initState();
expenses = DatabaseHelper.instance.getAllExpenses();
}
void refreshExpensesList() {
setState(() {
expenses = DatabaseHelper.instance.getAllExpenses();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Expenses List'),
),
body: FutureBuilder<List<Expense>>(
future: expenses,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return Center(child: Text('No expenses added yet.'));
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final expense = snapshot.data![index];
return Card(
margin: EdgeInsets.all(8.0),
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Title: ${expense.title}',
style: TextStyle(fontWeight: FontWeight.bold)),
Text('Amount: \$${expense.amount.toStringAsFixed(2)}'),
Text(
'Date: ${DateFormat('yyyy-MM-dd').format(expense.date)}'),
Text('Category: ${expense.category}'),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
icon: Icon(Icons.delete),
onPressed: () async {
await DatabaseHelper.instance.delete(expense.id!);
refreshExpensesList();
},
),
],
),
],
),
),
);
},
);
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else {
return Center(child: CircularProgressIndicator());
}
},
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => AddExpenseScreen()),
);
if (result == true) {
refreshExpensesList(); // Refresh list on return from add screen
}
},
child: Icon(Icons.add),
),
);
}
}
// main.dart
import 'package:flutter/material.dart';
import 'expenses_list_screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Expense Tracker',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ExpensesListScreen(),
);
}
}
Further Enhancements
Here are some ideas for further enhancing your expense tracking app:
- Implement an edit expense screen.
- Add more detailed reporting and analytics.
- Implement user authentication for secure data storage.
- Integrate cloud synchronization for data backup and sharing across devices.
Conclusion
Building an expense tracking app with Flutter and SQLite is a rewarding project that demonstrates the power and flexibility of these technologies. By following this guide, you can create a functional and visually appealing app to help users manage their expenses effectively. Experiment with different UI designs and features to customize the app to your specific needs and preferences.