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.
Application Features:
Section titled “Application Features:”- 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.
Key Technologies Used:
Section titled “Key Technologies Used:”ListView.separatedfor displaying the list with dividers.Dismissiblewidget for swipe-to-delete functionality.- Flutter Forms (
Form,TextFormField, validation). sqflitefor local database storage.providerfor efficient state management (replacing the outdatedscoped_model).intlfor date formatting.
Setup:
Section titled “Setup:”- Create a new Flutter project:
flutter create expense_calculator. - Open
pubspec.yamland 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- Run
flutter pub get. - Important Note: The original tutorial used
scoped_model. We will modernize this usingprovider, which requires restructuring the state management part significantly.
1. Data Model (lib/expense.dart)
Section titled “1. Data Model (lib/expense.dart)”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 UnmodifiableListViewimport '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 } }}4. Main Application Setup (lib/main.dart)
Section titled “4. Main Application Setup (lib/main.dart)”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(), ), ); }}5. Home Page UI (lib/home_page.dart)
Section titled “5. Home Page UI (lib/home_page.dart)”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 currencyimport '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), ), ); }}6. Form Page UI (lib/form_page.dart)
Section titled “6. Form Page UI (lib/form_page.dart)”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'), ), ], ), ), ), ); }}Running the Application
Section titled “Running the Application”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.