Flutter Project – Expense Tracker

FREE Online Courses: Knowledge Awaits – Click for Free Access!

In a world where so many goods and services are available and customers have several options, where in almost every individual’s life, transactions happen every day. It is important to keep managing our expenses, not just about how much we are spending but also in which area we are spending. Managing expenses also helps in optimizing our costs, saving, and living an organized life.

In this project, we will be building a similar app in Flutter called Expense Tracker, to keep track of our expenses. We will be building features like a chart to show expenses in different areas, Adding new expenses, Deleting existing expenses, etc. We will be creating different functions for user-interactions and will build the UI of the app using various widgets.

So let’s dive in!

Prerequisites For Flutter Expense Tracker Project

Before starting work on building the app, you should have the below-mentioned required software on your computer:

(i) Flutter – Refer to the link for installing Flutter, depending on your operating system.

(ii) Android Studio – You can download this Android Studio. This is necessary as it will run the app in the Android emulator.

(iii) Visual Studio Code – Although this is not necessary, you can also build apps in Android Studio. But in our case, we have used VS code as it is a good code editor.

Now that the setup is ready, let’s get started!

Download Flutter Expense Tracker Project

Please download the source code of the Flutter Expense Tracker Project: Flutter Expense Tracker Project Code.

Creating New Flutter Expense Tracker Project

First, let’s create a new project in Flutter by doing the following steps:-

1. Go to the directory where you want to save the project using:-

cd  $Project-directory-path

2. Then create a new project using the below command:-

flutter create expense_tracker

Creating New Flutter Project

Steps to Build Flutter Expense Tracker Project

1. Defining the Theme and Main Layout of the App

Before creating the complex UI and other widgets for user interaction, let’s start with defining the theme to be used in the app. We are using the ‘MaterialApp’ widget to define the material property of the app with all its features. Inside this, we have set the theme for both light and dark theme using using the ColorScheme class. We have also defined theme for card, elevated button as well as text. In the home, we are returning a custom widget, Expenses, which we will create after the next section. The expenses widget will contain the UI elements of the page.

import 'package:flutter/material.dart';

import 'widgets/expenses.dart';

var kColorScheme =
    ColorScheme.fromSeed(seedColor: const Color.fromARGB(255, 96, 59, 181));

var kDarkColorScheme = ColorScheme.fromSeed(
    brightness: Brightness.dark,
    seedColor: const Color.fromARGB(255, 5, 99, 125));

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData().copyWith(
        useMaterial3: true,
        colorScheme: kColorScheme,
        appBarTheme: const AppBarTheme().copyWith(
          backgroundColor: kColorScheme.onPrimaryContainer,
          foregroundColor: kColorScheme.primaryContainer,
        ),
        cardTheme: const CardTheme().copyWith(
          color: kColorScheme.secondaryContainer,
          margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
              backgroundColor: kColorScheme.primaryContainer),
        ),
        textTheme: ThemeData().textTheme.copyWith(
              titleLarge: TextStyle(
                  fontWeight: FontWeight.bold,
                  color: kColorScheme.onSecondaryContainer,
                  fontSize: 16),
            ),
      ),
      darkTheme: ThemeData.dark().copyWith(
        useMaterial3: true,
        colorScheme: kDarkColorScheme,
        cardTheme: const CardTheme().copyWith(
          color: kDarkColorScheme.secondaryContainer,
          margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
              backgroundColor: kDarkColorScheme.primaryContainer,
              foregroundColor: kDarkColorScheme.onPrimaryContainer),
        ),
      ),
      home: const Expenses(),
    ),
  );
}

2. Defining Model for Expense

Here we are creating the class called ‘Expense’, which will be used to store every detail about an expense, like the amount during transaction, date when happened, title of the expense and the category to which it belongs like food, travel, work etc.

We have also imported two main packages here, UUID and INTL. UUID is used to create a unique identifier for every expense, and INTL is used in formatting dates in the required format.

We have also defined a getter function called formatted date, which is used to get the date in the required format.

import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:intl/intl.dart';

const uuid = Uuid();
var formatter = DateFormat.yMd();

enum Category { food, travel, leisure, work }

var categoryIcons = {
  Category.food: Icons.dinner_dining,
  Category.leisure: Icons.movie,
  Category.travel: Icons.flight_takeoff,
  Category.work: Icons.work
};

