Skip to content

Flutter - Writting Advanced Applications

Flutter - Building an Advanced Application

Section titled “Flutter - Building an Advanced Application”

This chapter demonstrates building a more complete application, an ‘Expense Calculator’, integrating several concepts: data persistence (SQLite), state management (Provider), forms, and advanced list views.

  • Display a list of expenses.
  • Show the total expense amount.
  • Provide a form to add new expenses.
  • Allow editing existing expenses.
  • Allow deleting expenses (e.g., via swipe).
  • Persist data locally using SQLite.
  • ListView.separated for displaying the list with dividers.
  • Dismissible widget for swipe-to-delete functionality.
  • Flutter Forms (Form, TextFormField, validation).
  • sqflite for local database storage.
  • provider for efficient state management (replacing the outdated scoped_model).
  • intl for date formatting.
  1. Create a new Flutter project: flutter create expense_calculator.
  2. Open pubspec.yaml and add dependencies:
dependencies:
flutter:
sdk: flutter
sqflite: ^2.3.0 # For SQLite database
path_provider: ^2.1.1 # To find correct file paths
provider: ^6.1.1 # For state management
intl: ^0.18.1 # For date/number formatting
path: ^1.8.3 # Path manipulation utilities
  1. Run flutter pub get.
  2. Important Note: The original tutorial used scoped_model. We will modernize this using provider, which requires restructuring the state management part significantly.
import 'package:intl/intl.dart';
class Expense {
final int? id; // Nullable for auto-increment PK
final double amount;
final DateTime date;
final String category;
Expense({
this.id,
required this.amount,
required this.date,
required this.category,
});
// Computed property for display
String get formattedDate {
// Use intl package for reliable formatting
return DateFormat('yyyy-MM-dd').format(date);
}
// Convert Expense object to Map for DB
Map<String, dynamic> toMap() {
return {
'id': id,
'amount': amount,
'date': date.toIso8601String(), // Store dates as ISO8601 strings
'category': category,
};
}
// Create Expense object from Map (from DB)
factory Expense.fromMap(Map<String, dynamic> map) {
return Expense(
id: map['id'] as int?,
amount: map['amount'] as double,
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)”

This class handles all SQLite interactions. (Use the DatabaseHelper class defined in the ‘Flutter - Database Concepts’ chapter, adapting table/column names for Expenses).

// --- Reuse DatabaseHelper from previous chapter ---
// --- Make sure table name is 'Expense' ---
// --- Columns: id (INTEGER PK AUTOINCREMENT), amount (REAL), date (TEXT), category (TEXT) ---
// Example Adaptations within DatabaseHelper:
// const String _tableName = 'Expense'; // Change table name
// Future<void> _onCreate(Database db, int version) async {
// await db.execute('''
// CREATE TABLE $_tableName (
// id INTEGER PRIMARY KEY AUTOINCREMENT,
// amount REAL NOT NULL,
// date TEXT NOT NULL,
// category TEXT NOT NULL
// )
// ''');
// // Optional: Insert initial data
// }
// // Methods need to use Expense model and correct table name
// Future<int> insertExpense(Expense expense) async {
// final db = await database;
// // Use expense.toMap(), ensure 'id' is excluded if null for auto-increment
// Map<String, dynamic> row = {
// 'amount': expense.amount,
// 'date': expense.date.toIso8601String(),
// 'category': expense.category
// };
// return await db.insert(_tableName, row);
// }
// Future<List<Expense>> getAllExpenses() async {
// final db = await database;
// final List<Map<String, dynamic>> maps = await db.query(_tableName, orderBy: 'date DESC');
// return List.generate(maps.length, (i) => Expense.fromMap(maps[i]));
// }
// Future<int> updateExpense(Expense expense) async { ... }
// Future<int> deleteExpense(int id) async { ... }
// Future<double> getTotalExpense() async {
// final db = await database;
// final List<Map<String, dynamic>> result = await db.rawQuery(
// "SELECT SUM(amount) as total FROM $_tableName"
// );
// return (result.first['total'] as double?) ?? 0.0;
// }
// --- End of DatabaseHelper example adaptations ---

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

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

Create a ChangeNotifier to hold the expense list and notify listeners of changes.

import 'package:flutter/foundation.dart';
import 'dart:collection'; // For UnmodifiableListView
import 'database_helper.dart';
import 'expense.dart';
class ExpenseProvider with ChangeNotifier {
final DatabaseHelper _dbHelper = DatabaseHelper();
List<Expense> _items = [];
double _totalExpense = 0.0;
// Provide read-only access to the expense list
UnmodifiableListView<Expense> get items => UnmodifiableListView(_items);
// Provide read-only access to the total
double get totalExpense => _totalExpense;
ExpenseProvider() {
// Load initial data when the provider is created
loadExpenses();
}
// Load expenses from database
Future<void> loadExpenses() async {
_items = await _dbHelper.getAllExpenses();
await _calculateTotal();
notifyListeners(); // Notify UI to rebuild
}
// Calculate total (can be optimized)
Future<void> _calculateTotal() async {
_totalExpense = await _dbHelper.getTotalExpense();
// Or calculate from _items if DB call is slow for frequent updates:
// _totalExpense = _items.fold(0.0, (sum, item) => sum + item.amount);
}
// Add a new expense
Future<void> addExpense(Expense expense) async {
await _dbHelper.insertExpense(expense);
await loadExpenses(); // Reload data from DB to get new ID and update total
// For optimization on large datasets, you might add locally and recalculate total
}
// Update an existing expense
Future<void> updateExpense(Expense expense) async {
await _dbHelper.updateExpense(expense);
await loadExpenses(); // Reload all data for simplicity
// Optimization: update item in _items list directly if needed
}
// Delete an expense
Future<void> deleteExpense(int id) async {
await _dbHelper.deleteExpense(id);
_items.removeWhere((item) => item.id == id); // Remove locally first for responsiveness
await _calculateTotal(); // Recalculate total
notifyListeners(); // Update UI immediately
// Optional: Could call loadExpenses() instead for guaranteed consistency
}
// Get expense by ID (useful for edit form)
Expense? getExpenseById(int id) {
try {
return _items.firstWhere((item) => item.id == id);
} catch (e) {
return null; // Not found
}
}
}

Wrap the app with ChangeNotifierProvider to make ExpenseProvider available.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'expense_provider.dart';
import 'home_page.dart'; // We'll create this 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 Calculator',
theme: ThemeData(
primarySwatch: Colors.green, // Changed theme color
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const HomePage(),
),
);
}
}

