1. REST Calls with http
flutter pub add http
import 'package:http/http.dart' as http;
import 'dart:convert';
Future<List<dynamic>> fetchProducts() async {
final response = await http.get(Uri.parse('https://api.example.com/products'));
if (response.statusCode != 200) {
throw Exception('Failed to load products: ${response.statusCode}');
}
return jsonDecode(response.body) as List<dynamic>;
}
Future<void> createProduct(String name, double price) async {
final response = await http.post(
Uri.parse('https://api.example.com/products'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'name': name, 'price': price}),
);
if (response.statusCode != 201) {
throw Exception('Failed to create product');
}
}
2. Typed JSON Models
Working with raw Map<String, dynamic> everywhere loses type
safety and pushes key-name typos to runtime. A typed model with
fromJson/toJson fixes that.
class Product {
final int id;
final String name;
final double price;
Product({required this.id, required this.name, required this.price});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
);
}
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'price': price};
}
Future<List<Product>> fetchProducts() async {
final response = await http.get(Uri.parse('https://api.example.com/products'));
final list = jsonDecode(response.body) as List<dynamic>;
return list.map((json) => Product.fromJson(json as Map<String, dynamic>)).toList();
}
For a larger app, the json_serializable package generates this
boilerplate from annotations — worth adopting once hand-writing
fromJson/toJson for a few dozen models gets tedious.
3. dio: Interceptors, Timeouts & Errors
flutter pub add dio
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer $currentToken';
handler.next(options);
},
onError: (error, handler) {
if (error.response?.statusCode == 401) {
// token expired — trigger a re-auth flow here
}
handler.next(error);
},
));
final response = await dio.get('/products');
http is enough for simple call sites; dio earns its extra
dependency once an app needs shared configuration (a base URL, default headers), an
interceptor pattern (auth tokens, logging), or built-in timeout handling across every
request rather than reimplementing it per call.
4. Loading, Error & Success State
final productsProvider = FutureProvider<List<Product>>((ref) => fetchProducts());
class ProductListScreen extends ConsumerWidget {
const ProductListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
data: (products) => ListView(
children: products.map((p) => ListTile(title: Text(p.name))).toList(),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
);
}
}
FutureProvider wraps the async call in an AsyncValue,
whose .when forces handling all three states explicitly — there's no
way to accidentally forget the error case, which a hand-rolled
isLoading/hasError boolean pair makes easy to miss.
5. Hands-on Exercise
Connect the product list to a real API
Replace hardcoded product data with a real (or public test) API, with correct loading/error handling.
Requirements:
- A
Productmodel withfromJson/toJson, and a repository function fetching a list from a real endpoint (a public test API like fakestoreapi.com works well). - A
FutureProviderwrapping the fetch, consumed withAsyncValue.whencovering data, loading, and error explicitly. - A create-product form using either
httpordioto POST a new product, with a loading indicator on the submit button while the request is in flight. - Deliberately point the fetch at an invalid URL temporarily and confirm the error state renders a real, useful message rather than crashing or showing a blank screen.
- A
dioversion of the same GET request with a 5-second timeout configured, tested by simulating a slow/unreachable endpoint.
If jsonDecode throws a type error on a field you're confident is present, check the JSON's actual type against your cast — a numeric field like price often arrives as an int when it happens to have no decimal places, which breaks a direct as double cast; casting through (json['price'] as num).toDouble(), as shown in this week's model, handles both cases correctly.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does casting a JSON field with (json['price'] as num).toDouble() avoid a bug that json['price'] as double can hit?
Why does casting a JSON field with (json['price'] as num).toDouble() avoid a bug that json['price'] as double can hit?
JSON numbers don't distinguish int from double the way Dart does — a server sending price: 10 (no decimal) decodes to a Dart int, and casting that directly as double throws. Casting through the shared num supertype first, then calling .toDouble(), correctly handles a JSON number arriving as either an int or a double.
Q2
What specific capability does dio provide that would require manually reimplementing per call site with plain http?
What specific capability does dio provide that would require manually reimplementing per call site with plain http?
Shared, centralized configuration and behavior across every request — a base URL, default timeouts, and interceptors that run on every request/response (like automatically attaching an auth header, or reacting to every 401). With plain http, each of those would need to be manually repeated at every individual call site instead of configured once.
Q3
Why does using AsyncValue.when make it harder to accidentally ship a screen with no error handling, compared to a hand-rolled isLoading/hasError boolean pair?
Why does using AsyncValue.when make it harder to accidentally ship a screen with no error handling, compared to a hand-rolled isLoading/hasError boolean pair?
.when requires a callback for all three branches — data, loading, and error — as a matter of the method's own required parameters, so omitting the error case is a compile error, not just an easy oversight. A hand-rolled boolean-flag approach has no such enforcement; it's entirely possible to check isLoading and forget to ever check (or render) the error case.
Q4
Why does the exercise ask you to deliberately point the fetch at an invalid URL, rather than just trusting the error branch works because it compiles?
Why does the exercise ask you to deliberately point the fetch at an invalid URL, rather than just trusting the error branch works because it compiles?
Code compiling only confirms the error branch is syntactically present and type-correct — it says nothing about whether the actual error-handling logic behaves correctly, renders something useful, or avoids crashing when a real failure occurs. Deliberately triggering the failure path is the only way to actually verify the error state does what it's supposed to, the same reasoning behind testing any error path rather than assuming it from the code alone.