class Expense {
  Expense(
      {required this.amount,
      required this.date,
      required this.title,
      required this.category})
      : id = uuid.v4();

  final String id;
  final String title;
  final double amount;
  final DateTime date;
  final Category category;

  get formattedDate {
    return formatter.format(date);
  }
}

class ExpenseBucket {
  ExpenseBucket({required this.category, required this.expenses});

  ExpenseBucket.forCategory(List<Expense> allExpenses, this.category)
      : expenses = allExpenses
            .where((expense) => expense.category == category)
            .toList();

  final Category category;
  final List<Expense> expenses;

  double get totalExpenses {
    double sum = 0;

    for (final expense in expenses) {
      sum += expense.amount;
    }

    return sum;
  }
}

We have also created another class called ExpenseBucket, which is used to store all expenses of a particular category. A function is created on the class which accepts a particular category and expenses and creates a list of all expenses for that category.

3. Creating Expenses Screen

Let’s start building the Expenses Screen, which will be used to display the content regarding the list of expenses and a chart for total expenses per category.

It is a Stateful widget as the list of expenses will change when user adds or deletes an expense. We have defined some sample expenses in the variable _registeredExpenses using the Expense model we created in the previous step.

These are some of the functions that we have defined and will be using here:-

(i) _openAddExpenseOverlay – This is used to open the showModalBottomSheet through which user can add new expense. We have set the isScrollControlled property as true, and in the builder, we are returning the NewExpense widget, which we will create later.

(ii) _addExpense – This function is passed as an argument in the NewExpense widget. It is used to add the expense in the _registeredExpenses list.

(iii) removeExpense – This is executed when a user deletes an expense. This function removes the expense from _registeredExpenses list and shows a SnackBar for a period of 3 seconds, which is used to undo the deletion.

While returning a widget in the build function which is stored in the variable mainContent, we are first checking if the _resisteredExpenses list is empty or not. If it is empty, we display a Text widget showing no expense found, else an ExpenseList, which is a custom widget we will create in the helper widgets section.

We are returning Scaffold widget to show the basic material design of the page, inside which we have defined an appBar and in the action argument we are returning an IconButton through which _openAddExpenseOverlay function is executed. In the body argument, we are returning a Column widget which contains two widgets, a Chart which we will create in the next section and the mainContent.

import 'package:flutter/material.dart';

import '../models/expense_model.dart';
import 'expenses_list/expenses_list.dart';
import './new_expense.dart';
import './chart/chart.dart';

class Expenses extends StatefulWidget {
  const Expenses({super.key});

  @override
  State<Expenses> createState() {
    return _ExpensesState();
  }
}

class _ExpensesState extends State<Expenses> {
  final List<Expense> _registeredExpenses = [
    Expense(
        amount: 19.56,
        date: DateTime.now(),
        title: 'Flutter Course',
        category: Category.work),
    Expense(
        amount: 16.34,
        date: DateTime.now(),
        title: 'Movie',
        category: Category.leisure)
  ];

  void _openAddExpenseOverlay() {
    showModalBottomSheet(
      isScrollControlled: true,
      context: context,
      builder: (ctx) {
        return NewExpense(
          onAddExpense: _addExpense,
        );
      },
    );
  }

  void _addExpense(Expense expense) {
    setState(() {
      _registeredExpenses.add(expense);
    });
  }

  void removeExpense(Expense expense) {
    final expenseIndex = _registeredExpenses.indexOf(expense);
    setState(() {
      _registeredExpenses.remove(expense);
    });
    ScaffoldMessenger.of(context).clearSnackBars();
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        duration: const Duration(seconds: 3),
        content: const Text('Expense Deleted!'),
        action: SnackBarAction(
            label: 'Undo',
            onPressed: () {
              setState(() {
                _registeredExpenses.insert(expenseIndex, expense);
              });
            }),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    Widget mainContent = const Center(
      child: Text("No Expense found! Start adding some! "),
    );

    if (_registeredExpenses.isNotEmpty) {
      mainContent = ExpensesList(
        expenses: _registeredExpenses,
        onRemoveExpense: removeExpense,
      );
    }
    return Scaffold(
      appBar: AppBar(
        title: const Text('Expense Tracker (By ProjectGururkul)'),
        actions: [
          IconButton(
            onPressed: _openAddExpenseOverlay,
            icon: const Icon(Icons.add),
          ),
        ],
      ),
      body: Column(
        children: [
          Chart(expenses: _registeredExpenses),
          Expanded(
            child: mainContent,
          ),
        ],
      ),
    );
  }
}