Display the list, total, and provide navigation to the form.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // For formatting currency
import 'expense_provider.dart';
import 'expense.dart';
import 'form_page.dart'; // We'll create this next
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Access the provider using Consumer or context.watch
// Using Consumer rebuilds only this part of the widget tree
return Scaffold(
appBar: AppBar(
title: const Text('Expense Calculator'),
),
body: Consumer<ExpenseProvider>(
builder: (context, expenseProvider, child) {
// Handle loading state if provider implements it
if (expenseProvider.items.isEmpty) {
// Could show loading indicator or 'No expenses' message
// Check if it's truly empty or still loading initially
return const Center(child: Text('No expenses yet. Add one!'));
}
return ListView.separated(
itemCount: expenseProvider.items.length + 1, // +1 for the total row
itemBuilder: (context, index) {
if (index == 0) {
// Display Total Expense Row
final total = expenseProvider.totalExpense;
final currencyFormat = NumberFormat.currency(locale: 'en_US', symbol: '$');
return ListTile(
title: Text(
'Total Expenses: ${currencyFormat.format(total)}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
);
} else {
// Display Expense Item Row
final itemIndex = index - 1;
final expense = expenseProvider.items[itemIndex];
return Dismissible(
key: Key(expense.id.toString()), // Unique key for Dismissible
direction: DismissDirection.endToStart, // Swipe left to delete
onDismissed: (direction) {
// Delete the item using the provider
expenseProvider.deleteExpense(expense.id!);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${expense.category} expense deleted')),
);
},
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: const Icon(Icons.delete, color: Colors.white),
),
child: ListTile(
leading: const Icon(Icons.monetization_on),
title: Text(expense.category),
subtitle: Text('Spent on ${expense.formattedDate}'),
trailing: Text(NumberFormat.currency(locale: 'en_US', symbol: '$').format(expense.amount)),
onTap: () {
// Navigate to FormPage for editing
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FormPage(expenseId: expense.id),
),
);
},
),
);
}
},
separatorBuilder: (context, index) {
// Add a divider between items, but not after the total
return index == 0 ? const SizedBox.shrink() : const Divider();
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Navigate to FormPage for adding a new expense
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const FormPage(), // No ID means 'add mode'
),
);
},
tooltip: 'Add Expense',
child: const Icon(Icons.add),
),
);
}
}

