Creating Expense Tracking Apps with Flutter and SQLite

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.