4. Building a Custom Chart widget

As we saw in the above section, we are using Chart widget to show the chart of expenses based on category. For this, we are importing two custom widgets, Chart Bar(This we will create in the Helper Widgets section) and Expense Model. Through the constructor function, we are accepting the list of expenses.

We have also created two getter functions:-

(i) buckets – This function is used to create list of expenses for each category using the ExpenseBucket model we created in the model section.

(ii) maxTotalExpense – This is used to calculate the max total expense among the buckets/categories.

In the build function, we have defined a variable isDarkMode, to check if the app is working in dark or light mode based on which styling will be done. We are returning a Container to set the margin, padding, width, height and decoration for the widget. In the child argument we are returning a Column widget which is used to display the ChartBars for each category using Row widget and below them, their corresponding category icon. Between bars and their icons, gap is given using SizedBox widget.

import 'package:flutter/material.dart';

import 'package:expense_tracker/widgets/chart/chart_bar.dart';
import 'package:expense_tracker/models/expense_model.dart';

class Chart extends StatelessWidget {
  const Chart({super.key, required this.expenses});

  final List<Expense> expenses;

  List<ExpenseBucket> get buckets {
    return [
      ExpenseBucket.forCategory(expenses, Category.food),
      ExpenseBucket.forCategory(expenses, Category.leisure),
      ExpenseBucket.forCategory(expenses, Category.travel),
      ExpenseBucket.forCategory(expenses, Category.work),
    ];
  }

  double get maxTotalExpense {
    double maxTotalExpense = 0;

    for (final bucket in buckets) {
      if (bucket.totalExpenses > maxTotalExpense) {
        maxTotalExpense = bucket.totalExpenses;
      }
    }

    return maxTotalExpense;
  }

  @override
  Widget build(BuildContext context) {
    final isDarkMode =
        MediaQuery.of(context).platformBrightness == Brightness.dark;
    return Container(
      margin: const EdgeInsets.all(16),
      padding: const EdgeInsets.symmetric(
        vertical: 16,
        horizontal: 8,
      ),
      width: double.infinity,
      height: 180,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        gradient: LinearGradient(
          colors: [
            Theme.of(context).colorScheme.primary.withOpacity(0.3),
            Theme.of(context).colorScheme.primary.withOpacity(0.0)
          ],
          begin: Alignment.bottomCenter,
          end: Alignment.topCenter,
        ),
      ),
      child: Column(
        children: [
          Expanded(
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: [
                for (final bucket in buckets) // alternative to map()
                  ChartBar(
                    fill: bucket.totalExpenses == 0
                        ? 0
                        : bucket.totalExpenses / maxTotalExpense,
                  )
              ],
            ),
          ),
          const SizedBox(height: 12),
          Row(
            children: buckets // for ... in
                .map(
                  (bucket) => Expanded(
                    child: Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 4),
                      child: Icon(
                        categoryIcons[bucket.category],
                        color: isDarkMode
                            ? Theme.of(context).colorScheme.secondary
                            : Theme.of(context)
                                .colorScheme
                                .primary
                                .withOpacity(0.7),
                      ),
                    ),
                  ),
                )
                .toList(),
          )
        ],
      ),
    );
  }
}

5. Screen to add New Expense

This screen opens up after clicking the plus icon on the App bar in the Expenses screen. It is used to add new expenses to the expenses list. It is a Stateful widget as the user input will change the UI of the app, therefore some widgets need to render again.

Here we are accessing the function onAddExpense through the constructor function, which we created in the Expenses screen.

We have also created some variables which will be used to keep track of user input such as:

a. _titleController – This is used to keep track of the title of expense entered by the user.
b. _amountController – This is used to keep track of the amount entered by the user.
c. _selectedDate – This is used to store the selected date for the expense.
d. _selectedCategory – This is used to store the category of expense, which is one of the four values: food, leisure, travel, and work.

We have also defined functions such as:-

a. _presentDayPicker – This function is used to select date when user enters details for new expenses. Here we use the showDatePicker widget to show the calendar from where the user can select a date.

