flutter_building_complete_application
Flutter - Building a Complete Application
Section titled “Flutter - Building a 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). sqflitefor local data persistence.providerfor state management.- Date formatting using the
intlpackage. - Basic form validation.
Project Setup
Section titled “Project Setup”-
Create a new Flutter project:
flutter create expense_tracker_app -
Add dependencies to
pubspec.yaml(use latest versions): -
Run
flutter pub get.
1. Data Model (lib/expense.dart)
Section titled “1. Data Model (lib/expense.dart)”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 }}4. Main Application Setup (lib/main.dart)
Section titled “4. Main Application Setup (lib/main.dart)”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 nextimport '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, ), ], ), ), ), ); }}Running the App
Section titled “Running the App”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.