Practical Implementation Guide
For Courses in the Faculty of Computer Science & Information Technology
Academic Year: 2025
Course: Mobile Application Development with Flutter Lab
Practical Instructor: Mohammad Al-aqua
Academic Degree: Bachelor's in Computer Science
Student: ................................................
Academic Number: ..............................................
Major: .................................................
Group: .................................................
Mobile Application Development with Flutter
Flutter Personal Finance Tracker Course Outline
Lab 1: Introduction to Flutter
- Set up Flutter development environment
- Create the initial app with AppBar and basic scaffold
- Introduce "Hello World" and hot reload concepts
- Project component: App shell with title "My Finance Tracker"
Lab 2: Widgets and Layout
- Understand the basics layout widgets (Padding, Container, Column, Row, etc.)
- Design the dashboard layout with cards for summary info
- Implement balance overview section with static data
- Project component: Main dashboard with balance, income/expense cards
Lab 3: Lists and UI Components
- Create transaction list with ListTile widgets
- Add transaction item UI with amount, date, category
- Implement custom transaction card design
- Project component: Scrollable transaction history list
Lab 4: State Management
- Implement adding/removing transactions
- Create transaction model class
- Use setState for UI updates after transactions change
- Project component: Working "Add Transaction" button with state updates
Lab 5: Forms and Input
- Build transaction entry form with validation
- Add category selection dropdown
- Implement date picker integration
- Project component: Complete transaction entry form with validation
Lab 6: Navigation
- Create transaction detail screen
- Implement bottom navigation bar for different views
- Add settings/profile screen
- Project component: Multi-screen navigation structure
Lab 7: Local Storage
- Design SQLite database schema for transactions
- Implement CRUD operations for transactions
- Add persistent storage for user preferences
- Project component: Full local data persistence
Lab 8: RESTful APIs
- Integrate currency conversion API (https://frankfurter.dev/)
- Implement loading indicators and error handling
- Project component: Currency conversion feature
Lab 9: Firebase Integration
- Add user authentication
- Implement cloud backup of transactions
- Create data synchronization between devices
- Project component: User account system with cloud sync
Lab 10: Polish and Deployment
- Implement theming and dark mode
- Add app icons and splash screens
- Optimize performance and run tests
- Project component: Production-ready app with deployment preparation
Lab 1: Introduction to Flutter - Personal Finance Tracker
Objectives
- Set up Flutter development environment
- Create your first Flutter application
- Understand basic Flutter concepts and hot reload
- Build the initial shell of your finance tracker app
Prerequisites
- Basic programming knowledge
- Computer with Windows, macOS, or Linux
- Minimum 8GB RAM recommended
- At least 10GB of free disk space
Setup Instructions
1. Install Flutter SDK
For Windows:
- Download the Flutter SDK from flutter.dev/docs/get-started/install/windows
- Extract the zip file to a location like
C:\src\flutter(avoid spaces in the path) - Add Flutter to your PATH:
- Search for "Environment Variables" in Windows search
- Click "Edit the system environment variables"
- Click "Environment Variables"
- Under "System variables", find and select "Path"
- Click "Edit" and add the path to
flutter\bindirectory - Click "OK" to save
For macOS:
- Download the Flutter SDK from flutter.dev/docs/get-started/install/macos
- Extract the file to a location like
~/development - Add Flutter to your PATH:
- Open Terminal
- Run
nano ~/.zshrcornano ~/.bash_profile(depending on your shell) - Add
export PATH="$PATH:[PATH_TO_FLUTTER_DIRECTORY]/flutter/bin" - Save and exit (Ctrl+X, then Y)
- Run
source ~/.zshrcorsource ~/.bash_profile
For Linux:
- Download the Flutter SDK from flutter.dev/docs/get-started/install/linux
- Extract to a location like
~/development/flutter - Add Flutter to your PATH:
- Run
nano ~/.bashrc - Add
export PATH="$PATH:[PATH_TO_FLUTTER_DIRECTORY]/flutter/bin" - Save and exit (Ctrl+X, then Y)
- Run
source ~/.bashrc
- Run
2. Install an IDE
Choose one of the following:
Visual Studio Code (Recommended for beginners):
- Download and install from code.visualstudio.com
- Open VS Code and install the Flutter extension:
- Click on Extensions icon on the left sidebar
- Search for "Flutter"
- Click "Install" on the Flutter extension by Dart Code
Android Studio:
- Download and install from developer.android.com/studio
- Open Android Studio and install the Flutter plugin:
- Go to Preferences/Settings > Plugins
- Search for "Flutter"
- Click "Install" and restart Android Studio when prompted
3. Set up an Emulator or Connect a Physical Device
Android Emulator:
- Open Android Studio
- Click on "AVD Manager" (Android Virtual Device Manager)
- Click "Create Virtual Device"
- Select a phone (like Pixel 8) and click "Next"
- Download a system image (recommend API 30 or newer) and click "Next"
- Name your emulator and click "Finish"
iOS Simulator (macOS only):
- Install Xcode from the App Store
- Open Xcode and accept the license agreement
- Install additional components if prompted
- Open Terminal and run
open -a Simulator
Physical Device:
- For Android: Enable Developer Options and USB Debugging on your device
- For iOS: Register as an Apple Developer and set up your device in Xcode
4. Verify Installation
- Open Terminal or Command Prompt
- Run
flutter doctor - Address any issues that appear with red X marks
- When most items have green checkmarks, you're ready to proceed
Creating Your First Flutter App
1. Create a New Flutter Project
- Open Terminal or Command Prompt
- Navigate to your desired project location
- Run the following command:
flutter create personal_finance_tracker - Wait for the project to be created
- Navigate into the project directory:
cd personal_finance_tracker
2. Explore the Project Structure
Open the project in your IDE and explore the key files:
lib/main.dart- Main entry point of your applicationpubspec.yaml- Project configuration and dependenciesandroid/andios/- Platform-specific code
3. Run the Default App
- Start your emulator or connect your physical device
- In Terminal/Command Prompt (in your project directory), run:
flutter run - Wait for the app to compile and launch
- You should see the default Flutter counter app
4. Understand Hot Reload
- With the app running, open
lib/main.dartin your IDE - Find the text
'Flutter Demo'and change it to'My Finance Tracker' - Save the file
- Notice how the app updates immediately without restarting - this is hot reload!
- Try changing the colors or counter text to experiment further
Building the Finance Tracker Shell
Now that you understand the basics, let's create the initial shell for your finance tracker app.
1. Clean the Default App
Replace the entire content of lib/main.dart with this simplified code:
import 'package:flutter/material.dart';
void main() {
runApp(const PersonalFinanceApp());
}
class PersonalFinanceApp extends StatelessWidget {
const PersonalFinanceApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Personal Finance Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
home: const DashboardScreen(),
);
}
}
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Personal Finance Tracker'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_balance_wallet, size: 100, color: Colors.green),
SizedBox(height: 20),
Text(
'Welcome to Your Finance Tracker!',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 10),
Text(
'Track your expenses and income',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
),
);
}
}
2. Save and Run the App
- Save the file
- If the app is already running, it will hot reload
- If not, run
flutter runin the terminal
3. Understand the Code
Let's break down what this code does:
-
void main()- The entry point of the app that runs our main app widget -
PersonalFinanceApp- A StatelessWidget that sets up the MaterialAppMaterialApp- Provides the overall structure and themetitle- The title shown in recent apps on mobile devicestheme- The color scheme and visual propertieshome- The main widget to display
-
DashboardScreen- A StatelessWidget that creates our main screenScaffold- Provides the basic material design layoutAppBar- The top bar with the app titlebody- The main content area
Lab Exercises
Exercise 1: Customize the App Theme
Change the primary color to a color of your choice. Try different colors from the Colors class, such as Colors.blue, Colors.purple, or Colors.teal.
Exercise 2: Add an App Icon to the AppBar
Modify the AppBar to include an icon. Add this before the title:
leading: Icon(Icons.account_balance_wallet),
Exercise 3: Add a Simple Action Button
Add a floating action button that will eventually be used to add transactions:
floatingActionButton: FloatingActionButton(
onPressed: () {
// Will implement this in future labs
debugPrint('Button pressed!');
},
tooltip: 'Add Transaction',
child: Icon(Icons.add),
),
Exercise 4: Create a Simple Footer
Add a bottom navigation bar that will be expanded in future labs:
bottomNavigationBar: BottomNavigationBar(
items: [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.pie_chart),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
currentIndex: 0,
onTap: (index) {
// Will implement navigation in future labs
debugPrint('Tapped item $index');
},
),
Deliverables
By the end of this lab, you should have:
- A working Flutter development environment
- A basic understanding of Flutter project structure
- Experience with hot reload
- A simple app shell for your finance tracker with:
- Customized app title
- Basic theme
- App bar
- Floating action button
- Bottom navigation bar
Troubleshooting Common Issues
-
Flutter Doctor Shows Errors: Follow the suggestions provided by the
flutter doctorcommand to resolve each issue. -
App Won't Run: Ensure your emulator is running or device is connected and recognized by running
flutter devices. -
Hot Reload Not Working: Make sure you're saving the file and that there are no syntax errors.
-
Missing Dependencies: If you see errors about missing packages, run
flutter pub getin your project directory.
Next Steps
In the next lab, we'll expand our app by adding widgets and layouts to create a proper dashboard for our finance tracker.
Lab 2: Widgets and Layout - Personal Finance Tracker
Objectives
- Understand basic Flutter layout widgets
- Design a dashboard layout with information cards
- Implement a balance overview section
- Create income and expense summary cards
Prerequisites
- Completed Lab 1
- Working Flutter development environment
- Basic understanding of Dart syntax
Understanding Flutter Layout Widgets
1. Container Widget
The Container widget is a convenience widget that combines common painting, positioning, and sizing widgets.
Key Properties:
child: The widget inside this containerpadding: Empty space inside the container around the childmargin: Empty space outside the containerdecoration: Visual styling (borders, shadows, background color)widthandheight: Size constraintsalignment: How to position the child inside the container
Example:
Container(
width: 200,
height: 100,
margin: EdgeInsets.all(10),
padding: EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 5,
offset: Offset(0, 2),
),
],
),
child: Text('Balance'),
)
2. Row and Column Widgets
Row and Column are the primary layout widgets for horizontal and vertical arrangements.
Key Properties for Both:
children: List of widgets to displaymainAxisAlignment: How to align children along the main axis (horizontal for Row, vertical for Column)crossAxisAlignment: How to align children along the cross axis (vertical for Row, horizontal for Column)mainAxisSize: How much space to take along the main axis (min or max)
Row Example:
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Income'),
Text('\$1,200'),
],
)
Column Example:
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Expenses'),
Text('\$800'),
Text('Shopping, Food, Transport'),
],
)
3. Padding Widget
The Padding widget adds empty space around its child.
Key Properties:
padding: The amount of space to addchild: The widget to pad
Example:
Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('Balance Overview'),
)
4. SizedBox Widget
The SizedBox widget is used to create fixed-size spaces or to constrain child widgets.
Key Properties:
widthandheight: Fixed dimensionschild: Optional widget to constrain
Example:
// As a spacer:
SizedBox(height: 20)
// To constrain a child:
SizedBox(
width: double.infinity,
height: 100,
child: Card(child: Center(child: Text('Balance'))),
)
5. Card Widget
The Card widget creates a material design card with elevation and rounded corners.
Key Properties:
child: The content of the cardelevation: How high the card is raised (affects shadow)shape: The shape of the cardcolor: Background color
Example:
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Card Content'),
),
)
6. Expanded and Flexible Widgets
These widgets help distribute available space among children of a Row or Column.
Expanded forces the child to take all available space:
Row(
children: [
Expanded(
child: Container(color: Colors.red, height: 100),
),
Expanded(
child: Container(color: Colors.blue, height: 100),
),
],
)
Flexible allows the child to shrink below its ideal size if needed:
Row(
children: [
Flexible(
flex: 2, // Takes 2/3 of available space
child: Container(color: Colors.red, height: 100),
),
Flexible(
flex: 1, // Takes 1/3 of available space
child: Container(color: Colors.blue, height: 100),
),
],
)
Implementing the Finance Dashboard
Let's build the finance dashboard step by step:
1. Update the lib/main.dart File
First, let's modify our DashboardScreen class:
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Personal Finance Tracker'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
leading: const Icon(Icons.account_balance_wallet),
),
body: const FinanceDashboard(),
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.pie_chart),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
currentIndex: 0,
onTap: (index) {
debugPrint('Tapped item $index');
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
debugPrint('Add transaction button pressed');
},
tooltip: 'Add Transaction',
child: const Icon(Icons.add),
),
);
}
}
2. Create the Dashboard Layout
Now, let's add the FinanceDashboard widget below the DashboardScreen class:
class FinanceDashboard extends StatelessWidget {
const FinanceDashboard({super.key});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header section
const Text(
'My Balance',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Balance Card
BalanceOverviewCard(),
const SizedBox(height: 24),
// Income & Expenses Row
const Text(
'Summary',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Income and Expense cards
Row(
children: [
Expanded(
child: SummaryCard(
title: 'Income',
amount: 1250.00,
icon: Icons.arrow_upward,
color: Colors.green,
),
),
const SizedBox(width: 16),
Expanded(
child: SummaryCard(
title: 'Expenses',
amount: 850.00,
icon: Icons.arrow_downward,
color: Colors.red,
),
),
],
),
const SizedBox(height: 24),
// Recent Transactions Header
const Text(
'Recent Transactions',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Placeholder for transactions (will implement in Lab 3)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: Text(
'Your transactions will appear here',
style: TextStyle(color: Colors.grey),
),
),
),
],
),
);
}
}
3. Create the Balance Overview Card
Add the BalanceOverviewCard widget below:
class BalanceOverviewCard extends StatelessWidget {
const BalanceOverviewCard({super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Current Balance',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(
Icons.arrow_upward,
size: 14,
color: Colors.green,
),
SizedBox(width: 4),
Text(
'2.5%',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Text(
'\$400.00',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildBalanceDetail(
context,
Icons.calendar_today,
'This Month',
'\$1,250.00',
),
_buildBalanceDetail(
context,
Icons.account_balance_wallet,
'This Year',
'\$12,500.00',
),
],
),
],
),
),
);
}
Widget _buildBalanceDetail(
BuildContext context,
IconData icon,
String label,
String amount,
) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
Text(
amount,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
],
);
}
}
4. Create the Summary Cards
Finally, add the SummaryCard widget:
class SummaryCard extends StatelessWidget {
final String title;
final double amount;
final IconData icon;
final Color color;
const SummaryCard({
super.key,
required this.title,
required this.amount,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Text(
'\$${amount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'This month',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
);
}
}
Understanding the Implementation
Let's break down what we've built:
- SingleChildScrollView: Makes the content scrollable if it's larger than the screen
- Column and Row Combinations: Organizes widgets vertically and horizontally
- Card Widgets: Creates elevated material design cards for information display
- Expanded Widgets: Distributes space evenly in the income/expense row
- Container with Decoration: Adds styling to various elements
- SizedBox: Creates consistent spacing between UI elements
Our dashboard has three main sections:
- A balance overview card at the top
- Income and expense summary cards in the middle
- A placeholder for recent transactions (to be implemented in Lab 3)
Exercises
Exercise 1: Add a Savings Goal Card
Create a new card below the income and expense cards to display a savings goal:
class SavingsGoalCard extends StatelessWidget {
const SavingsGoalCard({super.key});
@override
Widget build(BuildContext context) {
// Goal progress (70%)
const double progress = 0.7;
return Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.flag, color: Colors.blue),
SizedBox(width: 8),
Text(
'Savings Goal',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
const Text(
'New Laptop',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('\$700 of \$1,000'),
Text(
'${(progress * 100).toInt()}%',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
),
);
}
}
Add this card to the dashboard by inserting it between the income/expense cards and the recent transactions header:
// After the income and expense row
const SizedBox(height: 16),
const SavingsGoalCard(),
const SizedBox(height: 24),
Exercise 2: Add Custom Styling to the Balance Card
Enhance the BalanceOverviewCard with a gradient background. Modify the card's container:
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.inversePrimary,
Theme.of(context).colorScheme.primary.inversePrimary(0.7),
],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
// ... (existing code)
),
),
)
Remember to update the text colors to white for better contrast:
const Text(
'Current Balance',
style: TextStyle(
fontSize: 16,
color: Colors.white,
),
),
Exercise 3: Add a Refresh Button
Add a refresh button to the AppBar that will eventually update the dashboard data:
appBar: AppBar(
title: const Text('Personal Finance Tracker'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
leading: const Icon(Icons.account_balance_wallet),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
debugPrint('Refresh button pressed');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Refreshing data...')),
);
},
),
],
),
Exercises 4: Organize your code
- Move the
DashboardScreenwidget to a new file nameddashboard_screen.dart. - Move every widget in the
lib/main.dartfile to its own file in thelib/widgets/directory.
Deliverables
By the end of this lab, you should have:
- A comprehensive understanding of Flutter layout widgets
- A functional dashboard with:
- Balance overview card
- Income and expense summary cards
- Layout for recent transactions (placeholder)
- Optional savings goal card
Troubleshooting Common Issues
-
Layout Overflow Errors: If you see yellow/black stripes on the screen, it means a widget is trying to take more space than available. Use
Expanded,Flexible, or constrain the widget's size. -
Text Overflow: When text is too long for its container, use
overflow: TextOverflow.ellipsisin TextStyle to show "..." instead of error stripes. -
Card Padding Issues: Remember that Cards already have some built-in padding. If your layout looks off, check if you're adding unnecessary padding.
-
Colors Not Showing: When using
Theme.of(context), ensure it's not called in a constructor and only within build methods. -
Rendering Issues: If the UI doesn't update as expected, try using Flutter's "Hot Restart" instead of just "Hot Reload".
Next Steps
In the next lab, we'll implement the transaction list using ListTile widgets and create a custom transaction card design. We'll also learn about scrollable lists and more advanced UI components.
Lab 3: Lists and UI Components - Personal Finance Tracker
Objectives
- Understand different types of ListView widgets
- Create a transaction list using ListTile widgets
- Implement a custom transaction card design
- Build a scrollable transaction history list
Prerequisites
- Completed Lab 2
- Basic understanding of Flutter widgets and layouts
- Familiarity with Dart classes and collections
Understanding ListView in Flutter
What is ListView?
ListView is a scrollable list of widgets arranged linearly. It's one of Flutter's most commonly used widgets for displaying a scrollable collection of children.
Types of ListViews
1. ListView
The basic ListView constructor creates a scrollable, linear array of widgets.
ListView(
children: [
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
ListTile(title: Text('Item 3')),
],
)
This approach is good for a small, fixed number of children.
2. ListView.builder
Creates a scrollable, linear array of widgets that are built on demand. This is more efficient for long lists because it only builds items that are currently visible.
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
);
},
)
Use this when you have a large or infinite list.
3. ListView.separated
Similar to ListView.builder but allows you to specify a separator widget between each item.
ListView.separated(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
);
},
separatorBuilder: (context, index) {
return Divider();
},
)
Good for lists where you need visual separation between items.
4. ListView.custom
Provides the most customization options, allowing you to define custom child model objects.
ListView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 100,
),
)
Reserved for advanced use cases.
ListTile Widget
ListTile is a specialized row widget designed for items in a ListView.
Key Properties:
leading: Widget to display before the titletitle: The primary content of the list itemsubtitle: Additional text displayed below the titletrailing: Widget to display after the titleonTap: Callback function when the tile is tappeddense: Whether to make the tile more compactisThreeLine: Whether the subtitle should be displayed on a third line
ListTile(
leading: Icon(Icons.shopping_cart),
title: Text('Groceries'),
subtitle: Text('March 15, 2023'),
trailing: Text('\$45.00'),
onTap: () {
print('Tile tapped!');
},
)
Implementing the Transaction List
Let's implement the transaction list for our finance tracker app:
1. Create a Transaction Model
First, let's create a model to represent a financial transaction. Create a new file lib/models/transaction.dart:
class Transaction {
final String id;
final String title;
final double amount;
final DateTime date;
final String category;
final bool isExpense;
Transaction({
required this.id,
required this.title,
required this.amount,
required this.date,
required this.category,
required this.isExpense,
});
}
2. Create a Transaction List Widget
Now, let's create a widget for the transaction list. Create a new file lib/widgets/transaction_list.dart:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // Add this package with: flutter pub add intl
import '../models/transaction.dart';
class TransactionList extends StatelessWidget {
final List<Transaction> transactions;
final Function(String) onDeleteTransaction;
const TransactionList({
super.key,
required this.transactions,
required this.onDeleteTransaction,
});
@override
Widget build(BuildContext context) {
if (transactions.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.receipt_long,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'No transactions yet!',
style: TextStyle(
fontSize: 18,
color: Colors.grey.shade600,
),
),
],
),
);
}
return ListView.builder(
itemCount: transactions.length,
itemBuilder: (ctx, index) {
return TransactionCard(
transaction: transactions[index],
onDelete: onDeleteTransaction,
);
},
);
}
}
3. Implement a Custom Transaction Card
Next, let's create a custom card for displaying transactions. Add this class to the transaction_list.dart file:
class TransactionCard extends StatelessWidget {
final Transaction transaction;
final Function(String) onDelete;
const TransactionCard({
super.key,
required this.transaction,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
elevation: 2,
child: ListTile(
leading: CircleAvatar(
radius: 24,
backgroundColor: transaction.isExpense
? Colors.red.shade100
: Colors.green.shade100,
child: Icon(
getCategoryIcon(transaction.category),
color: transaction.isExpense ? Colors.red : Colors.green,
),
),
title: Text(
transaction.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${transaction.category} • ${DateFormat.yMMMd().format(transaction.date)}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${transaction.isExpense ? '-' : '+'}\$${transaction.amount.toStringAsFixed(2)}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: transaction.isExpense ? Colors.red : Colors.green,
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.grey),
onPressed: () => onDelete(transaction.id),
),
],
),
),
);
}
IconData getCategoryIcon(String category) {
switch (category.toLowerCase()) {
case 'food':
return Icons.restaurant;
case 'shopping':
return Icons.shopping_bag;
case 'transport':
return Icons.directions_car;
case 'entertainment':
return Icons.movie;
case 'utilities':
return Icons.receipt;
case 'salary':
return Icons.work;
case 'gift':
return Icons.card_giftcard;
default:
return Icons.attach_money;
}
}
}
4. Update the Main Dashboard
Now let's update our dashboard to include the transaction list. Modify the FinanceDashboard class in main.dart:
class FinanceDashboard extends StatefulWidget {
const FinanceDashboard({super.key});
@override
State<FinanceDashboard> createState() => _FinanceDashboardState();
}
class _FinanceDashboardState extends State<FinanceDashboard> {
// Sample transaction data
final List<Transaction> _transactions = [
Transaction(
id: 't1',
title: 'Grocery Shopping',
amount: 45.99,
date: DateTime.now().subtract(const Duration(days: 1)),
category: 'Food',
isExpense: true,
),
Transaction(
id: 't2',
title: 'Monthly Salary',
amount: 1500.00,
date: DateTime.now().subtract(const Duration(days: 3)),
category: 'Salary',
isExpense: false,
),
Transaction(
id: 't3',
title: 'New Headphones',
amount: 99.99,
date: DateTime.now().subtract(const Duration(days: 5)),
category: 'Shopping',
isExpense: true,
),
Transaction(
id: 't4',
title: 'Restaurant Dinner',
amount: 35.50,
date: DateTime.now().subtract(const Duration(days: 7)),
category: 'Food',
isExpense: true,
),
];
void _deleteTransaction(String id) {
setState(() {
_transactions.removeWhere((tx) => tx.id == id);
});
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header section
const Text(
'My Balance',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Balance Card
const BalanceOverviewCard(),
const SizedBox(height: 24),
// Income & Expenses Row
const Text(
'Summary',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Income and Expense cards
const Row(
children: [
Expanded(
child: SummaryCard(
title: 'Income',
amount: 1250.00,
icon: Icons.arrow_upward,
color: Colors.green,
),
),
SizedBox(width: 16),
Expanded(
child: SummaryCard(
title: 'Expenses',
amount: 850.00,
icon: Icons.arrow_downward,
color: Colors.red,
),
),
],
),
const SizedBox(height: 24),
// Recent Transactions Header
const Text(
'Recent Transactions',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Transaction List
SizedBox(
height: 400, // Fixed height for the list
child: TransactionList(
transactions: _transactions,
onDeleteTransaction: _deleteTransaction,
),
),
],
),
);
}
}
5. Add Grouped Transaction List (Advanced)
For a more polished look, let's create a version of the transaction list that groups transactions by date. Create a new file lib/widgets/grouped_transaction_list.dart:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/transaction.dart';
class GroupedTransactionList extends StatelessWidget {
final List<Transaction> transactions;
final Function(String) onDeleteTransaction;
const GroupedTransactionList({
super.key,
required this.transactions,
required this.onDeleteTransaction,
});
@override
Widget build(BuildContext context) {
// Sort transactions by date (newest first)
final sortedTransactions = List.of(transactions)
..sort((a, b) => b.date.compareTo(a.date));
// Group transactions by date
final Map<String, List<Transaction>> groupedTransactions = {};
for (var tx in sortedTransactions) {
final dateKey = DateFormat.yMMMd().format(tx.date);
if (!groupedTransactions.containsKey(dateKey)) {
groupedTransactions[dateKey] = [];
}
groupedTransactions[dateKey]!.add(tx);
}
// Convert to list of date-transactions pairs
final groupList = groupedTransactions.entries.toList();
return ListView.builder(
itemCount: groupList.length,
itemBuilder: (ctx, index) {
final dateKey = groupList[index].key;
final dateTransactions = groupList[index].value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
dateKey,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
),
...dateTransactions.map((tx) {
return TransactionCard(
transaction: tx,
onDelete: onDeleteTransaction,
);
}).toList(),
const SizedBox(height: 8),
],
);
},
);
}
}
To use this grouped list, replace the TransactionList widget in the dashboard with:
SizedBox(
height: 400,
child: GroupedTransactionList(
transactions: _transactions,
onDeleteTransaction: _deleteTransaction,
),
),
Exercises
Exercise 1: Implement a Transaction Filter
Add a dropdown button to filter transactions by category:
- Add this at the top of the
_FinanceDashboardStateclass
String? _selectedCategory;
// Add this method to the class
List<Transaction> get _filteredTransactions {
if (_selectedCategory == null) {
return _transactions;
}
return _transactions.where((tx) => tx.category == _selectedCategory).toList();
}
- Add this above the transaction list
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Transactions',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
DropdownButton<String?>(
hint: const Text('All Categories'),
value: _selectedCategory,
onChanged: (newValue) {
setState(() {
_selectedCategory = newValue;
});
},
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('All Categories'),
),
...{..._transactions.map((tx) => tx.category)}.map(
(category) => DropdownMenuItem<String>(
value: category,
child: Text(category),
),
),
],
),
],
),
- Update the
TransactionListto use filtered transactions
SizedBox(
height: 400,
child: TransactionList(
transactions: _filteredTransactions,
onDeleteTransaction: _deleteTransaction,
),
),
Exercise 2: Implement a Search Bar
Add a search bar to filter transactions by title:
- Add this to the
_FinanceDashboardStateclass
String _searchQuery = '';
- Update the
_filteredTransactionsgetter
List<Transaction> get _filteredTransactions {
var filtered = _transactions;
if (_selectedCategory != null) {
filtered = filtered.where((tx) => tx.category == _selectedCategory).toList();
}
if (_searchQuery.isNotEmpty) {
filtered = filtered.where(
(tx) => tx.title.toLowerCase().contains(_searchQuery.toLowerCase())
).toList();
}
return filtered;
}
- Add this above the
dropdownfilter
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
decoration: InputDecoration(
hintText: 'Search transactions...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
Common Issues and Troubleshooting
-
List items not showing: Ensure your ListView has a defined height or is inside a widget that constrains its height (like Expanded or SizedBox).
-
Overflow errors in ListTile: ListTile has a fixed height. For content that may overflow, consider using a custom layout instead.
-
Performance issues with long lists: Make sure you're using ListView.builder rather than the default ListView constructor for long lists.
-
ListView inside Column causing errors: ListView tries to be infinitely tall inside a Column. Wrap it in a SizedBox with a fixed height or an Expanded widget.
-
Items disappearing when scrolling: This might happen if your ListView.builder doesn't properly reuse widgets. Check your itemBuilder function.
Next Steps
In the next lab, we'll focus on state management techniques in Flutter. We'll learn how to properly manage the state of our app, implement adding and removing transactions, and ensure UI updates correctly reflect these changes.
Don't forget to add the intl package to your project by running:
flutter pub add intl
Lab 4: State Management - Personal Finance Tracker
Objectives
By the end of this lab, students will:
- Understand the difference between Stateless and Stateful widgets
- Learn how to use the
setState()method to update the UI - Implement adding and removing transactions in the finance tracker
- See how state changes are reflected in the app instantly
Prerequisites
- Completed Lab 3 (transaction list implementation)
- Basic understanding of Dart classes and Flutter widgets
- Familiarity with the
Transactionmodel from Lab 3
1. Understanding State in Flutter
In Flutter, state means data that can change over time.
- If your widget never changes after it’s built → use a StatelessWidget
- If your widget needs to change (e.g., after a button press, form submission, or API call) → use a StatefulWidget
1.1 StatelessWidget
A StatelessWidget is immutable — once it’s built, it cannot change its data.
Example:
class MyStatelessWidget extends StatelessWidget {
const MyStatelessWidget({super.key});
@override
Widget build(BuildContext context) {
return const Text('Hello, I never change!');
}
}
- The text
"Hello, I never change!"will always be the same. - If you want to change it, you must rebuild the whole widget from outside.
1.2 StatefulWidget
A StatefulWidget can change its data while the app is running.
Example:
class MyStatefulWidget extends StatefulWidget {
const MyStatefulWidget({super.key});
@override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
int counter = 0;
void _incrementCounter() {
setState(() {
counter++; // Change the state
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: $counter'),
ElevatedButton(
onPressed: _incrementCounter,
child: const Text('Increase'),
),
],
);
}
}
Here’s what happens:
- The widget starts with
counter = 0 - When the button is pressed,
_incrementCounter()is called - Inside
_incrementCounter(), we callsetState() setState()tells Flutter: "Hey, my data changed — rebuild the UI"- Flutter rebuilds the widget with the new value
2. The setState() Method
setState() is the simplest way to update the UI in Flutter.
Syntax:
setState(() {
// Change your variables here
});
Rules for using setState():
- Only call it inside a StatefulWidget’s State class
- Keep the code inside
setState()short — just update variables - Flutter will rebuild only the widget where the state changed, not the whole app
3. Applying State Management to Our Finance Tracker
In Lab 3, we had a hardcoded list of transactions.
Now, we’ll make it dynamic — so students can add and remove transactions.
3.1 Updating the Dashboard to be Stateful
In main.dart, make sure the FinanceDashboard is a StatefulWidget:
class FinanceDashboard extends StatefulWidget {
const FinanceDashboard({super.key});
@override
State<FinanceDashboard> createState() => _FinanceDashboardState();
}
3.2 Adding State Variables
Inside _FinanceDashboardState, we are storing our transactions in a list:
class _FinanceDashboardState extends State<FinanceDashboard> {
final List<Transaction> _transactions = [
Transaction(
id: 't1',
title: 'Grocery Shopping',
amount: 45.99,
date: DateTime.now().subtract(const Duration(days: 1)),
category: 'Food',
isExpense: true,
),
Transaction(
id: 't2',
title: 'Monthly Salary',
amount: 1500.00,
date: DateTime.now().subtract(const Duration(days: 3)),
category: 'Salary',
isExpense: false,
),
Transaction(
id: 't3',
title: 'New Headphones',
amount: 99.99,
date: DateTime.now().subtract(const Duration(days: 5)),
category: 'Shopping',
isExpense: true,
),
Transaction(
id: 't4',
title: 'Restaurant Dinner',
amount: 35.50,
date: DateTime.now().subtract(const Duration(days: 7)),
category: 'Food',
isExpense: true,
),
];
3.3 Adding a New Transaction
We’ll create a method _addTransaction() in _FinanceDashboardState:
void _addTransaction(String title, double amount, String category, bool isExpense) {
final newTx = Transaction(
id: DateTime.now().toString(),
title: title,
amount: amount,
date: DateTime.now(),
category: category,
isExpense: isExpense,
);
setState(() {
_transactions.add(newTx);
});
}
Note: We call setState() inside the method to update the UI as soon as we add a new transaction.
3.4 Removing a Transaction
We already had this in Lab 3, but here’s the method again:
void _deleteTransaction(String id) {
setState(() {
_transactions.removeWhere((tx) => tx.id == id);
});
}
Note: removeWhere() is a built-in method in Dart that removes all elements from a list that match a condition.
4. Creating a Simple Add Transaction Form
We’ll make a popup form when the user taps the Floating Action Button.
4.1 The Form Widget
Add this method inside _FinanceDashboardState:
void _startAddTransaction(BuildContext context) {
String title = '';
String category = '';
String amountStr = '';
bool isExpense = true;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(labelText: 'Title'),
onChanged: (value) => title = value,
),
TextField(
decoration: const InputDecoration(labelText: 'Amount'),
keyboardType: TextInputType.number,
onChanged: (value) => amountStr = value,
),
TextField(
decoration: const InputDecoration(labelText: 'Category'),
onChanged: (value) => category = value,
),
SwitchListTile(
title: const Text('Is Expense?'),
value: isExpense,
onChanged: (value) {
setState(() {
isExpense = value;
});
},
),
ElevatedButton(
onPressed: () {
if (title.isEmpty || amountStr.isEmpty || category.isEmpty) {
return;
}
_addTransaction(
title,
double.parse(amountStr),
category,
isExpense,
);
Navigator.of(context).pop();
},
child: const Text('Add Transaction'),
),
],
),
);
},
);
}
4.2 Connecting the Floating Action Button
In DashboardScreen, update the FAB:
floatingActionButton: FloatingActionButton(
onPressed: () => _startAddTransaction(context),
tooltip: 'Add Transaction',
child: const Icon(Icons.add),
),
But since the FAB is in DashboardScreen and _startAddTransaction is in FinanceDashboard,
we can move the FAB into FinanceDashboard for simplicity in this lab.
5. Final FinanceDashboard with State
Here’s the simplified version:
class FinanceDashboard extends StatefulWidget {
const FinanceDashboard({super.key});
@override
State<FinanceDashboard> createState() => _FinanceDashboardState();
}
class _FinanceDashboardState extends State<FinanceDashboard> {
final List<Transaction> _transactions = [];
void _addTransaction(String title, double amount, String category, bool isExpense) {
final newTx = Transaction(
id: DateTime.now().toString(),
title: title,
amount: amount,
date: DateTime.now(),
category: category,
isExpense: isExpense,
);
setState(() {
_transactions.add(newTx);
});
}
void _deleteTransaction(String id) {
setState(() {
_transactions.removeWhere((tx) => tx.id == id);
});
}
void _startAddTransaction(BuildContext context) {
String title = '';
String category = '';
String amountStr = '';
bool isExpense = true;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(labelText: 'Title'),
onChanged: (value) => title = value,
),
TextField(
decoration: const InputDecoration(labelText: 'Amount'),
keyboardType: TextInputType.number,
onChanged: (value) => amountStr = value,
),
TextField(
decoration: const InputDecoration(labelText: 'Category'),
onChanged: (value) => category = value,
),
SwitchListTile(
title: const Text('Is Expense?'),
value: isExpense,
onChanged: (value) {
setState(() {
isExpense = value;
});
},
),
ElevatedButton(
onPressed: () {
if (title.isEmpty || amountStr.isEmpty || category.isEmpty) {
return;
}
_addTransaction(
title,
double.parse(amountStr),
category,
isExpense,
);
Navigator.of(context).pop();
},
child: const Text('Add Transaction'),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const BalanceOverviewCard(),
const SizedBox(height: 24),
const Row(
children: [
Expanded(
child: SummaryCard(
title: 'Income',
amount: 0,
icon: Icons.arrow_upward,
color: Colors.green,
),
),
SizedBox(width: 16),
Expanded(
child: SummaryCard(
title: 'Expenses',
amount: 0,
icon: Icons.arrow_downward,
color: Colors.red,
),
),
],
),
const SizedBox(height: 24),
const Text(
'Recent Transactions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
SizedBox(
height: 400,
child: TransactionList(
transactions: _transactions,
onDeleteTransaction: _deleteTransaction,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _startAddTransaction(context),
child: const Icon(Icons.add),
),
);
}
}
6. Key Takeaways
- StatelessWidget → UI never changes after it’s built
- StatefulWidget → UI can change when data changes
setState()→ tells Flutter to rebuild the widget with new data- Keep
setState()short — only update variables inside it - State is stored in the State class, not in the widget itself
7. Exercises
- Exercise 1: Add a validation message if the user enters a negative amount.
- Exercise 2: Update the Income and Expenses summary cards to calculate totals from
_transactions. - Exercise 3: Sort transactions so the newest appears first.
Lab 5: Forms and Input - Personal Finance Tracker
Objectives
By the end of this lab, students will:
- Understand TextEditingController and how to manage form input
- Learn form validation techniques in Flutter
- Implement dropdown menus for category selection
- Add date picker functionality
- Create a complete transaction entry form with proper validation
- Understand the difference between controlled and uncontrolled inputs
Prerequisites
- Completed Lab 4 (basic state management)
- Understanding of
setState()and StatefulWidget - Familiarity with the Transaction model
1. Understanding Form Input in Flutter
In Flutter, there are two main ways to handle form input:
1.1 Using onChanged (Uncontrolled)
String title = '';
TextField(
decoration: InputDecoration(labelText: 'Title'),
onChanged: (value) => title = value,
)
- The widget doesn't control the input value
- We just listen to changes and store them in a variable
1.2 Using TextEditingController (Controlled)
final TextEditingController _titleController = TextEditingController();
TextField(
controller: _titleController,
decoration: InputDecoration(labelText: 'Title'),
)
// To get the value:
String title = _titleController.text;
- The widget controls the input value
- We can read, write, and clear the input programmatically
- Better for validation and complex forms
2. TextEditingController Deep Dive
2.1 What is TextEditingController?
A TextEditingController is like a remote control for a text field. It allows you to:
- Read the current text:
controller.text - Set the text:
controller.text = 'new value' - Clear the text:
controller.clear() - Listen to changes:
controller.addListener(() => print(controller.text))
2.2 Basic Usage
class MyForm extends StatefulWidget {
@override
State<MyForm> createState() => _MyFormState();
}
class _MyFormState extends State<MyForm> {
final TextEditingController _nameController = TextEditingController();
@override
void dispose() {
// IMPORTANT: Always dispose controllers to prevent memory leaks
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(labelText: 'Name'),
),
ElevatedButton(
onPressed: () {
print('Name: ${_nameController.text}');
_nameController.clear(); // Clear the field
},
child: Text('Submit'),
),
],
);
}
}
Important: Always call dispose() on controllers to prevent memory leaks!
3. Form Validation
3.1 Manual Validation
String? _validateTitle(String? value) {
if (value == null || value.isEmpty) {
return 'Title is required';
}
if (value.length < 3) {
return 'Title must be at least 3 characters';
}
return null; // null means no error
}
3.2 Using Form Widget with GlobalKey
Flutter provides a Form widget that makes validation easier:
class MyForm extends StatefulWidget {
@override
State<MyForm> createState() => _MyFormState();
}
class _MyFormState extends State<MyForm> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField( // Note: TextFormField, not TextField
controller: _titleController,
decoration: InputDecoration(labelText: 'Title'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Title is required';
}
return null;
},
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Form is valid, proceed
print('Title: ${_titleController.text}');
}
},
child: Text('Submit'),
),
],
),
);
}
}
4. Input Widgets Overview
4.1 TextField vs TextFormField
- TextField: Basic text input widget
- TextFormField: TextField + built-in validation support
4.2 Common Input Types
// Text input
TextFormField(
decoration: InputDecoration(labelText: 'Name'),
)
// Number input
TextFormField(
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: 'Amount'),
)
// Email input
TextFormField(
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(labelText: 'Email'),
)
// Password input
TextFormField(
obscureText: true,
decoration: InputDecoration(labelText: 'Password'),
)
// Multiline input
TextFormField(
maxLines: 3,
decoration: InputDecoration(labelText: 'Description'),
)
5. Implementing the Complete Transaction Form
Let's create a proper transaction form with validation. Create a new file lib/widgets/add_transaction_form.dart:
import 'package:flutter/material.dart';
class AddTransactionForm extends StatefulWidget {
final Function(String, double, String, bool) onAddTransaction;
const AddTransactionForm({super.key, required this.onAddTransaction});
@override
State<AddTransactionForm> createState() => _AddTransactionFormState();
}
class _AddTransactionFormState extends State<AddTransactionForm> {
// Form key for validation
final _formKey = GlobalKey<FormState>();
// Controllers for text inputs
final _titleController = TextEditingController();
final _amountController = TextEditingController();
// Form state variables
String _selectedCategory = 'Food';
bool _isExpense = true;
// Available categories
final List<String> _categories = [
'Food',
'Shopping',
'Transport',
'Entertainment',
'Utilities',
'Salary',
'Gift',
'Other',
];
@override
void dispose() {
// Clean up controllers
_titleController.dispose();
_amountController.dispose();
super.dispose();
}
// Method to submit the form
void _submitForm() {
if (!_formKey.currentState!.validate()) {
return; // Form is not valid
}
final title = _titleController.text;
final amount = double.parse(_amountController.text);
widget.onAddTransaction(title, amount, _selectedCategory, _isExpense);
Navigator.of(context).pop(); // Close the form
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Form title
const Text(
'Add New Transaction',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// Title input
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Transaction Title',
hintText: 'e.g., Grocery Shopping',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
if (value.length < 3) {
return 'Title must be at least 3 characters';
}
return null;
},
),
const SizedBox(height: 16),
// Amount input
TextFormField(
controller: _amountController,
decoration: const InputDecoration(
labelText: 'Amount',
hintText: '0.00',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.attach_money),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount';
}
final amount = double.tryParse(value);
if (amount == null) {
return 'Please enter a valid number';
}
if (amount <= 0) {
return 'Amount must be greater than 0';
}
return null;
},
),
const SizedBox(height: 16),
// Category dropdown
DropdownButtonFormField<String>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Category',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: _categories.map((category) {
return DropdownMenuItem(value: category, child: Text(category));
}).toList(),
onChanged: (value) {
setState(() {
_selectedCategory = value!;
});
},
),
const SizedBox(height: 16),
// Income/Expense toggle
Card(
child: SwitchListTile(
title: Text(_isExpense ? 'Expense' : 'Income'),
subtitle: Text(
_isExpense ? 'Money going out' : 'Money coming in',
),
value: _isExpense,
onChanged: (value) {
setState(() {
_isExpense = value;
});
},
secondary: Icon(
_isExpense ? Icons.arrow_downward : Icons.arrow_upward,
color: _isExpense ? Colors.red : Colors.green,
),
),
),
const SizedBox(height: 24),
// Submit button
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text(
'Add Transaction',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}
6. Updating the Dashboard to Use the New Form
Now let's update the FinanceDashboard to use our new form, update the _FinanceDashboardState class:
class _FinanceDashboardState extends State<FinanceDashboard> {
String? _selectedCategory;
// Add this method to the class
List<Transaction> get _filteredTransactions {
var filtered = _transactions;
if (_selectedCategory != null) {
filtered = filtered
.where((tx) => tx.category == _selectedCategory)
.toList();
}
if (_searchQuery.isNotEmpty) {
filtered = filtered
.where(
(tx) => tx.title.toLowerCase().contains(_searchQuery.toLowerCase()),
)
.toList();
}
return filtered;
}
String _searchQuery = '';
// Sample transaction data
final List<Transaction> _transactions = [
Transaction(
id: 't1',
title: 'Grocery Shopping',
amount: 45.99,
date: DateTime.now().subtract(const Duration(days: 1)),
category: 'Food',
isExpense: true,
),
Transaction(
id: 't2',
title: 'Monthly Salary',
amount: 1500.00,
date: DateTime.now().subtract(const Duration(days: 3)),
category: 'Salary',
isExpense: false,
),
Transaction(
id: 't3',
title: 'New Headphones',
amount: 99.99,
date: DateTime.now().subtract(const Duration(days: 5)),
category: 'Shopping',
isExpense: true,
),
Transaction(
id: 't4',
title: 'Restaurant Dinner',
amount: 35.50,
date: DateTime.now().subtract(const Duration(days: 7)),
category: 'Food',
isExpense: true,
),
];
void _deleteTransaction(String id) {
setState(() {
_transactions.removeWhere((tx) => tx.id == id);
});
}
void _addTransaction(
String title,
double amount,
String category,
bool isExpense,
) {
final newTx = Transaction(
id: DateTime.now().toString(),
title: title,
amount: amount,
date: DateTime.now(),
category: category,
isExpense: isExpense,
);
setState(() {
_transactions.add(newTx);
});
}
void _startAddTransaction(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) {
return AddTransactionForm(onAddTransaction: _addTransaction);
},
);
}
// Calculate totals for summary cards
double get _totalIncome {
return _transactions
.where((tx) => !tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
}
double get _totalExpenses {
return _transactions
.where((tx) => tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
}
double get _balance {
return _totalIncome - _totalExpenses;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header section
const Text(
'My Balance',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Balance Card
BalanceOverviewCard(balance: _balance),
const SizedBox(height: 24),
// Income & Expenses Row
const Text(
'Summary',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Income and Expense cards
Row(
children: [
Expanded(
child: SummaryCard(
title: 'Income',
amount: _totalIncome,
icon: Icons.arrow_upward,
color: Colors.green,
),
),
SizedBox(width: 16),
Expanded(
child: SummaryCard(
title: 'Expenses',
amount: _totalExpenses,
icon: Icons.arrow_downward,
color: Colors.red,
),
),
],
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
decoration: InputDecoration(
hintText: 'Search transactions...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
vertical: 0,
horizontal: 16,
),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
// Recent Transactions Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Transactions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
DropdownButton<String?>(
hint: const Text('All Categories'),
value: _selectedCategory,
onChanged: (newValue) {
setState(() {
_selectedCategory = newValue;
});
},
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('All Categories'),
),
...{..._transactions.map((tx) => tx.category)}.map(
(category) => DropdownMenuItem<String>(
value: category,
child: Text(category),
),
),
],
),
],
),
const SizedBox(height: 16),
// Transaction List
SizedBox(
height: 400, // Fixed height for the list
child: GroupedTransactionList(
transactions: _filteredTransactions,
onDeleteTransaction: _deleteTransaction,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _startAddTransaction(context),
child: const Icon(Icons.add),
),
);
}
}
7. Updating the Balance Overview Card
Update the BalanceOverviewCard to accept dynamic balance data. In your existing card widget:
class BalanceOverviewCard extends StatelessWidget {
const BalanceOverviewCard({super.key, required this.balance});
final double balance;
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Current Balance',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.arrow_upward, size: 14, color: Colors.green),
SizedBox(width: 4),
Text(
'2.5%',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Text(
'\$400.00',
style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
],
),
),
);
}
}
8. Form Validation Best Practices
8.1 Common Validation Rules
// Required field
String? validateRequired(String? value) {
if (value == null || value.isEmpty) {
return 'This field is required';
}
return null;
}
// Email validation
String? validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Please enter a valid email';
}
return null;
}
// Number validation
String? validateAmount(String? value) {
if (value == null || value.isEmpty) {
return 'Amount is required';
}
final amount = double.tryParse(value);
if (amount == null) {
return 'Please enter a valid number';
}
if (amount <= 0) {
return 'Amount must be greater than 0';
}
return null;
}
8.2 Real-time Validation
You can validate as the user types:
TextFormField(
controller: _titleController,
decoration: InputDecoration(labelText: 'Title'),
validator: validateRequired,
autovalidateMode: AutovalidateMode.onUserInteraction,
)
9. Exercises
Exercise 1: Add Amount Formatting
Format the amount input to show currency as the user types:
// Add this package: flutter pub add intl
TextFormField(
controller: _amountController,
decoration: const InputDecoration(
labelText: 'Amount',
prefixText: '\$ ',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
onChanged: (value) {
// Format the input as currency
if (value.isNotEmpty) {
final amount = double.tryParse(value);
if (amount != null) {
final formatted = NumberFormat.currency(symbol: '').format(amount);
// Update controller without triggering onChanged again
}
}
},
)
Exercise 2: Add Form Reset
Add a "Clear Form" button that resets all fields:
void _clearForm() {
_titleController.clear();
_amountController.clear();
setState(() {
_selectedCategory = 'Food';
_isExpense = true;
});
}
// Add this button next to the submit button
OutlinedButton(
onPressed: _clearForm,
child: const Text('Clear Form'),
)
10. Common Issues and Troubleshooting
10.1 Controller Memory Leaks
Problem: Not disposing controllers
Solution: Always call dispose() in the State class
10.2 Form Not Validating
Problem: Using TextField instead of TextFormField
Solution: Use TextFormField with a Form widget
10.3 Keyboard Covering Input
Problem: Bottom sheet doesn't adjust for keyboard
Solution: Use MediaQuery.of(context).viewInsets.bottom in padding
10.4 Date Picker Not Updating
Problem: Forgetting to call setState() after date selection
Solution: Always wrap state changes in setState()
11. Key Takeaways
- TextEditingController gives you full control over text inputs
- Form validation prevents bad data from entering your app
- TextFormField + Form widget = easy validation
- DropdownButtonFormField for selecting from predefined options
- Date pickers require
showDatePicker()and state management - Always dispose controllers to prevent memory leaks
- Real-time validation improves user experience
Next Steps
In the next lab, we'll implement navigation between different screens, create a transaction detail view, and add a bottom navigation bar to organize our app into multiple sections.
Lab 6: Navigation - Personal Finance Tracker
Objectives
By the end of this lab, students will:
- Understand different types of navigation in Flutter
- Learn how to navigate between screens using Navigator
- Implement bottom navigation bar for app sections
- Create a transaction detail screen
- Understand the navigation stack concept
- Learn how to pass data between screens
Prerequisites
- Completed Lab 5 (forms and input)
- Understanding of StatefulWidget and setState()
- Familiarity with the Transaction model and form implementation
1. Understanding Navigation in Flutter
1.1 What is Navigation?
Navigation is how users move between different screens (pages) in your app. Think of it like turning pages in a book or switching between tabs in a web browser.
1.2 The Navigation Stack
Flutter uses a stack-based navigation system. Imagine a stack of cards:
- Push: Add a new screen on top of the stack
- Pop: Remove the current screen and go back to the previous one
┌─────────────────┐
│ Detail Screen │ ← Current screen (top of stack)
├─────────────────┤
│ Home Screen │ ← Previous screen
├─────────────────┤
│ Login Screen │ ← Bottom of stack
└─────────────────┘
1.3 Types of Navigation
1. Push Navigation (Forward)
Moving to a new screen:
Navigator.push(
context,
MaterialPageRoute(builder: (context) => NewScreen()),
);
2. Pop Navigation (Back)
Going back to the previous screen:
Navigator.pop(context);
3. Replace Navigation
Replace current screen with a new one:
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => NewScreen()),
);
4. Named Routes
Navigate using route names (like URLs):
Navigator.pushNamed(context, '/details');
2. Basic Navigation Example
Let's start with a simple example:
// Screen 1
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailScreen()),
);
},
child: Text('Go to Details'),
),
),
);
}
}
// Screen 2
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Details')),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.pop(context); // Go back
},
child: Text('Go Back'),
),
),
);
}
}
3. Passing Data Between Screens
3.1 Passing Data Forward (Push)
// From Screen A to Screen B
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(data: 'Hello World'),
),
);
// Screen B receives the data
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key});
final String data;
const DetailScreen({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Text('Received: $data'),
);
}
}
3.2 Passing Data Back (Pop)
// Screen B sends data back
Navigator.pop(context, 'Result from Screen B');
// Screen A receives the data
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailScreen()),
);
print('Received back: $result');
4. Bottom Navigation Bar
4.1 What is Bottom Navigation Bar?
A bottom navigation bar allows users to switch between different sections of your app. It's like having multiple "tabs" at the bottom of the screen.
4.2 Basic Implementation
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
final List<Widget> _screens = [HomeScreen(), StatsScreen(), SettingsScreen()];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.bar_chart), label: 'Stats'),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
class StatsScreen extends StatelessWidget {
const StatsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(body: Center(child: Text('StatsScreen')));
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(body: Center(child: Text('HomeScreen')));
}
}
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(body: Center(child: Text('SettingsScreen')));
}
}
5. Implementing Navigation in Our Finance Tracker
5.1 Create the Transaction Detail Screen
First, let's create a screen to show transaction details. Create a new file lib/screens/transaction_detail_screen.dart:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/transaction.dart';
class TransactionDetailScreen extends StatelessWidget {
final Transaction transaction;
const TransactionDetailScreen({super.key, required this.transaction});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Transaction Details'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Transaction Type Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: transaction.isExpense
? Colors.red.withOpacity(0.1)
: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
transaction.isExpense ? 'EXPENSE' : 'INCOME',
style: TextStyle(
color: transaction.isExpense ? Colors.red : Colors.green,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
const SizedBox(height: 24),
// Amount
Text(
'${transaction.isExpense ? '-' : '+'}\$${transaction.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: transaction.isExpense ? Colors.red : Colors.green,
),
),
const SizedBox(height: 32),
// Details Card
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_buildDetailRow(
Icons.title,
'Title',
transaction.title,
),
const Divider(),
_buildDetailRow(
Icons.category,
'Category',
transaction.category,
),
const Divider(),
_buildDetailRow(
Icons.calendar_today,
'Date',
DateFormat.yMMMMd().format(transaction.date),
),
const Divider(),
_buildDetailRow(
Icons.access_time,
'Time',
DateFormat.jm().format(transaction.date),
),
],
),
),
),
const SizedBox(height: 32),
// Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
// TODO: Implement edit functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Edit feature coming soon!'),
),
);
},
icon: const Icon(Icons.edit),
label: const Text('Edit'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
_showDeleteDialog(context);
},
icon: const Icon(Icons.delete),
label: const Text('Delete'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
);
}
Widget _buildDetailRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(icon, color: Colors.grey),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Delete Transaction'),
content: const Text(
'Are you sure you want to delete this transaction? This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Close dialog
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(); // Close dialog
Navigator.of(context).pop(transaction.id); // Return to previous screen with delete signal
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Delete'),
),
],
);
},
);
}
}
5.2 Create a Statistics Screen
Create a new file lib/screens/statistics_screen.dart:
import 'package:flutter/material.dart';
import '../models/transaction.dart';
class StatisticsScreen extends StatelessWidget {
final List<Transaction> transactions;
const StatisticsScreen({super.key, required this.transactions});
@override
Widget build(BuildContext context) {
final totalIncome = transactions
.where((tx) => !tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
final totalExpenses = transactions
.where((tx) => tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
final balance = totalIncome - totalExpenses;
// Group expenses by category
final Map<String, double> expensesByCategory = {};
for (var tx in transactions.where((tx) => tx.isExpense)) {
expensesByCategory[tx.category] =
(expensesByCategory[tx.category] ?? 0) + tx.amount;
}
return Scaffold(
appBar: AppBar(
title: const Text('Statistics'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Overview Cards
Row(
children: [
Expanded(
child: _buildStatCard(
'Total Income',
'\$${totalIncome.toStringAsFixed(2)}',
Colors.green,
Icons.arrow_upward,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Total Expenses',
'\$${totalExpenses.toStringAsFixed(2)}',
Colors.red,
Icons.arrow_downward,
),
),
],
),
const SizedBox(height: 16),
_buildStatCard(
'Net Balance',
'\$${balance.toStringAsFixed(2)}',
balance >= 0 ? Colors.green : Colors.red,
balance >= 0 ? Icons.trending_up : Icons.trending_down,
),
const SizedBox(height: 32),
// Expenses by Category
const Text(
'Expenses by Category',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
if (expensesByCategory.isEmpty)
const Center(
child: Text(
'No expenses to show',
style: TextStyle(color: Colors.grey),
),
)
else
...expensesByCategory.entries.map((entry) {
final percentage = (entry.value / totalExpenses * 100);
return _buildCategoryRow(
entry.key,
entry.value,
percentage,
);
}).toList(),
],
),
),
);
}
Widget _buildStatCard(String title, String value, Color color, IconData icon) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
);
}
Widget _buildCategoryRow(String category, double amount, double percentage) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
category,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'\$${amount.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: percentage / 100,
backgroundColor: Colors.grey.shade200,
),
),
const SizedBox(width: 8),
Text('${percentage.toStringAsFixed(1)}%'),
],
),
],
),
),
);
}
}
5.3 Create a Settings Screen
Create a new file lib/screens/settings_screen.dart:
import 'package:flutter/material.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _darkMode = false;
bool _notifications = true;
String _currency = 'USD';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Profile Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.person,
size: 30,
color: Colors.white,
),
),
const SizedBox(width: 16),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'John Doe',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'john.doe@example.com',
style: TextStyle(color: Colors.grey),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Preferences Section
const Text(
'Preferences',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: const Text('Enable dark theme'),
value: _darkMode,
onChanged: (value) {
setState(() {
_darkMode = value;
});
},
secondary: const Icon(Icons.dark_mode),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Notifications'),
subtitle: const Text('Receive transaction alerts'),
value: _notifications,
onChanged: (value) {
setState(() {
_notifications = value;
});
},
secondary: const Icon(Icons.notifications),
),
const Divider(height: 1),
ListTile(
title: const Text('Currency'),
subtitle: Text('Current: $_currency'),
leading: const Icon(Icons.attach_money),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
_showCurrencyDialog();
},
),
],
),
),
const SizedBox(height: 24),
// Actions Section
const Text(
'Actions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
ListTile(
title: const Text('Export Data'),
subtitle: const Text('Download your transaction data'),
leading: const Icon(Icons.download),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export feature coming soon!')),
);
},
),
const Divider(height: 1),
ListTile(
title: const Text('Backup & Sync'),
subtitle: const Text('Sync data across devices'),
leading: const Icon(Icons.cloud_sync),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Backup feature coming soon!')),
);
},
),
const Divider(height: 1),
ListTile(
title: const Text('About'),
subtitle: const Text('App version and info'),
leading: const Icon(Icons.info),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
_showAboutDialog();
},
),
],
),
),
],
),
);
}
void _showCurrencyDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Currency'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: ['USD', 'EUR', 'GBP', 'JPY'].map((currency) {
return RadioListTile<String>(
title: Text(currency),
value: currency,
groupValue: _currency,
onChanged: (value) {
setState(() {
_currency = value!;
});
Navigator.of(context).pop();
},
);
}).toList(),
),
);
},
);
}
void _showAboutDialog() {
showAboutDialog(
context: context,
applicationName: 'Personal Finance Tracker',
applicationVersion: '1.0.0',
applicationIcon: const Icon(Icons.account_balance_wallet),
children: [
const Text('A simple app to track your personal finances.'),
const SizedBox(height: 16),
const Text('Built with Flutter for educational purposes.'),
],
);
}
}
5.4 Update the Main App Structure
Now let's update our main app to use bottom navigation. Update your main.dart:
import 'package:flutter/material.dart';
import 'package:personal_finance_tracker/dashboard_screen.dart';
import 'models/transaction.dart';
import 'screens/statistics_screen.dart';
import 'screens/settings_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Personal Finance Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
// Sample transaction data
final List<Transaction> _transactions = [
Transaction(
id: 't1',
title: 'Grocery Shopping',
amount: 45.99,
date: DateTime.now().subtract(const Duration(days: 1)),
category: 'Food',
isExpense: true,
),
Transaction(
id: 't2',
title: 'Monthly Salary',
amount: 1500.00,
date: DateTime.now().subtract(const Duration(days: 3)),
category: 'Salary',
isExpense: false,
),
Transaction(
id: 't3',
title: 'New Headphones',
amount: 99.99,
date: DateTime.now().subtract(const Duration(days: 5)),
category: 'Shopping',
isExpense: true,
),
];
void _addTransaction(
String title,
double amount,
String category,
bool isExpense,
) {
final newTx = Transaction(
id: DateTime.now().toString(),
title: title,
amount: amount,
date: DateTime.now(),
category: category,
isExpense: isExpense,
);
setState(() {
_transactions.add(newTx);
});
}
void _deleteTransaction(String id) {
setState(() {
_transactions.removeWhere((tx) => tx.id == id);
});
}
@override
Widget build(BuildContext context) {
final List<Widget> screens = [
DashboardScreen(
transactions: _transactions,
onAddTransaction: _addTransaction,
onDeleteTransaction: _deleteTransaction,
),
StatisticsScreen(transactions: _transactions),
const SettingsScreen(),
];
return Scaffold(
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
and update the DashboardScreen in lib/dashboard_screen.dart:
import 'package:flutter/material.dart';
import 'package:personal_finance_tracker/models/transaction.dart';
import 'package:personal_finance_tracker/screens/transaction_detail_screen.dart';
import 'package:personal_finance_tracker/widgets/add_transaction_form.dart';
import 'package:personal_finance_tracker/widgets/transaction_list.dart';
class DashboardScreen extends StatefulWidget {
final List<Transaction> transactions;
final Function(String, double, String, bool) onAddTransaction;
final Function(String) onDeleteTransaction;
const DashboardScreen({
super.key,
required this.transactions,
required this.onAddTransaction,
required this.onDeleteTransaction,
});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
String? _selectedCategory;
String _searchQuery = '';
List<Transaction> get _filteredTransactions {
var filtered = widget.transactions;
if (_selectedCategory != null) {
filtered = filtered
.where((tx) => tx.category == _selectedCategory)
.toList();
}
if (_searchQuery.isNotEmpty) {
filtered = filtered
.where(
(tx) => tx.title.toLowerCase().contains(_searchQuery.toLowerCase()),
)
.toList();
}
return filtered;
}
void _startAddTransaction(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) {
return AddTransactionForm(onAddTransaction: widget.onAddTransaction);
},
);
}
void _navigateToTransactionDetail(Transaction transaction) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TransactionDetailScreen(transaction: transaction),
),
);
// If result is a transaction ID, it means user wants to delete it
if (result != null && result is String) {
widget.onDeleteTransaction(result);
}
}
double get _totalIncome {
return widget.transactions
.where((tx) => !tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
}
double get _totalExpenses {
return widget.transactions
.where((tx) => tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
}
double get _balance {
return _totalIncome - _totalExpenses;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Personal Finance Tracker'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
leading: const Icon(Icons.account_balance_wallet),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Balance Card
BalanceOverviewCard(balance: _balance),
const SizedBox(height: 24),
// Summary Cards
Row(
children: [
Expanded(
child: SummaryCard(
title: 'Income',
amount: _totalIncome,
icon: Icons.arrow_upward,
color: Colors.green,
),
),
const SizedBox(width: 16),
Expanded(
child: SummaryCard(
title: 'Expenses',
amount: _totalExpenses,
icon: Icons.arrow_downward,
color: Colors.red,
),
),
],
),
const SizedBox(height: 24),
// Search Bar
TextField(
decoration: InputDecoration(
hintText: 'Search transactions...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
const SizedBox(height: 16),
// Filter and Title
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Transactions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
DropdownButton<String?>(
hint: const Text('All'),
value: _selectedCategory,
onChanged: (newValue) {
setState(() {
_selectedCategory = newValue;
});
},
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('All Categories'),
),
...{...widget.transactions.map((tx) => tx.category)}.map(
(category) => DropdownMenuItem<String>(
value: category,
child: Text(category),
),
),
],
),
],
),
const SizedBox(height: 16),
// Transaction List
SizedBox(
height: 400,
child: TransactionList(
transactions: _filteredTransactions,
onDeleteTransaction: widget.onDeleteTransaction,
onTransactionTap: _navigateToTransactionDetail,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _startAddTransaction(context),
child: const Icon(Icons.add),
),
);
}
}
class BalanceOverviewCard extends StatelessWidget {
final double balance;
const BalanceOverviewCard({super.key, required this.balance});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Current Balance',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 16),
Text(
'\$${balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: balance >= 0 ? Colors.green : Colors.red,
),
),
],
),
),
);
}
}
class SummaryCard extends StatelessWidget {
final String title;
final double amount;
final IconData icon;
final Color color;
const SummaryCard({
super.key,
required this.title,
required this.amount,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
Text(
'\$${amount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
);
}
}
5.5 Update the Transaction List Widget
Update your TransactionList widget to handle taps. In lib/widgets/transaction_list.dart, modify the TransactionList class:
class TransactionList extends StatelessWidget {
final List<Transaction> transactions;
final Function(String) onDeleteTransaction;
final Function(Transaction)? onTransactionTap;
const TransactionList({
super.key,
required this.transactions,
required this.onDeleteTransaction,
this.onTransactionTap,
});
@override
Widget build(BuildContext context) {
if (transactions.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.receipt_long,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'No transactions yet!',
style: TextStyle(
fontSize: 18,
color: Colors.grey.shade600,
),
),
],
),
);
}
return ListView.builder(
itemCount: transactions.length,
itemBuilder: (ctx, index) {
return TransactionCard(
transaction: transactions[index],
onDelete: onDeleteTransaction,
onTap: onTransactionTap,
);
},
);
}
}
And update the TransactionCard class:
class TransactionCard extends StatelessWidget {
final Transaction transaction;
final Function(String) onDelete;
final Function(Transaction)? onTap;
const TransactionCard({
super.key,
required this.transaction,
required this.onDelete,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
elevation: 2,
child: ListTile(
onTap: onTap != null ? () => onTap!(transaction) : null,
leading: CircleAvatar(
radius: 24,
backgroundColor: transaction.isExpense
? Colors.red.shade100
: Colors.green.shade100,
child: Icon(
getCategoryIcon(transaction.category),
color: transaction.isExpense ? Colors.red : Colors.green,
),
),
title: Text(
transaction.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${transaction.category} • ${DateFormat.yMMMd().format(transaction.date)}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${transaction.isExpense ? '-' : '+'}\$${transaction.amount.toStringAsFixed(2)}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: transaction.isExpense ? Colors.red : Colors.green,
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.grey),
onPressed: () => onDelete(transaction.id),
),
],
),
),
);
}
IconData getCategoryIcon(String category) {
switch (category.toLowerCase()) {
case 'food':
return Icons.restaurant;
case 'shopping':
return Icons.shopping_bag;
case 'transport':
return Icons.directions_car;
case 'entertainment':
return Icons.movie;
case 'utilities':
return Icons.receipt;
case 'salary':
return Icons.work;
case 'gift':
return Icons.card_giftcard;
default:
return Icons.attach_money;
}
}
}
6. Key Navigation Concepts
6.1 Navigation Stack
- Push: Add new screen on top
- Pop: Remove current screen and go back
- Replace: Replace current screen with new one
6.2 Passing Data
- Forward: Pass data when navigating to a new screen
- Backward: Return data when going back
6.3 Bottom Navigation
- Switches between different sections of the app
- Maintains separate navigation stacks for each tab
- Uses
setState()to change the current index
7. Exercises
Exercise 1: Add Navigation Animation
Customize the navigation animation:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => TransactionDetailScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: animation.drive(
Tween(begin: const Offset(1.0, 0.0), end: Offset.zero),
),
child: child,
);
},
),
);
Exercise 2: Add a Search Screen
Create a dedicated search screen accessible from the app bar:
// Add to AppBar actions
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SearchScreen()),
);
},
),
],
Exercise 3: Implement Edit Transaction
Add edit functionality to the transaction detail screen that navigates to an edit form.
8. Common Issues and Troubleshooting
8.1 Navigator Context Issues
Problem: Navigator.of(context) returns null
Solution: Make sure you're using the correct BuildContext
8.2 Bottom Navigation Not Updating
Problem: Tapping bottom nav items doesn't change screens
Solution: Make sure you're calling setState() when changing _currentIndex
8.3 Data Not Passing Between Screens
Problem: Data is null in the destination screen Solution: Check that you're passing data correctly in the constructor
8.4 Back Button Not Working
Problem: Custom back button doesn't work
Solution: Use Navigator.pop(context) or Navigator.of(context).pop()
9. Key Takeaways
- Navigation is how users move between screens in your app
- Navigator.push() goes forward, Navigator.pop() goes back
- Bottom Navigation Bar organizes your app into sections
- Pass data between screens using constructors and return values
- setState() is needed to update the UI when navigation state changes
- Always handle null cases when receiving data from navigation
Next Steps
In the next lab, we'll implement local storage using SQLite to persist our transaction data. We'll learn how to save, retrieve, and manage data locally on the device so that transactions don't disappear when the app is closed.
Lab 7: Local Storage - Personal Finance Tracker
Objectives
By the end of this lab, students will:
- Understand what local storage is and why it's important
- Learn how to use SQLite database in Flutter
- Design a database schema for transactions
- Implement CRUD operations (Create, Read, Update, Delete)
- Store user preferences locally
- Make data persist between app sessions
Prerequisites
- Completed Lab 6 (navigation implementation)
- Understanding of StatefulWidget and setState()
- Familiarity with the Transaction model and app structure
- Basic understanding of databases (helpful but not required)
1. Understanding Local Storage
1.1 What is Local Storage?
Local storage means saving data directly on the user's device (phone/tablet) so that:
- Data survives when the app is closed and reopened
- Data is available without internet connection
- App loads faster because data is stored locally
1.2 Types of Local Storage in Flutter
1. SharedPreferences
- For simple key-value pairs (settings, preferences)
- Good for: user settings, theme preferences, simple flags
- Not good for: complex data, lists of objects
2. SQLite Database
- For complex, structured data with relationships
- Good for: transactions, user data, anything that needs querying
- Best choice for our finance tracker
3. File Storage
- For files, images, documents
- Good for: saving files, caching images
- Not needed for our current app
1.3 Why SQLite for Our App?
Our finance tracker needs to:
- Store many transactions (potentially hundreds)
- Search and filter transactions
- Sort transactions by date, amount, category
- Calculate totals efficiently
SQLite is perfect for this because it's:
- Fast for queries and calculations
- Reliable and battle-tested
- Built into Android and iOS
- Easy to use with Flutter
2. Setting Up SQLite in Flutter
2.1 Add Required Dependencies
Add these packages to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
intl: ^0.19.0
sqflite: ^2.3.0
path: ^1.8.3
shared_preferences: ^2.2.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
Run this command to install the packages:
flutter pub get
or run this command to add and install the packages:
flutter pub add intl sqflite path shared_preferences
2.2 Understanding the Packages
sqflite: SQLite database for Flutterpath: Helps us find the right location to store the database fileshared_preferences: For storing simple settings (theme, currency, etc.)
3. Database Design
3.1 Planning Our Database Schema
Before writing code, let's plan what data we need to store:
Transactions Table:
id(Primary Key) - Unique identifiertitle- Transaction descriptionamount- Money amount (as number)date- When the transaction happenedcategory- Food, Shopping, Salary, etc.isExpense- True for expenses, False for income
Settings Table:
key- Setting name (like "currency", "theme")value- Setting value (like "USD", "dark")
3.2 SQL Commands We'll Use
-- Create transactions table
CREATE TABLE transactions(
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
amount REAL NOT NULL,
date TEXT NOT NULL,
category TEXT NOT NULL,
isExpense INTEGER NOT NULL
);
-- Create settings table
CREATE TABLE settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
Note: SQLite doesn't have a boolean type, so we use INTEGER (0 = false, 1 = true).
4. Creating the Database Helper
4.1 Create the Database Helper Class
Create a new file lib/helpers/database_helper.dart:
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart'
hide Transaction; // to avoid conflict with Transaction class
import '../models/transaction.dart';
class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database;
// Singleton pattern - ensures only one database connection
factory DatabaseHelper() {
return _instance;
}
DatabaseHelper._internal();
// Get database instance
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
// Initialize the database
Future<Database> _initDatabase() async {
// Get the path to store the database
String path = join(await getDatabasesPath(), 'finance_tracker.db');
// Open the database and create tables if they don't exist
return await openDatabase(path, version: 1, onCreate: _createTables);
}
// Create database tables
Future<void> _createTables(Database db, int version) async {
// Create transactions table
await db.execute('''
CREATE TABLE transactions(
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
amount REAL NOT NULL,
date TEXT NOT NULL,
category TEXT NOT NULL,
isExpense INTEGER NOT NULL
)
''');
debugPrint('Database tables created successfully');
}
// CRUD Operations for Transactions
// Create - Insert a new transaction
Future<int> insertTransaction(Transaction transaction) async {
final db = await database;
final result = await db.insert('transactions', {
'id': transaction.id,
'title': transaction.title,
'amount': transaction.amount,
'date': transaction.date.toIso8601String(),
'category': transaction.category,
'isExpense': transaction.isExpense ? 1 : 0,
}, conflictAlgorithm: ConflictAlgorithm.replace);
debugPrint('Transaction inserted: ${transaction.title}');
return result;
}
// Read - Get all transactions
Future<List<Transaction>> getAllTransactions() async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'transactions',
orderBy: 'date DESC', // Newest first
);
return List.generate(maps.length, (i) {
return Transaction(
id: maps[i]['id'],
title: maps[i]['title'],
amount: maps[i]['amount'],
date: DateTime.parse(maps[i]['date']),
category: maps[i]['category'],
isExpense: maps[i]['isExpense'] == 1,
);
});
}
// Read - Get transactions by category
Future<List<Transaction>> getTransactionsByCategory(String category) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'transactions',
where: 'category = ?',
whereArgs: [category],
orderBy: 'date DESC',
);
return List.generate(maps.length, (i) {
return Transaction(
id: maps[i]['id'],
title: maps[i]['title'],
amount: maps[i]['amount'],
date: DateTime.parse(maps[i]['date']),
category: maps[i]['category'],
isExpense: maps[i]['isExpense'] == 1,
);
});
}
// Update - Modify an existing transaction
Future<int> updateTransaction(Transaction transaction) async {
final db = await database;
final result = await db.update(
'transactions',
{
'title': transaction.title,
'amount': transaction.amount,
'date': transaction.date.toIso8601String(),
'category': transaction.category,
'isExpense': transaction.isExpense ? 1 : 0,
},
where: 'id = ?',
whereArgs: [transaction.id],
);
debugPrint('Transaction updated: ${transaction.title}');
return result;
}
// Delete - Remove a transaction
Future<int> deleteTransaction(String id) async {
final db = await database;
final result = await db.delete(
'transactions',
where: 'id = ?',
whereArgs: [id],
);
debugPrint('Transaction deleted: $id');
return result;
}
// Delete all data (for testing or reset)
Future<void> deleteAllData() async {
final db = await database;
await db.delete('transactions');
debugPrint('All data deleted');
}
// Close database connection
Future<void> close() async {
final db = await database;
await db.close();
}
}
4.2 Understanding the Database Helper
Key Concepts:
- Singleton Pattern: Only one database connection exists in the entire app
- Async/Await: Database operations take time, so we use async functions
- CRUD Operations: Create, Read, Update, Delete - the four basic database operations
- SQL Queries: We use SQL commands to interact with the database
Important Methods:
insertTransaction(): Adds a new transaction to the databasegetAllTransactions(): Gets all transactions from the databaseupdateTransaction(): Modifies an existing transactiondeleteTransaction(): Removes a transaction from the database
5. Updating the App to Use Database
5.1 Create a Data Service
Create a new file lib/services/transaction_service.dart to manage all database operations:
import 'package:personal_finance_tracker/helpers/database_helper.dart';
import 'package:personal_finance_tracker/models/transaction.dart';
class TransactionService {
final DatabaseHelper _databaseHelper = DatabaseHelper();
// Get all transactions
Future<List<Transaction>> getAllTransactions() async {
return await _databaseHelper.getAllTransactions();
}
// Add a new transaction
Future<void> addTransaction(Transaction transaction) async {
await _databaseHelper.insertTransaction(transaction);
}
// Update an existing transaction
Future<void> updateTransaction(Transaction transaction) async {
await _databaseHelper.updateTransaction(transaction);
}
// Delete a transaction
Future<void> deleteTransaction(String id) async {
await _databaseHelper.deleteTransaction(id);
}
// Get transactions by category
Future<List<Transaction>> getTransactionsByCategory(String category) async {
return await _databaseHelper.getTransactionsByCategory(category);
}
// Calculate total income
Future<double> getTotalIncome() async {
final transactions = await getAllTransactions();
double total = 0.0;
for (var tx in transactions) {
if (!tx.isExpense) {
total += tx.amount;
}
}
return total;
}
// Calculate total expenses
Future<double> getTotalExpenses() async {
final transactions = await getAllTransactions();
double total = 0.0;
for (var tx in transactions) {
if (tx.isExpense) {
total += tx.amount;
}
}
return total;
}
// Calculate balance
Future<double> getBalance() async {
final income = await getTotalIncome();
final expenses = await getTotalExpenses();
return income - expenses;
}
}
5.2 Update the Main Screen to Use Database
Update your main.dart to use the database:
import 'package:flutter/material.dart';
import 'package:personal_finance_tracker/screens/settings_screen.dart';
import 'package:personal_finance_tracker/screens/statistics_screen.dart';
import 'dashboard_screen.dart';
import 'models/transaction.dart';
import 'services/transaction_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Personal Finance Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
final TransactionService _transactionService = TransactionService();
List<Transaction> _transactions = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadTransactions();
}
// Load transactions from database
Future<void> _loadTransactions() async {
setState(() {
_isLoading = true;
});
try {
final transactions = await _transactionService.getAllTransactions();
setState(() {
_transactions = transactions;
_isLoading = false;
});
} catch (e) {
debugPrint('Error loading transactions: $e');
setState(() {
_isLoading = false;
});
}
}
// Add a new transaction
Future<void> _addTransaction(
String title,
double amount,
String category,
bool isExpense,
) async {
final newTransaction = Transaction(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
amount: amount,
date: DateTime.now(),
category: category,
isExpense: isExpense,
);
try {
await _transactionService.addTransaction(newTransaction);
await _loadTransactions(); // Reload to show the new transaction
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaction added successfully!')),
);
}
} catch (e) {
debugPrint('Error adding transaction: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to add transaction')),
);
}
}
}
// Delete a transaction
Future<void> _deleteTransaction(String id) async {
try {
await _transactionService.deleteTransaction(id);
await _loadTransactions(); // Reload to update the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaction deleted successfully!')),
);
}
} catch (e) {
debugPrint('Error deleting transaction: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to delete transaction')),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final List<Widget> screens = [
DashboardScreen(
transactions: _transactions,
onAddTransaction: _addTransaction,
onDeleteTransaction: _deleteTransaction,
onRefresh: _loadTransactions,
),
StatisticsScreen(transactions: _transactions),
const SettingsScreen(),
];
return Scaffold(
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
5.3 Update the Dashboard Screen
Update your dashboard_screen.dart to handle the refresh functionality:
import 'package:flutter/material.dart';
import 'models/transaction.dart';
import 'screens/transaction_detail_screen.dart';
import 'widgets/add_transaction_form.dart';
import 'widgets/transaction_list.dart';
class DashboardScreen extends StatefulWidget {
final List<Transaction> transactions;
final Function(String, double, String, bool) onAddTransaction;
final Function(String) onDeleteTransaction;
final VoidCallback onRefresh;
const DashboardScreen({
super.key,
required this.transactions,
required this.onAddTransaction,
required this.onDeleteTransaction,
required this.onRefresh,
});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
String? _selectedCategory;
String _searchQuery = '';
List<Transaction> get _filteredTransactions {
var filtered = widget.transactions;
if (_selectedCategory != null) {
filtered = filtered
.where((tx) => tx.category == _selectedCategory)
.toList();
}
if (_searchQuery.isNotEmpty) {
filtered = filtered
.where(
(tx) => tx.title.toLowerCase().contains(_searchQuery.toLowerCase()),
)
.toList();
}
return filtered;
}
void _startAddTransaction(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) {
return AddTransactionForm(onAddTransaction: widget.onAddTransaction);
},
);
}
void _navigateToTransactionDetail(Transaction transaction) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TransactionDetailScreen(transaction: transaction),
),
);
// If result is a transaction ID, it means user wants to delete it
if (result != null && result is String) {
widget.onDeleteTransaction(result);
}
}
double get _totalIncome {
return widget.transactions
.where((tx) => !tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
}
double get _totalExpenses {
return widget.transactions
.where((tx) => tx.isExpense)
.fold(0.0, (sum, tx) => sum + tx.amount);
}
double get _balance {
return _totalIncome - _totalExpenses;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Personal Finance Tracker'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
leading: const Icon(Icons.account_balance_wallet),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: widget.onRefresh,
tooltip: 'Refresh',
),
],
),
body: RefreshIndicator(
onRefresh: () async {
widget.onRefresh();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Balance Card
BalanceOverviewCard(balance: _balance),
const SizedBox(height: 24),
// Summary Cards
Row(
children: [
Expanded(
child: SummaryCard(
title: 'Income',
amount: _totalIncome,
icon: Icons.arrow_upward,
color: Colors.green,
),
),
const SizedBox(width: 16),
Expanded(
child: SummaryCard(
title: 'Expenses',
amount: _totalExpenses,
icon: Icons.arrow_downward,
color: Colors.red,
),
),
],
),
const SizedBox(height: 24),
// Search Bar
TextField(
decoration: InputDecoration(
hintText: 'Search transactions...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
const SizedBox(height: 16),
// Filter and Title
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Transactions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
DropdownButton<String?>(
hint: const Text('All'),
value: _selectedCategory,
onChanged: (newValue) {
setState(() {
_selectedCategory = newValue;
});
},
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('All Categories'),
),
...{...widget.transactions.map((tx) => tx.category)}.map(
(category) => DropdownMenuItem<String>(
value: category,
child: Text(category),
),
),
],
),
],
),
const SizedBox(height: 16),
// Transaction List
SizedBox(
height: 400,
child: TransactionList(
transactions: _filteredTransactions,
onDeleteTransaction: widget.onDeleteTransaction,
onTransactionTap: _navigateToTransactionDetail,
),
),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _startAddTransaction(context),
child: const Icon(Icons.add),
),
);
}
}
// Keep the same BalanceOverviewCard and SummaryCard classes from Lab 6
class BalanceOverviewCard extends StatelessWidget {
final double balance;
const BalanceOverviewCard({super.key, required this.balance});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Current Balance',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 16),
Text(
'\$${balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: balance >= 0 ? Colors.green : Colors.red,
),
),
],
),
),
);
}
}
class SummaryCard extends StatelessWidget {
final String title;
final double amount;
final IconData icon;
final Color color;
const SummaryCard({
super.key,
required this.title,
required this.amount,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
Text(
'\$${amount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
);
}
}
6. Adding User Preferences with SharedPreferences
6.1 Create a Settings Service
Create a new file lib/services/settings_service.dart:
import 'package:shared_preferences/shared_preferences.dart';
class SettingsService {
static const String _currencyKey = 'currency';
static const String _darkModeKey = 'dark_mode';
static const String _notificationsKey = 'notifications';
// Get currency setting
Future<String> getCurrency() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_currencyKey) ?? 'USD';
}
// Save currency setting
Future<void> setCurrency(String currency) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_currencyKey, currency);
}
// Get dark mode setting
Future<bool> getDarkMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_darkModeKey) ?? false;
}
// Save dark mode setting
Future<void> setDarkMode(bool isDarkMode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_darkModeKey, isDarkMode);
}
// Get notifications setting
Future<bool> getNotifications() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_notificationsKey) ?? true;
}
// Save notifications setting
Future<void> setNotifications(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_notificationsKey, enabled);
}
// Clear all settings
Future<void> clearAllSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
}
}
6.2 Update the Settings Screen
Update your settings_screen.dart to use the settings service:
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final SettingsService _settingsService = SettingsService();
bool _darkMode = false;
bool _notifications = true;
String _currency = 'USD';
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
try {
final currency = await _settingsService.getCurrency();
final darkMode = await _settingsService.getDarkMode();
final notifications = await _settingsService.getNotifications();
setState(() {
_currency = currency;
_darkMode = darkMode;
_notifications = notifications;
_isLoading = false;
});
} catch (e) {
debugPrint('Error loading settings: $e');
setState(() {
_isLoading = false;
});
}
}
Future<void> _updateDarkMode(bool value) async {
await _settingsService.setDarkMode(value);
setState(() {
_darkMode = value;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Dark mode ${value ? 'enabled' : 'disabled'}')),
);
}
}
Future<void> _updateNotifications(bool value) async {
await _settingsService.setNotifications(value);
setState(() {
_notifications = value;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Notifications ${value ? 'enabled' : 'disabled'}'),
),
);
}
}
Future<void> _updateCurrency(String currency) async {
await _settingsService.setCurrency(currency);
setState(() {
_currency = currency;
});
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Currency changed to $currency')));
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Profile Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.person,
size: 30,
color: Colors.white,
),
),
const SizedBox(width: 16),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'John Doe',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'john.doe@example.com',
style: TextStyle(color: Colors.grey),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Preferences Section
const Text(
'Preferences',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: const Text('Enable dark theme'),
value: _darkMode,
onChanged: _updateDarkMode,
secondary: const Icon(Icons.dark_mode),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Notifications'),
subtitle: const Text('Receive transaction alerts'),
value: _notifications,
onChanged: _updateNotifications,
secondary: const Icon(Icons.notifications),
),
const Divider(height: 1),
ListTile(
title: const Text('Currency'),
subtitle: Text('Current: $_currency'),
leading: const Icon(Icons.attach_money),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
_showCurrencyDialog();
},
),
],
),
),
const SizedBox(height: 24),
// Actions Section
const Text(
'Actions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
ListTile(
title: const Text('Export Data'),
subtitle: const Text('Download your transaction data'),
leading: const Icon(Icons.download),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export feature coming soon!'),
),
);
},
),
const Divider(height: 1),
ListTile(
title: const Text('Clear All Data'),
subtitle: const Text('Delete all transactions and settings'),
leading: const Icon(Icons.delete_forever, color: Colors.red),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
_showClearDataDialog();
},
),
const Divider(height: 1),
ListTile(
title: const Text('About'),
subtitle: const Text('App version and info'),
leading: const Icon(Icons.info),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
_showAboutDialog();
},
),
],
),
),
],
),
);
}
void _showCurrencyDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Currency'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: ['USD', 'EUR', 'GBP', 'JPY'].map((currency) {
return RadioListTile<String>(
title: Text(currency),
value: currency,
groupValue: _currency,
onChanged: (value) {
if (value != null) {
_updateCurrency(value);
}
Navigator.of(context).pop();
},
);
}).toList(),
),
);
},
);
}
void _showClearDataDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Clear All Data'),
content: const Text(
'This will permanently delete all your transactions and settings. This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
// Clear all data
await _settingsService.clearAllSettings();
// You would also clear transaction data here
if (context.mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data cleared')),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Clear All'),
),
],
);
},
);
}
void _showAboutDialog() {
showAboutDialog(
context: context,
applicationName: 'Personal Finance Tracker',
applicationVersion: '1.0.0',
applicationIcon: const Icon(Icons.account_balance_wallet),
children: [
const Text('A simple app to track your personal finances.'),
const SizedBox(height: 16),
const Text('Built with Flutter for educational purposes.'),
],
);
}
}
7. Testing the Database
7.1 Add Some Test Data
You can add a method to insert test data for development. Add this to your DatabaseHelper class:
// Add test data (for development only)
Future<void> insertTestData() async {
final testTransactions = [
Transaction(
id: 'test1',
title: 'Grocery Shopping',
amount: 45.99,
date: DateTime.now().subtract(const Duration(days: 1)),
category: 'Food',
isExpense: true,
),
Transaction(
id: 'test2',
title: 'Monthly Salary',
amount: 1500.00,
date: DateTime.now().subtract(const Duration(days: 3)),
category: 'Salary',
isExpense: false,
),
Transaction(
id: 'test3',
title: 'Coffee',
amount: 4.50,
date: DateTime.now(),
category: 'Food',
isExpense: true,
),
];
for (var transaction in testTransactions) {
await insertTransaction(transaction);
}
print('Test data inserted');
}
7.2 Add a Debug Button (Optional)
You can add a debug button to your settings screen to insert test data:
// Add this to the actions section in settings_screen.dart
ListTile(
title: const Text('Add Test Data'),
subtitle: const Text('Insert sample transactions (Debug only)'),
leading: const Icon(Icons.bug_report),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () async {
final dbHelper = DatabaseHelper();
await dbHelper.insertTestData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test data added!')),
);
}
},
),
8. Key Concepts Summary
8.1 Database Concepts
- SQLite: A lightweight database that runs on the device
- Tables: Store data in rows and columns (like Excel sheets)
- CRUD: Create, Read, Update, Delete - the four basic operations
- Primary Key: A unique identifier for each row
- SQL: The language used to interact with databases
8.2 Flutter Database Concepts
- Async/Await: Database operations take time, so we use async functions
- Singleton Pattern: Only one database connection in the entire app
- Future: Represents a value that will be available in the future
8.3 Storage Types
- SQLite: For complex, structured data (transactions, user data)
- SharedPreferences: For simple settings (theme, currency, flags)
- File Storage: For files, images, documents
9. Common Issues and Troubleshooting
9.1 Database Not Creating
Problem: Tables don't exist or data isn't saving Solution:
- Check that
_createTables()is being called - Verify SQL syntax in CREATE TABLE statements
- Check database version number
9.2 Data Not Loading
Problem: App shows empty list even after adding transactions Solution:
- Check that
_loadTransactions()is called ininitState() - Verify async/await usage
- Check for errors in console output
9.3 App Crashes on Database Operations
Problem: App crashes when adding/deleting transactions Solution:
- Wrap database operations in try-catch blocks
- Check that all required fields are provided
- Verify data types match the database schema
9.4 Settings Not Persisting
Problem: Settings reset when app is restarted Solution:
- Check that SharedPreferences is properly initialized
- Verify that settings are loaded in
initState() - Make sure settings are saved when changed
10. Exercises
Exercise 1: Add Transaction Categories Management
Create a way to add/remove custom categories:
// Add to DatabaseHelper
Future<List<String>> getAllCategories() async {
final db = await database;
final result = await db.query(
'transactions',
columns: ['category'],
distinct: true,
);
return result.map((row) => row['category'] as String).toList();
}
Exercise 2: Add Data Export
Implement a feature to export transactions to a text file:
// Add to TransactionService
Future<String> exportTransactionsToText() async {
final transactions = await getAllTransactions();
final buffer = StringBuffer();
buffer.writeln('Personal Finance Tracker Export');
buffer.writeln('Generated: ${DateTime.now()}');
buffer.writeln('');
for (var tx in transactions) {
buffer.writeln('${tx.date.toIso8601String()},${tx.title},${tx.amount},${tx.category},${tx.isExpense ? 'Expense' : 'Income'}');
}
return buffer.toString();
}
Exercise 3: Add Monthly Summary
Create a method to get transactions for a specific month:
// Add to DatabaseHelper
Future<List<Transaction>> getTransactionsByMonth(int year, int month) async {
final db = await database;
final startDate = DateTime(year, month, 1);
final endDate = DateTime(year, month + 1, 0);
final List<Map<String, dynamic>> maps = await db.query(
'transactions',
where: 'date >= ? AND date <= ?',
whereArgs: [startDate.toIso8601String(), endDate.toIso8601String()],
orderBy: 'date DESC',
);
return List.generate(maps.length, (i) {
return Transaction(
id: maps[i]['id'],
title: maps[i]['title'],
amount: maps[i]['amount'],
date: DateTime.parse(maps[i]['date']),
category: maps[i]['category'],
isExpense: maps[i]['isExpense'] == 1,
);
});
}
11. Key Takeaways
- Local storage makes your app work offline and remember data
- SQLite is perfect for complex data like transactions
- SharedPreferences is great for simple settings
- Always use async/await for database operations
- Handle errors with try-catch blocks
- Test your database operations thoroughly
- Use services to organize your database code
Next Steps
In the next lab, we'll integrate RESTful APIs to add features like:
- Currency conversion using live exchange rates
- Financial tips from external sources
- Loading indicators and error handling for network requests
Your app now has persistent storage - transactions and settings will survive app restarts! This is a major milestone in building a real-world app.
Lab 8: RESTful APIs - Personal Finance Tracker
Objectives
By the end of this lab, students will:
- Understand what RESTful APIs are and how they work
- Learn how to make HTTP requests in Flutter
- Work with JSON data from external APIs
- Implement currency conversion using a real API
- Handle loading states and network errors
- Add offline fallback functionality
- Integrate API features into the finance tracker
Prerequisites
- Completed Lab 7 (local storage implementation)
- Understanding of async/await and Future
- Familiarity with the finance tracker structure
- Basic understanding of JSON format
1. Understanding RESTful APIs
1.1 What is an API?
API stands for Application Programming Interface. It's like a waiter in a restaurant:
- You (the app) are the customer
- The API is the waiter
- The server (external service) is the kitchen
You tell the waiter (API) what you want, the waiter tells the kitchen (server), and the kitchen sends back your food (data).
1.2 What is REST?
REST stands for Representational State Transfer. It's a set of rules for how APIs should work:
- GET: Ask for data (like reading a book)
- POST: Send new data (like writing a new page)
- PUT: Update existing data (like editing a page)
- DELETE: Remove data (like tearing out a page)
1.3 What is JSON?
JSON (JavaScript Object Notation) is the language APIs use to send data. It looks like this:
{
"name": "John",
"age": 25,
"city": "New York",
"hobbies": ["reading", "swimming"]
}
2. Understanding HTTP Requests
2.1 How HTTP Requests Work
When your app wants data from the internet:
- App sends request: "Can I have the current USD to EUR exchange rate?"
- Server processes: The server looks up the exchange rate
- Server sends response: "1 USD = 0.92 EUR"
- App uses the data: Display the converted amount
2.2 Parts of an HTTP Request
GET https://api.frankfurter.dev/v1/latest?from=USD&to=EUR
│ │ │
│ └─ URL (where to send request) └─ Parameters (what you want)
└─ Method (what you want to do)
2.3 HTTP Response
{
"amount": 1.0,
"base": "USD",
"date": "2024-12-09",
"rates": {
"EUR": 0.92
}
}
3. Setting Up HTTP Requests in Flutter
3.1 Add HTTP Package
Add the HTTP package to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
intl: ^0.19.0
sqflite: ^2.3.0
path: ^1.8.3
shared_preferences: ^2.2.2
http: ^1.5.0 # Add this line
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
Run the command to install:
flutter pub get
or run this command to add and install the packages:
flutter pub add http
3.2 Import the HTTP Package
In your Dart files, import the package:
import 'dart:convert';
import 'package:http/http.dart' as http;
4. Basic HTTP Request Example
4.1 Simple GET Request
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> fetchData() async {
// 1. Create the URL
final url = Uri.parse('https://api.frankfurter.dev/v1/latest?from=USD&to=EUR');
try {
// 2. Make the request
final response = await http.get(url);
// 3. Check if the request was successful
if (response.statusCode == 200) {
// 4. Convert JSON string to Dart object
final data = json.decode(response.body);
print('Exchange rate: ${data['rates']['EUR']}');
} else {
print('Error: ${response.statusCode}');
}
} catch (e) {
print('Network error: $e');
}
}
4.2 Understanding the Code
Uri.parse(): Converts a string URL into a Uri objecthttp.get(): Makes a GET request to the URLresponse.statusCode: HTTP status (200 = success, 404 = not found, etc.)json.decode(): Converts JSON string to Dart Map/Listtry-catch: Handles network errors (no internet, server down, etc.)
5. Creating a Currency Conversion Service
5.1 Create the Currency Model
Create a new file lib/models/currency_rate.dart:
class CurrencyRate {
final double amount;
final String baseCurrency;
final String targetCurrency;
final double rate;
final DateTime date;
const CurrencyRate({
required this.amount,
required this.baseCurrency,
required this.targetCurrency,
required this.rate,
required this.date,
});
factory CurrencyRate.fromJson(Map<String, dynamic> json, String targetCurrency) {
return CurrencyRate(
amount: json['amount'].toDouble(),
baseCurrency: json['base'],
targetCurrency: targetCurrency,
rate: json['rates'][targetCurrency].toDouble(),
date: DateTime.parse(json['date']),
);
}
double get convertedAmount => amount * rate;
@override
String toString() {
return '$amount $baseCurrency = ${convertedAmount.toStringAsFixed(2)} $targetCurrency';
}
}
5.2 Create the Currency Service
Create a new file lib/services/currency_service.dart:
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/currency_rate.dart';
class CurrencyService {
static const String _baseUrl = 'https://api.frankfurter.dev/v1';
static const Duration _timeout = Duration(seconds: 10);
// Get supported currencies
Future<List<String>> getSupportedCurrencies() async {
try {
final url = Uri.parse('$_baseUrl/currencies');
final response = await http.get(url).timeout(_timeout);
if (response.statusCode == 200) {
final Map<String, dynamic> data = json.decode(response.body);
return data.keys.toList()..sort();
} else {
throw Exception('Failed to load currencies: ${response.statusCode}');
}
} catch (e) {
debugPrint('Error fetching currencies: $e');
// Return default currencies if API fails
return ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD'];
}
}
// Convert currency
Future<CurrencyRate> convertCurrency({
required double amount,
required String fromCurrency,
required String toCurrency,
}) async {
// Return same currency if from and to are the same
if (fromCurrency == toCurrency) {
return CurrencyRate(
amount: amount,
baseCurrency: fromCurrency,
targetCurrency: toCurrency,
rate: 1.0,
date: DateTime.now(),
);
}
try {
final url = Uri.parse(
'$_baseUrl/latest?amount=$amount&from=$fromCurrency&to=$toCurrency',
);
final response = await http.get(url).timeout(_timeout);
if (response.statusCode == 200) {
final Map<String, dynamic> data = json.decode(response.body);
return CurrencyRate.fromJson(data, toCurrency);
} else {
throw Exception('Failed to convert currency: ${response.statusCode}');
}
} catch (e) {
debugPrint('Error converting currency: $e');
rethrow; // Re-throw the error so the UI can handle it
}
}
}
5.3 Understanding the Currency Service
Key Features:
getSupportedCurrencies(): Gets list of available currenciesconvertCurrency(): Converts amount from one currency to another- Error handling: Returns default data if API fails
- Timeout: Prevents app from hanging if network is slow
6. Creating the Currency Conversion UI
6.1 Create the Currency Conversion Screen
Create a new file lib/screens/currency_converter_screen.dart:
import 'package:flutter/material.dart';
import '../models/currency_rate.dart';
import '../services/currency_service.dart';
class CurrencyConverterScreen extends StatefulWidget {
const CurrencyConverterScreen({super.key});
@override
State<CurrencyConverterScreen> createState() => _CurrencyConverterScreenState();
}
class _CurrencyConverterScreenState extends State<CurrencyConverterScreen> {
final CurrencyService _currencyService = CurrencyService();
final TextEditingController _amountController = TextEditingController(text: '1.0');
List<String> _currencies = [];
String _fromCurrency = 'USD';
String _toCurrency = 'EUR';
CurrencyRate? _currentRate;
bool _isLoading = false;
bool _isLoadingCurrencies = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadCurrencies();
_convertCurrency();
}
@override
void dispose() {
_amountController.dispose();
super.dispose();
}
Future<void> _loadCurrencies() async {
try {
final currencies = await _currencyService.getSupportedCurrencies();
setState(() {
_currencies = currencies;
_isLoadingCurrencies = false;
});
} catch (e) {
setState(() {
_isLoadingCurrencies = false;
_errorMessage = 'Failed to load currencies';
});
}
}
Future<void> _convertCurrency() async {
final amountText = _amountController.text.trim();
if (amountText.isEmpty) return;
final amount = double.tryParse(amountText);
if (amount == null || amount <= 0) {
setState(() {
_errorMessage = 'Please enter a valid amount';
_currentRate = null;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final rate = await _currencyService.convertCurrency(
amount: amount,
fromCurrency: _fromCurrency,
toCurrency: _toCurrency,
);
setState(() {
_currentRate = rate;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Failed to convert currency. Please check your internet connection.';
_currentRate = null;
});
}
}
void _swapCurrencies() {
setState(() {
final temp = _fromCurrency;
_fromCurrency = _toCurrency;
_toCurrency = temp;
});
_convertCurrency();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Currency Converter'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: _isLoadingCurrencies
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Amount Input
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Amount',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: 'Enter amount',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.attach_money),
),
onChanged: (value) {
// Auto-convert after user stops typing
Future.delayed(const Duration(milliseconds: 500), () {
if (_amountController.text == value) {
_convertCurrency();
}
});
},
),
],
),
),
),
const SizedBox(height: 16),
// Currency Selection
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// From Currency
Row(
children: [
const Text('From:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<String>(
value: _fromCurrency,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: _currencies.map((currency) {
return DropdownMenuItem(
value: currency,
child: Text(currency),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_fromCurrency = value;
});
_convertCurrency();
}
},
),
),
],
),
const SizedBox(height: 16),
// Swap Button
Center(
child: IconButton(
onPressed: _swapCurrencies,
icon: const Icon(Icons.swap_vert),
tooltip: 'Swap currencies',
style: IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
const SizedBox(height: 16),
// To Currency
Row(
children: [
const Text('To:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<String>(
value: _toCurrency,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: _currencies.map((currency) {
return DropdownMenuItem(
value: currency,
child: Text(currency),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_toCurrency = value;
});
_convertCurrency();
}
},
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Convert Button
ElevatedButton(
onPressed: _isLoading ? null : _convertCurrency,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Convert', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 24),
// Result
if (_errorMessage != null)
Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
)
else if (_currentRate != null)
Card(
color: Colors.green.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Conversion Result',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
_currentRate.toString(),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 8),
Text(
'Exchange Rate: 1 ${_currentRate!.baseCurrency} = ${_currentRate!.rate.toStringAsFixed(4)} ${_currentRate!.targetCurrency}',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
),
Text(
'Date: ${_currentRate!.date.day}/${_currentRate!.date.month}/${_currentRate!.date.year}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
),
],
),
),
);
}
}
6.2 Understanding the Currency Converter UI
Key Features:
- Amount input: User enters the amount to convert
- Currency dropdowns: Select from and to currencies
- Swap button: Quickly swap the currencies
- Auto-conversion: Converts automatically when user stops typing
- Loading states: Shows spinner while loading
- Error handling: Shows error messages when something goes wrong
- Result display: Shows the converted amount and exchange rate
7. Integrating Currency Converter into the App
7.1 Add Currency Converter to Navigation
Update your main.dart to include the currency converter:
import 'package:flutter/material.dart';
import 'package:personal_finance_tracker/screens/currency_converter_screen.dart';
import 'package:personal_finance_tracker/screens/settings_screen.dart';
import 'package:personal_finance_tracker/screens/statistics_screen.dart';
import 'dashboard_screen.dart';
import 'models/transaction.dart';
import 'services/transaction_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Personal Finance Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
final TransactionService _transactionService = TransactionService();
List<Transaction> _transactions = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadTransactions();
}
// Load transactions from database
Future<void> _loadTransactions() async {
setState(() {
_isLoading = true;
});
try {
final transactions = await _transactionService.getAllTransactions();
setState(() {
_transactions = transactions;
_isLoading = false;
});
} catch (e) {
debugPrint('Error loading transactions: $e');
setState(() {
_isLoading = false;
});
}
}
// Add a new transaction
Future<void> _addTransaction(
String title,
double amount,
String category,
bool isExpense,
) async {
final newTransaction = Transaction(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
amount: amount,
date: DateTime.now(),
category: category,
isExpense: isExpense,
);
try {
await _transactionService.addTransaction(newTransaction);
await _loadTransactions(); // Reload to show the new transaction
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaction added successfully!')),
);
}
} catch (e) {
debugPrint('Error adding transaction: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to add transaction')),
);
}
}
}
// Delete a transaction
Future<void> _deleteTransaction(String id) async {
try {
await _transactionService.deleteTransaction(id);
await _loadTransactions(); // Reload to update the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaction deleted successfully!')),
);
}
} catch (e) {
debugPrint('Error deleting transaction: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to delete transaction')),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final List<Widget> screens = [
DashboardScreen(
transactions: _transactions,
onAddTransaction: _addTransaction,
onDeleteTransaction: _deleteTransaction,
onRefresh: _loadTransactions,
),
StatisticsScreen(transactions: _transactions),
const CurrencyConverterScreen(),
const SettingsScreen(),
];
return Scaffold(
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // Show all tabs
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.currency_exchange),
label: 'Convert',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
8. Understanding Loading States and Error Handling
8.1 Types of Loading States
- Initial Loading: When the screen first loads
- Action Loading: When user performs an action (like converting currency)
- Background Loading: When refreshing data
8.2 Common Network Errors
try {
final response = await http.get(url);
// Handle response
} on TimeoutException catch (e) {
// Network timeout
print('Request timeout: $e');
} on SocketException catch (e) {
// No internet connection
print('No internet connection: $e');
} on HttpException catch (e) {
// HTTP error
print('HTTP error: $e');
} catch (e) {
// Other errors
print('Unknown error: $e');
}
8.3 Best Practices for Error Handling
- Show user-friendly messages: "Please check your internet connection" instead of "SocketException"
- Provide fallback options: Show cached data or default values
- Allow retry: Add retry buttons for failed requests
- Don't crash the app: Always handle errors gracefully
9. Key API Concepts Summary
9.1 HTTP Methods
- GET: Retrieve data (like getting exchange rates)
- POST: Send new data (like creating a user account)
- PUT: Update existing data (like updating profile)
- DELETE: Remove data (like deleting a transaction)
9.2 HTTP Status Codes
- 200: OK - Request successful
- 400: Bad Request - Invalid request format
- 401: Unauthorized - Authentication required
- 404: Not Found - Resource doesn't exist
- 500: Internal Server Error - Server problem
9.3 JSON Structure
{
"key": "value",
"number": 123,
"boolean": true,
"array": ["item1", "item2"],
"object": {
"nested_key": "nested_value"
}
}
9.4 Error Handling Strategies
- Try-Catch: Handle exceptions gracefully
- Timeouts: Don't wait forever for responses
- Fallbacks: Provide cached data when API fails
- User Feedback: Show appropriate error messages
10. Common Issues and Troubleshooting
10.1 Network Permission Issues
Problem: HTTP requests don't work on Android
Solution: Add internet permission to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
10.2 JSON Parsing Errors
Problem: App crashes when parsing JSON Solution: Always validate JSON structure:
try {
final data = json.decode(response.body);
// Check if required fields exist
if (data['rates'] != null && data['base'] != null) {
// Process data
} else {
throw Exception('Invalid API response format');
}
} catch (e) {
debugPrint('JSON parsing error: $e');
}
10.3 Timeout Issues
Problem: App hangs waiting for API response Solution: Always use timeouts:
final response = await http.get(url).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Request timed out');
},
);
12. Key Takeaways
- APIs let your app get data from external services
- HTTP requests are how you communicate with APIs
- JSON is the common format for API data
- Error handling is crucial for network operations
- Loading states improve user experience
- Caching provides offline functionality
- Timeouts prevent app freezing
- User feedback is important for network operations
12.1 Best Practices
- Always handle errors gracefully
- Show loading indicators for network operations
- Provide offline functionality when possible
- Use timeouts to prevent hanging
- Cache data to improve performance
- Validate JSON before processing
- Give user feedback about network status
Next Steps
In the next lab, we'll integrate Firebase for:
- User authentication (sign up, login, logout)
- Cloud storage for syncing transactions across devices
- Real-time updates when data changes
- Push notifications for important updates
Your app now has internet connectivity and can work with external APIs! This opens up many possibilities for enhanced functionality. The currency conversion feature makes your finance tracker more useful for users who deal with multiple currencies.
Lab 9: Firebase Integration - Personal Finance Tracker
Objectives
By the end of this lab, students will:
- Understand what Firebase is and its benefits
- Set up a Firebase project and connect it to Flutter
- Implement user authentication (sign up, login, logout)
- Store and sync transaction data in the cloud using Firestore
- Handle real-time data updates
- Create a user account system with profile management
- Sync data between multiple devices
- Handle offline/online scenarios
Prerequisites
- Completed Lab 8 (RESTful APIs implementation)
- Understanding of async/await and Future
- Familiarity with the finance tracker structure
- Google account (for Firebase console access)
- Basic understanding of user authentication concepts
1. Understanding Firebase
1.1 What is Firebase?
Firebase is Google's platform for building mobile and web applications. Think of it as a backend-as-a-service - it provides all the server-side functionality you need without having to build and manage servers yourself.
1.2 Why Use Firebase?
Without Firebase:
- You need to build your own server
- Set up databases
- Handle user authentication
- Manage security
- Scale infrastructure
With Firebase:
- Everything is pre-built and managed by Google
- Automatic scaling
- Built-in security
- Real-time updates
- Works offline
1.3 Firebase Services We'll Use
1. Firebase Authentication
- User sign up, login, logout
- Password reset
- Email verification
- Different sign-in methods (email, Google, Facebook, etc.)
2. Cloud Firestore
- NoSQL database in the cloud
- Real-time data synchronization
- Offline support
- Automatic scaling
3. Firebase Security Rules
- Control who can read/write data
- Ensure users can only access their own data
2. Setting Up Firebase
2.1 Create a Firebase Project
- Go to Firebase Console
- Click "Create a project"
- Enter project name:
personal-finance-tracker - Disable Google Analytics (we don't need it for this project)
- Click "Create project"
2.2 Enable Authentication
- In Firebase Console, go to "Authentication"
- Click "Get started"
- Go to "Sign-in method" tab
- Enable "Email/Password" provider
- Click "Save"
2.3 Create Firestore Database
- In Firebase Console, go to "Firestore Database"
- Click "Create database"
- Choose "Start in test mode" (we'll set up security rules later)
- Select a location close to your users (e.g., us-central1)
- Click "Done"
2.4 Add your flutter app to Firebase
- In Firebase Console, click "Add app" → Flutter
- Install the Firebase CLI:
npm install -g firebase-toolsif you have node installed or download the standalone version from the Firebase CLI page - Run
firebase loginin your terminal to log in to your Firebase account - Install and run the FlutterFire CLI :
- From any directory, run this command:
dart pub global activate flutterfire_cli - Then, at the root of your Flutter project directory, run this command:
flutterfire configure --project=<your-project-id> - Choose what platforms you want to deploy to (web, iOS, Android, etc.)
choose
androidandios
- From any directory, run this command:
3. Adding Firebase to Flutter
3.1 Add Firebase Dependencies
Run:
flutter pub add firebase_core firebase_auth cloud_firestore
3.2 Initialize Firebase in Your App
Update your main.dart:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:personal_finance_tracker/firebase_options.dart';
import 'screens/login_screen.dart';
import 'screens/main_screen.dart';
void main() async {
// Ensure Flutter is initialized
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Personal Finance Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const AuthWrapper(),
);
}
}
class AuthWrapper extends StatelessWidget {
const AuthWrapper({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
// Show loading screen while checking authentication
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// If user is signed in, show main app
if (snapshot.hasData && snapshot.data != null) {
return const MainScreen();
}
// If user is not signed in, show login screen
return const LoginScreen();
},
);
}
}
3.3 Understanding the Auth Wrapper
The AuthWrapper automatically:
- Listens for authentication state changes
- Shows login screen when user is not signed in
- Shows main app when user is signed in
- Handles loading state while checking authentication
4. Creating User Authentication
4.1 Create Authentication Service
Create lib/services/auth_service.dart:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
class AuthService {
final FirebaseAuth _auth = FirebaseAuth.instance;
// Get current user
User? get currentUser => _auth.currentUser;
// Get user stream
Stream<User?> get authStateChanges => _auth.authStateChanges();
// Sign up with email and password
Future<UserCredential?> signUp({
required String email,
required String password,
required String displayName,
}) async {
try {
// Create user account
final credential = await _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
// Update display name
await credential.user?.updateDisplayName(displayName);
await credential.user?.reload();
debugPrint('User signed up: ${credential.user?.email}');
return credential;
} on FirebaseAuthException catch (e) {
debugPrint('Sign up error: ${e.code} - ${e.message}');
throw _handleAuthError(e);
} catch (e) {
debugPrint('Unknown sign up error: $e');
throw 'An unexpected error occurred. Please try again.';
}
}
// Sign in with email and password
Future<UserCredential?> signIn({
required String email,
required String password,
}) async {
try {
final credential = await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
debugPrint('User signed in: ${credential.user?.email}');
return credential;
} on FirebaseAuthException catch (e) {
debugPrint('Sign in error: ${e.code} - ${e.message}');
throw _handleAuthError(e);
} catch (e) {
debugPrint('Unknown sign in error: $e');
throw 'An unexpected error occurred. Please try again.';
}
}
// Sign out
Future<void> signOut() async {
try {
await _auth.signOut();
debugPrint('User signed out');
} catch (e) {
debugPrint('Sign out error: $e');
throw 'Failed to sign out. Please try again.';
}
}
// Send password reset email
Future<void> sendPasswordResetEmail(String email) async {
try {
await _auth.sendPasswordResetEmail(email: email);
debugPrint('Password reset email sent to: $email');
} on FirebaseAuthException catch (e) {
debugPrint('Password reset error: ${e.code} - ${e.message}');
throw _handleAuthError(e);
} catch (e) {
debugPrint('Unknown password reset error: $e');
throw 'Failed to send password reset email. Please try again.';
}
}
// Handle Firebase Auth errors
String _handleAuthError(FirebaseAuthException e) {
switch (e.code) {
case 'user-not-found':
return 'No user found with this email address.';
case 'wrong-password':
return 'Wrong password provided.';
case 'email-already-in-use':
return 'An account already exists with this email address.';
case 'weak-password':
return 'The password provided is too weak.';
case 'invalid-email':
return 'The email address is not valid.';
case 'user-disabled':
return 'This user account has been disabled.';
case 'too-many-requests':
return 'Too many requests. Please try again later.';
case 'requires-recent-login':
return 'This operation requires recent authentication. Please sign in again.';
default:
return e.message ?? 'An authentication error occurred.';
}
}
}
4.2 Create Login Screen
Create lib/screens/login_screen.dart:
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import 'signup_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
final _authService = AuthService();
bool _isLoading = false;
bool _isPasswordVisible = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _signIn() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
});
try {
await _authService.signIn(
email: _emailController.text.trim(),
password: _passwordController.text,
);
// AuthWrapper will automatically navigate to main app
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _forgotPassword() async {
final email = _emailController.text.trim();
if (email.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your email address first')),
);
return;
}
try {
await _authService.sendPasswordResetEmail(email);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Password reset email sent to $email'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60),
// App Logo and Title
const Icon(
Icons.account_balance_wallet,
size: 80,
color: Colors.deepPurple,
),
const SizedBox(height: 16),
const Text(
'Personal Finance Tracker',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Sign in to manage your finances',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 60),
// Login Form
Form(
key: _formKey,
child: Column(
children: [
// Email Field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
prefixIcon: const Icon(Icons.email),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 24),
// Sign In Button
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _signIn,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
)
: const Text(
'Sign In',
style: TextStyle(fontSize: 16),
),
),
),
const SizedBox(height: 16),
// Forgot Password Button
TextButton(
onPressed: _forgotPassword,
child: const Text('Forgot Password?'),
),
],
),
),
const SizedBox(height: 60),
// Sign Up Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Don't have an account? "),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SignUpScreen(),
),
);
},
child: const Text('Sign Up'),
),
],
),
],
),
),
),
);
}
}
4.3 Create Sign Up Screen
Create lib/screens/signup_screen.dart:
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
class SignUpScreen extends StatefulWidget {
const SignUpScreen({super.key});
@override
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends State<SignUpScreen> {
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
final _authService = AuthService();
bool _isLoading = false;
bool _isPasswordVisible = false;
bool _isConfirmPasswordVisible = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _signUp() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
});
try {
await _authService.signUp(
email: _emailController.text.trim(),
password: _passwordController.text,
displayName: _nameController.text.trim(),
);
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account created successfully!'),
backgroundColor: Colors.green,
),
);
// AuthWrapper will automatically navigate to main app
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create Account'),
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 20),
// Title
const Text(
'Join Personal Finance Tracker',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Create your account to start tracking your finances',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
// Sign Up Form
Form(
key: _formKey,
child: Column(
children: [
// Name Field
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'Full Name',
hintText: 'Enter your full name',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your name';
}
if (value.length < 2) {
return 'Name must be at least 2 characters';
}
return null;
},
),
const SizedBox(height: 16),
// Email Field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
prefixIcon: const Icon(Icons.email),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 16),
// Confirm Password Field
TextFormField(
controller: _confirmPasswordController,
obscureText: !_isConfirmPasswordVisible,
decoration: InputDecoration(
labelText: 'Confirm Password',
hintText: 'Confirm your password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_isConfirmPasswordVisible = !_isConfirmPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm your password';
}
if (value != _passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 24),
// Sign Up Button
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _signUp,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
)
: const Text(
'Create Account',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
const SizedBox(height: 40),
// Sign In Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Already have an account? '),
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Sign In'),
),
],
),
],
),
),
),
);
}
}
5. Integrating Cloud Firestore
5.1 Understanding Firestore Structure
Firestore is a NoSQL database that stores data in documents and collections:
users (collection)
├── user1_id (document)
│ ├── name: "John Doe"
│ ├── email: "john@example.com"
│ └── transactions (subcollection)
│ ├── transaction1_id (document)
│ │ ├── title: "Grocery"
│ │ ├── amount: 45.99
│ │ └── date: "2024-01-15"
│ └── transaction2_id (document)
│ ├── title: "Salary"
│ ├── amount: 1500.00
│ └── date: "2024-01-01"
└── user2_id (document)
└── ...
5.2 Create Firestore Service
Create lib/services/firestore_service.dart:
import 'package:cloud_firestore/cloud_firestore.dart' hide Transaction;
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import '../models/transaction.dart';
class FirestoreService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance;
// Get current user ID
String? get _userId => _auth.currentUser?.uid;
// Get user's transactions collection reference
CollectionReference? get _userTransactionsCollection {
if (_userId == null) return null;
return _firestore
.collection('users')
.doc(_userId)
.collection('transactions');
}
// Create or update user profile
Future<void> createUserProfile({
required String uid,
required String name,
required String email,
}) async {
try {
await _firestore.collection('users').doc(uid).set({
'name': name,
'email': email,
'createdAt': FieldValue.serverTimestamp(),
'lastLoginAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
debugPrint('User profile created/updated: $email');
} catch (e) {
debugPrint('Error creating user profile: $e');
rethrow;
}
}
// Add transaction to Firestore
Future<void> addTransaction(Transaction transaction) async {
if (_userTransactionsCollection == null) {
throw Exception('User not authenticated');
}
try {
await _userTransactionsCollection!.doc(transaction.id).set({
'title': transaction.title,
'amount': transaction.amount,
'date': Timestamp.fromDate(transaction.date),
'category': transaction.category,
'isExpense': transaction.isExpense,
'createdAt': FieldValue.serverTimestamp(),
});
debugPrint('Transaction added to Firestore: ${transaction.title}');
} catch (e) {
debugPrint('Error adding transaction to Firestore: $e');
rethrow;
}
}
// Get all transactions from Firestore
Future<List<Transaction>> getAllTransactions() async {
if (_userTransactionsCollection == null) {
throw Exception('User not authenticated');
}
try {
final querySnapshot = await _userTransactionsCollection!
.orderBy('date', descending: true)
.get();
return querySnapshot.docs.map((doc) {
final data = doc.data() as Map<String, dynamic>;
return Transaction(
id: doc.id,
title: data['title'],
amount: data['amount'].toDouble(),
date: (data['date'] as Timestamp).toDate(),
category: data['category'],
isExpense: data['isExpense'],
);
}).toList();
} catch (e) {
debugPrint('Error getting transactions from Firestore: $e');
rethrow;
}
}
// Get real-time transaction stream
Stream<List<Transaction>> getTransactionsStream() {
if (_userTransactionsCollection == null) {
return Stream.empty();
}
return _userTransactionsCollection!
.orderBy('date', descending: true)
.snapshots()
.map((querySnapshot) {
return querySnapshot.docs.map((doc) {
final data = doc.data() as Map<String, dynamic>;
return Transaction(
id: doc.id,
title: data['title'],
amount: data['amount'].toDouble(),
date: (data['date'] as Timestamp).toDate(),
category: data['category'],
isExpense: data['isExpense'],
);
}).toList();
});
}
// Update transaction in Firestore
Future<void> updateTransaction(Transaction transaction) async {
if (_userTransactionsCollection == null) {
throw Exception('User not authenticated');
}
try {
await _userTransactionsCollection!.doc(transaction.id).update({
'title': transaction.title,
'amount': transaction.amount,
'date': Timestamp.fromDate(transaction.date),
'category': transaction.category,
'isExpense': transaction.isExpense,
'updatedAt': FieldValue.serverTimestamp(),
});
debugPrint('Transaction updated in Firestore: ${transaction.title}');
} catch (e) {
debugPrint('Error updating transaction in Firestore: $e');
rethrow;
}
}
// Delete transaction from Firestore
Future<void> deleteTransaction(String transactionId) async {
if (_userTransactionsCollection == null) {
throw Exception('User not authenticated');
}
try {
await _userTransactionsCollection!.doc(transactionId).delete();
debugPrint('Transaction deleted from Firestore: $transactionId');
} catch (e) {
debugPrint('Error deleting transaction from Firestore: $e');
rethrow;
}
}
// Sync local transactions to Firestore
Future<void> syncLocalTransactionsToFirestore(
List<Transaction> localTransactions,
) async {
if (_userTransactionsCollection == null) return;
try {
final batch = _firestore.batch();
for (final transaction in localTransactions) {
final docRef = _userTransactionsCollection!.doc(transaction.id);
batch.set(docRef, {
'title': transaction.title,
'amount': transaction.amount,
'date': Timestamp.fromDate(transaction.date),
'category': transaction.category,
'isExpense': transaction.isExpense,
'syncedAt': FieldValue.serverTimestamp(),
});
}
await batch.commit();
debugPrint(
'Local transactions synced to Firestore: ${localTransactions.length} items',
);
} catch (e) {
debugPrint('Error syncing local transactions to Firestore: $e');
rethrow;
}
}
}
5.3 Update Transaction Service to Sync with Firestore
Update your lib/services/transaction_service.dart:
import 'package:flutter/foundation.dart';
import '../helpers/database_helper.dart';
import '../models/transaction.dart';
import 'firestore_service.dart';
class TransactionService {
final DatabaseHelper _databaseHelper = DatabaseHelper();
final FirestoreService _firestoreService = FirestoreService();
bool _isOnline = true; // You can implement proper connectivity checking
// Get all transactions (with offline support)
Future<List<Transaction>> getAllTransactions() async {
try {
if (_isOnline) {
// Try to get from Firestore first
final firestoreTransactions = await _firestoreService.getAllTransactions();
// Update local database with Firestore data
for (final transaction in firestoreTransactions) {
await _databaseHelper.insertTransaction(transaction);
}
return firestoreTransactions;
} else {
// Fallback to local database
return await _databaseHelper.getAllTransactions();
}
} catch (e) {
debugPrint('Error getting transactions from Firestore, falling back to local: $e');
// Fallback to local database if Firestore fails
return await _databaseHelper.getAllTransactions();
}
}
// Get real-time transaction stream
Stream<List<Transaction>> getTransactionsStream() {
try {
return _firestoreService.getTransactionsStream();
} catch (e) {
debugPrint('Error getting transaction stream: $e');
return Stream.empty();
}
}
// Add a new transaction (sync to both local and cloud)
Future<void> addTransaction(Transaction transaction) async {
try {
// Always save locally first
await _databaseHelper.insertTransaction(transaction);
// Try to sync to Firestore
if (_isOnline) {
await _firestoreService.addTransaction(transaction);
}
} catch (e) {
debugPrint('Error adding transaction: $e');
// Transaction is saved locally even if cloud sync fails
rethrow;
}
}
// Update an existing transaction
Future<void> updateTransaction(Transaction transaction) async {
try {
// Update locally
await _databaseHelper.updateTransaction(transaction);
// Try to sync to Firestore
if (_isOnline) {
await _firestoreService.updateTransaction(transaction);
}
} catch (e) {
debugPrint('Error updating transaction: $e');
rethrow;
}
}
// Delete a transaction
Future<void> deleteTransaction(String id) async {
try {
// Delete locally
await _databaseHelper.deleteTransaction(id);
// Try to sync to Firestore
if (_isOnline) {
await _firestoreService.deleteTransaction(id);
}
} catch (e) {
debugPrint('Error deleting transaction: $e');
rethrow;
}
}
// Sync local data to Firestore (useful when going online)
Future<void> syncToCloud() async {
try {
final localTransactions = await _databaseHelper.getAllTransactions();
await _firestoreService.syncLocalTransactionsToFirestore(localTransactions);
} catch (e) {
debugPrint('Error syncing to cloud: $e');
rethrow;
}
}
// Calculate total income (from local data for speed)
Future<double> getTotalIncome() async {
final transactions = await _databaseHelper.getAllTransactions();
double total = 0.0;
for (var tx in transactions) {
if (!tx.isExpense) {
total += tx.amount;
}
}
return total;
}
// Calculate total expenses (from local data for speed)
Future<double> getTotalExpenses() async {
final transactions = await _databaseHelper.getAllTransactions();
double total = 0.0;
for (var tx in transactions) {
if (tx.isExpense) {
total += tx.amount;
}
}
return total;
}
// Calculate balance
Future<double> getBalance() async {
final income = await getTotalIncome();
final expenses = await getTotalExpenses();
return income - expenses;
}
}
6. Update Main Screen with Real-time Data
6.4 Create New Main Screen
Create lib/screens/main_screen.dart:
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../screens/currency_converter_screen.dart';
import '../screens/settings_screen.dart';
import '../screens/statistics_screen.dart';
import '../dashboard_screen.dart';
import '../models/transaction.dart';
import '../services/transaction_service.dart';
import '../services/firestore_service.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
final TransactionService _transactionService = TransactionService();
final FirestoreService _firestoreService = FirestoreService();
List<Transaction> _transactions = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_initializeUser();
_setupTransactionStream();
}
// Initialize user profile in Firestore
Future<void> _initializeUser() async {
try {
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
await _firestoreService.createUserProfile(
uid: user.uid,
name: user.displayName ?? 'User',
email: user.email ?? '',
);
}
} catch (e) {
debugPrint('Error initializing user: $e');
}
}
// Set up real-time transaction stream
void _setupTransactionStream() {
_transactionService.getTransactionsStream().listen(
(transactions) {
if (mounted) {
setState(() {
_transactions = transactions;
_isLoading = false;
});
}
},
onError: (error) {
debugPrint('Transaction stream error: $error');
// Fallback to loading from local database
_loadTransactionsFromLocal();
},
);
}
// Fallback to local data
Future<void> _loadTransactionsFromLocal() async {
try {
final transactions = await _transactionService.getAllTransactions();
if (mounted) {
setState(() {
_transactions = transactions;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Error loading transactions from local: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
// Add a new transaction
Future<void> _addTransaction(
String title,
double amount,
String category,
bool isExpense,
) async {
final newTransaction = Transaction(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
amount: amount,
date: DateTime.now(),
category: category,
isExpense: isExpense,
);
try {
await _transactionService.addTransaction(newTransaction);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaction added successfully!')),
);
}
} catch (e) {
debugPrint('Error adding transaction: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to add transaction')),
);
}
}
}
// Delete a transaction
Future<void> _deleteTransaction(String id) async {
try {
await _transactionService.deleteTransaction(id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaction deleted successfully!')),
);
}
} catch (e) {
debugPrint('Error deleting transaction: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to delete transaction')),
);
}
}
}
// Refresh data manually
Future<void> _refreshData() async {
try {
await _transactionService.syncToCloud();
// The stream will automatically update the UI
} catch (e) {
debugPrint('Error refreshing data: $e');
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading your data...'),
],
),
),
);
}
final List<Widget> screens = [
DashboardScreen(
transactions: _transactions,
onAddTransaction: _addTransaction,
onDeleteTransaction: _deleteTransaction,
onRefresh: _refreshData,
),
StatisticsScreen(transactions: _transactions),
const CurrencyConverterScreen(),
const SettingsScreen(),
];
return Scaffold(
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // Show all tabs
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart),
label: 'Statistics',
),
BottomNavigationBarItem(
icon: Icon(Icons.currency_exchange),
label: 'Convert',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
7. Adding User Profile and Sign Out
7.1 Update Settings Screen with Firebase Features
Update your lib/screens/settings_screen.dart:
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../services/auth_service.dart';
import '../services/settings_service.dart';
import '../services/transaction_service.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final AuthService _authService = AuthService();
final SettingsService _settingsService = SettingsService();
final TransactionService _transactionService = TransactionService();
bool _darkMode = false;
bool _notifications = true;
String _currency = 'USD';
bool _isLoading = true;
bool _isSyncing = false;
User? get _currentUser => FirebaseAuth.instance.currentUser;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
try {
final currency = await _settingsService.getCurrency();
final darkMode = await _settingsService.getDarkMode();
final notifications = await _settingsService.getNotifications();
setState(() {
_currency = currency;
_darkMode = darkMode;
_notifications = notifications;
_isLoading = false;
});
} catch (e) {
debugPrint('Error loading settings: $e');
setState(() {
_isLoading = false;
});
}
}
Future<void> _updateDarkMode(bool value) async {
await _settingsService.setDarkMode(value);
setState(() {
_darkMode = value;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Dark mode ${value ? 'enabled' : 'disabled'}')),
);
}
}
Future<void> _updateNotifications(bool value) async {
await _settingsService.setNotifications(value);
setState(() {
_notifications = value;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Notifications ${value ? 'enabled' : 'disabled'}'),
),
);
}
}
Future<void> _updateCurrency(String currency) async {
await _settingsService.setCurrency(currency);
setState(() {
_currency = currency;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Currency changed to $currency')),
);
}
}
Future<void> _syncData() async {
setState(() {
_isSyncing = true;
});
try {
await _transactionService.syncToCloud();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Data synced successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Sync failed: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isSyncing = false;
});
}
}
}
Future<void> _signOut() async {
final confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Sign Out'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Sign Out'),
),
],
);
},
);
if (confirm == true) {
try {
await _authService.signOut();
// AuthWrapper will automatically navigate to login screen
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Sign out failed: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Profile Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
_currentUser?.displayName?.substring(0, 1).toUpperCase() ?? 'U',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_currentUser?.displayName ?? 'User',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
_currentUser?.email ?? '',
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Synced',
style: TextStyle(
color: Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
],
),
),
),
const SizedBox(height: 24),
// Preferences Section
const Text(
'Preferences',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: const Text('Enable dark theme'),
value: _darkMode,
onChanged: _updateDarkMode,
secondary: const Icon(Icons.dark_mode),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Notifications'),
subtitle: const Text('Receive transaction alerts'),
value: _notifications,
onChanged: _updateNotifications,
secondary: const Icon(Icons.notifications),
),
const Divider(height: 1),
ListTile(
title: const Text('Currency'),
subtitle: Text('Current: $_currency'),
leading: const Icon(Icons.attach_money),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: _showCurrencyDialog,
),
],
),
),
const SizedBox(height: 24),
// Cloud Sync Section
const Text(
'Cloud & Sync',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
ListTile(
title: const Text('Sync Data'),
subtitle: const Text('Upload local data to cloud'),
leading: _isSyncing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_sync),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: _isSyncing ? null : _syncData,
),
const Divider(height: 1),
ListTile(
title: const Text('Account Info'),
subtitle: const Text('Manage your account settings'),
leading: const Icon(Icons.account_circle),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Account management coming soon!')),
);
},
),
],
),
),
const SizedBox(height: 24),
// Actions Section
const Text(
'Actions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
ListTile(
title: const Text('Export Data'),
subtitle: const Text('Download your transaction data'),
leading: const Icon(Icons.download),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export feature coming soon!')),
);
},
),
const Divider(height: 1),
ListTile(
title: const Text('About'),
subtitle: const Text('App version and info'),
leading: const Icon(Icons.info),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: _showAboutDialog,
),
const Divider(height: 1),
ListTile(
title: const Text(
'Sign Out',
style: TextStyle(color: Colors.red),
),
subtitle: const Text('Sign out of your account'),
leading: const Icon(Icons.logout, color: Colors.red),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: _signOut,
),
],
),
),
],
),
);
}
void _showCurrencyDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Currency'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: ['USD', 'EUR', 'GBP', 'JPY'].map((currency) {
return RadioListTile<String>(
title: Text(currency),
value: currency,
groupValue: _currency,
onChanged: (value) {
if (value != null) {
_updateCurrency(value);
}
Navigator.of(context).pop();
},
);
}).toList(),
),
);
},
);
}
void _showAboutDialog() {
showAboutDialog(
context: context,
applicationName: 'Personal Finance Tracker',
applicationVersion: '2.0.0',
applicationIcon: const Icon(Icons.account_balance_wallet),
children: [
const Text('A simple app to track your personal finances.'),
const SizedBox(height: 16),
const Text('Now with cloud sync powered by Firebase!'),
],
);
}
}
8. Setting Up Firebase Security Rules
8.1 Understanding Security Rules
Security rules control who can read and write data in Firestore. By default, we started in "test mode" which allows anyone to read/write data. Now we need to secure it.
8.2 Configure Security Rules
Go to Firebase Console → Firestore Database → Rules and replace with:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users can only access their own data
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
// Users can only access their own transactions
match /transactions/{transactionId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}
}
8.3 Understanding the Rules
request.auth != null: User must be signed inrequest.auth.uid == userId: User can only access their own data- Nested rules: Transaction rules inherit from user rules
9. Testing Firebase Integration
9.1 Test User Authentication
- Sign up with a new email and password
- Sign in with existing credentials
- Sign out and verify you're redirected to login
- Try forgot password feature
9.2 Test Cloud Sync
- Add transactions and verify they appear instantly
- Delete transactions and see real-time updates
- Use multiple devices (or browsers) with the same account
- Go offline and verify app still works with local data
9.3 Test Security
- Try to access another user's data (should fail)
- Sign out and try to access data (should fail)
- Test with invalid authentication tokens
10. Key Firebase Concepts Summary
10.1 Authentication
- Sign up/Sign in: Create and authenticate users
- Auth state changes: Listen for login/logout events
- User management: Profile updates, password reset
- Security: Only authenticated users can access their data
10.2 Firestore Database
- Collections: Groups of documents (like folders)
- Documents: Individual records with fields
- Subcollections: Collections inside documents
- Real-time: Changes sync automatically across devices
10.3 Security Rules
- Authentication-based: Control access based on user login
- Data validation: Ensure data meets requirements
- Field-level control: Different permissions for different fields
10.4 Offline Support
- Local caching: Data works offline automatically
- Sync on reconnect: Changes upload when online
- Conflict resolution: Firebase handles simultaneous changes
11. Common Issues and Troubleshooting
11.1 Authentication Issues
Problem: "User not found" or "Wrong password" Solution: Verify email/password, check Firebase console users
Problem: App crashes on auth operations Solution: Ensure Firebase is initialized before any auth calls
11.2 Firestore Permission Errors
Problem: "Insufficient permissions" when accessing data Solution: Check security rules and ensure user is authenticated
Problem: Data not syncing Solution: Verify internet connection and Firestore rules
11.3 Build Errors
Problem: "Duplicate class" or Gradle errors
Solution: Check build.gradle files match the documentation
Problem: "Firebase not initialized"
Solution: Ensure Firebase.initializeApp() is called in main()
12. Key Takeaways
12.1 Benefits of Firebase
- No server management: Google handles all infrastructure
- Real-time sync: Changes appear instantly across devices
- Offline support: App works without internet connection
- Authentication: Secure user system built-in
- Scalability: Handles millions of users automatically
12.2 Best Practices
- Always authenticate users before accessing data
- Set up proper security rules to protect user data
- Handle offline scenarios gracefully
- Sync local and cloud data for best user experience
- Provide loading states for network operations
- Handle errors appropriately with user-friendly messages
12.3 Architecture Pattern
UI Layer (Screens/Widgets)
↕
Service Layer (TransactionService, AuthService)
↕
Data Layer (LocalDB, Firestore)
This separation makes your app:
- Easier to test
- More maintainable
- Flexible for different data sources
Next Steps
In the final lab (Lab 10), we'll:
- Polish the UI with better theming and animations
- Add app icons and splash screens
- Optimize performance and add testing
- Prepare for deployment to app stores
- Add final touches like error boundaries and analytics
Your app now has:
- ✅ User authentication with sign up/login
- ✅ Cloud database with real-time sync
- ✅ Offline support with local caching
- ✅ Multi-device sync across all user devices
- ✅ Secure data with proper access controls
This makes your finance tracker a production-ready app that users can rely on to manage their financial data securely across all their devices!