b. _submitExpenseData – This function is executed when the user tries to submit the details for new expense. It checks if the details entered by the user are valid. If that is not the case, it shows an AlertDialog telling that the details entered are incorrect, otherwise, it opens the Expenses screen with newly added expenses in the list.

import 'package:flutter/material.dart';

import '../models/expense_model.dart';

class NewExpense extends StatefulWidget {
  const NewExpense({required this.onAddExpense, super.key});

  final Function(Expense) onAddExpense;
  @override
  State<NewExpense> createState() {
    return _NewExpenseState();
  }
}

class _NewExpenseState extends State<NewExpense> {
  final _titleController = TextEditingController();
  final _amountController = TextEditingController();
  DateTime? _selectedDate;
  Category _selectedCategory = Category.leisure;

  void _presentDayPicker() async {
    var now = DateTime.now();
    final initialDate = DateTime(now.year - 1, now.month, now.day);
    var _pickedDate = await showDatePicker(
        context: context,
        initialDate: now,
        firstDate: initialDate,
        lastDate: now);
    setState(() {
      _selectedDate = _pickedDate;
    });
  }

  void _submitExpenseData() {
    final enteredAmount = double.tryParse(_amountController.text);
    final amountIsInvalid = enteredAmount == null || enteredAmount <= 0;
    if (_titleController.text.trim().isEmpty ||
        amountIsInvalid ||
        _selectedDate == null) {
      showDialog(
          context: context,
          builder: (ctx) {
            return AlertDialog(
              title: const Text('Invalid Input'),
              content: const Text(
                  'Please make sure a valid title, amount, date and category was enterd!'),
              actions: [
                TextButton(
                    onPressed: () {
                      Navigator.pop(ctx);
                    },
                    child: const Text('Cancel'))
              ],
            );
          });
    } else {
      widget.onAddExpense(Expense(
          amount: enteredAmount,
          date: _selectedDate!,
          title: _titleController.text,
          category: _selectedCategory));
    }
    Navigator.pop(context);
  }

  @override
  void dispose() {
    _titleController.dispose();
    _amountController.dispose();
    super.dispose();
  }

In the build function, we are returning a Column widget inside a Padding widget to give some padding. In the Column widget we are returning several user input types of widgets and arranging them using Row or Expanded widget to give space we are using SizedBox to give definite space or Spacer to give as much space as possible. We are taking input in fields such as TextField for title, TextField for amount, datePicker which we defined using function shown using IconButton.

We are using DropdownButton through which the user can select a category and in the bottom, we are showing an ElevatedButton which is used to submit details and through this _submitExpenseData function is called.

@override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 48, 16, 16),
      child: Column(
        children: [
          TextField(
            maxLength: 50,
            decoration: const InputDecoration(
              label: Text("Title"),
            ),
            controller: _titleController,
          ),
          Row(
            children: [
              Expanded(
                child: TextField(
                  maxLength: 20,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(
                    prefixText: '\$ ',
                    label: Text("Amount"),
                  ),
                  controller: _amountController,
                ),
              ),
              const SizedBox(
                width: 16,
              ),
              Expanded(
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.end,
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: [
                    Text(
                      _selectedDate == null
                          ? 'No Date Selected!'
                          : formatter.format(_selectedDate!),
                    ),
                    IconButton(
                        onPressed: _presentDayPicker,
                        icon: const Icon(Icons.calendar_month)),
                  ],
                ),
              )
            ],
          ),
          const SizedBox(
            height: 16,
          ),
          Row(
            children: [
              DropdownButton(
                  value: _selectedCategory,
                  items: Category.values
                      .map(
                        (category) => DropdownMenuItem(
                          value: category,
                          child: Text(
                            category.name.toUpperCase(),
                          ),
                        ),
                      )
                      .toList(),
                  onChanged: (value) {
                    setState(() {
                      if (value == null) {
                        return;
                      }
                      setState(() {
                        _selectedCategory = value;
                      });
                    });
                  }),
              const Spacer(),
              TextButton(
                onPressed: Navigator.of(context).pop,
                child: const Text('Cancel'),
              ),
              ElevatedButton(
                onPressed: _submitExpenseData,
                child: const Text('Save Expense'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

6. Helper Widgets

A. ExpenseItem

It is a Stateless widget used to show each expense in the expense list as mentioned above. Here, through the constructor function, we are accepting the expense object about which to show. In the build function, we are returning a Card widget, inside which we are adding padding using the Padding widget. Inside this, we are returning a Column widget where we set the crossAxisAlignment to start and in the children argument returning a Text to show the title of expense and a Row widget which is used to show the amount of expense, icon for the category and the date of the expense

import 'package:flutter/material.dart';

import 'package:expense_tracker/models/expense_model.dart';

class ExpenseItem extends StatelessWidget {
  const ExpenseItem({required this.expense, super.key});

  final Expense expense;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              expense.title,
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(
              height: 4,
            ),
            Row(
              children: [
                Text('\$${expense.amount.toStringAsFixed(2)}'),
                const Spacer(),
                Row(
                  children: [
                    Icon(categoryIcons[expense.category]),
                    const SizedBox(
                      width: 8,
                    ),
                    Text(expense.formattedDate)
                  ],
                )
              ],
            ),
          ],
        ),
      ),
    );
  }
}

