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.