Handle adding and editing expenses with validation.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'expense_provider.dart';
import 'expense.dart';
class FormPage extends StatefulWidget {
final int? expenseId; // Null if adding, non-null if editing
const FormPage({Key? key, this.expenseId}) : super(key: key);
@override
_FormPageState createState() => _FormPageState();
}
class _FormPageState extends State<FormPage> {
final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController();
final _categoryController = TextEditingController();
DateTime _selectedDate = DateTime.now();
bool _isEditMode = false;
Expense? _existingExpense;
@override
void initState() {
super.initState();
if (widget.expenseId != null) {
_isEditMode = true;
// Fetch existing expense data using the Provider
// Use read here as we don't need to rebuild based on provider changes in initState
_existingExpense = Provider.of<ExpenseProvider>(context, listen: false)
.getExpenseById(widget.expenseId!);
if (_existingExpense != null) {
_amountController.text = _existingExpense!.amount.toString();
_categoryController.text = _existingExpense!.category;
_selectedDate = _existingExpense!.date;
} else {
// Handle case where expense is not found (e.g., navigate back)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { // Check if widget is still in the tree
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: Expense not found'))
);
}
});
}
}
}
@override
void dispose() {
_amountController.dispose();
_categoryController.dispose();
super.dispose();
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2101),
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); // Trigger onSaved callbacks
final amount = double.tryParse(_amountController.text) ?? 0.0;
final category = _categoryController.text;
final expense = Expense(
id: _isEditMode ? widget.expenseId : null,
amount: amount,
date: _selectedDate,
category: category,
);
// Access provider to add or update
final expenseProvider = Provider.of<ExpenseProvider>(context, listen: false);
if (_isEditMode) {
expenseProvider.updateExpense(expense);
} else {
expenseProvider.addExpense(expense);
}
Navigator.pop(context); // Go back after submitting
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditMode ? 'Edit Expense' : 'Add Expense'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: ListView( // Use ListView for scrollability on small screens
children: <Widget>[
TextFormField(
controller: _amountController,
decoration: const InputDecoration(
labelText: 'Amount',
icon: Icon(Icons.attach_money),
hintText: 'e.g., 19.99'
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount.';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid number.';
}
if (double.parse(value) <= 0) {
return 'Amount must be positive.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _categoryController,
decoration: const InputDecoration(
labelText: 'Category',
icon: Icon(Icons.category),
hintText: 'e.g., Food, Transport'
),
textCapitalization: TextCapitalization.words,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a category.';
}
return null;
},
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.calendar_today),
title: const Text('Date'),
subtitle: Text(DateFormat('yyyy-MM-dd').format(_selectedDate)),
trailing: const Icon(Icons.edit),
onTap: () => _selectDate(context),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _submitForm,
child: Text(_isEditMode ? 'Update Expense' : 'Add Expense'),
),
],
),
),
),
);
}
}

Run the app (flutter run).

  • You should see the home screen, initially empty or with seed data.
  • The total expense updates automatically.
  • Click the ’+’ button to add a new expense via the form.
  • Tap an existing expense to edit it.
  • Swipe an expense left to delete it.
  • Data persists across app restarts thanks to SQLite.

This example showcases how to combine UI elements, forms, local persistence, and modern state management (provider) to build a functional Flutter application.