B. ExpensesList

This is used to show the list of expenses. Here we are accepting the list of expenses and onRemoveExpense function which we created in the Expenses Screen through the constructor function.

In the build argument, we are returning a ListView builder to show the list of expenses where we set itemCount to the length of expenses, and in itemBuilder returning a Dismissible widget so that the user can delete the expense just by a swipe. In the Dismissible widget, we set the key parameter using the ValueKey class and set the background using Container where we defined the color and margin. In the onDismissed argument, we returned the onRemoveExpense function and in the child argument, we returned the Expense widget we created above.

import 'package:flutter/material.dart';

import '../../models/expense_model.dart';
import './expense_item.dart';

class ExpensesList extends StatelessWidget {
  const ExpensesList(
      {required this.expenses, required this.onRemoveExpense, super.key});

  final List<Expense> expenses;
  final Function(Expense expense) onRemoveExpense;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: expenses.length,
        itemBuilder: (ctx, index) {
          return Dismissible(
            key: ValueKey(expenses[index]),
            background: Container(
              color: Theme.of(context).colorScheme.error.withOpacity(0.75),
              margin: EdgeInsets.symmetric(
                  horizontal: Theme.of(context).cardTheme.margin!.horizontal),
            ),
            onDismissed: (direction) {
              onRemoveExpense(expenses[index]);
            },
            child: ExpenseItem(expense: expenses[index]),
          );
        });
  }
}

C. ChartBar

As we saw above, where we created a chart, we are using this widget there. ChartBar is used to show individual bar for each category, which shows how much expenses is through this category compared to overall.
Here we are accepting fill argument through the constructor function, which shows the percentage of bar that should be filled.

In the build function, we have defined a variable called ‘isDarkMode’, which checks if currently the app is in dark mode or not. Based on this, the content is displayed in a particular style. Finally, we are returning a ‘FractionalSizedBox’ widget which has padding using Padding widget and takes as much space as possible due to Expanded widget. In the ‘FractionalSizedBox’, we have set the arguments heightFactor as fill and in child, we are using DecoratedBox to give some styling and set the shape to rectangle.

import 'package:flutter/material.dart';

class ChartBar extends StatelessWidget {
  const ChartBar({
    super.key,
    required this.fill,
  });

  final double fill;

  @override
  Widget build(BuildContext context) {
    final isDarkMode =
        MediaQuery.of(context).platformBrightness == Brightness.dark;
    return Expanded(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 4),
        child: FractionallySizedBox(
          heightFactor: fill, // 0 <> 1
          child: DecoratedBox(
            decoration: BoxDecoration(
              shape: BoxShape.rectangle,
              borderRadius:
                  const BorderRadius.vertical(top: Radius.circular(8)),
              color: isDarkMode
                  ? Theme.of(context).colorScheme.secondary
                  : Theme.of(context).colorScheme.primary.withOpacity(0.65),
            ),
          ),
        ),
      ),
    );
  }
}

Flutter Expense Tracker Output

Flutter Expense Tracker Output

Conclusion

Through this Flutter Expense Tracker project, we got to learn about a lot of widgets and used them to build an interactive app through which users can manage their expenses. Here we got to learn about widgets like SnackBar, a Dismissible widget to delete a list item through swipe, DropdownButton, FractionalSizedBox, etc. We also got to know how to show date-picker through which users can select a date. We also created several complex functions for user interactions.

I hope you enjoyed working on this project!
Thank you for reading! Keep Learning Flutter!

Leave a Reply

Your email address will not be published. Required fields are marked *