Last updated: September 11, 2026
After completing this codelab, students will be able to:
HTTP is a request–response protocol: the client sends a request (method + URL + headers + body), the server replies with a status code + body. REST is an architectural style that maps operations to resources via URLs and HTTP methods:
| Method | Meaning on a resource collection | Example |
|---|---|---|
GET | Reads data (no side effects). | GET /posts, GET /posts/1 |
POST | Creates a new resource. | POST /posts |
PUT / PATCH | Replaces / partially updates a resource. | PUT /posts/1 |
DELETE | Deletes a resource. | DELETE /posts/1 |
Key status codes: 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Internal Server Error. A mobile app must prepare a UI for each group: success (2xx), client errors (4xx), and server/network errors (5xx/timeout).
JSON is the standard data-exchange format of APIs. Example response of GET https://jsonplaceholder.typicode.com/posts/1:
{
"userId": 1,
"id": 1,
"title": "sunt aut facere...",
"body": "quia et suscipit..."
}
In Dart, raw JSON (Map<String, dynamic>) must be mapped to model classes to be null-safe and typo-proof. Manual fromJson/toJson is enough for this codelab; for large projects use a code generator (json_serializable / freezed).
This week's architecture rule (a bridge to Clean Architecture in Week 7):
AsyncValue to the UI (loading/error/data).UI (ConsumerWidget) --watch--> Provider (AsyncValue)
Provider --calls--> Repository --uses--> Dio --HTTP--> REST API
flutter create week4_api
cd week4_api
flutter pub add dio flutter_riverpod
Folder structure:
lib/
├── main.dart
├── data/
│ ├── api_client.dart
│ ├── models/
│ │ └── post.dart
│ └── repositories/
│ └── post_repository.dart
└── pages/
└── post_list_page.dart
Create lib/data/models/post.dart. This week's dummy API is JSONPlaceholder (free, no API key) with endpoint GET /posts.
class Post {
const Post({
required this.userId,
required this.id,
required this.title,
required this.body,
});
final int userId;
final int id;
final String title;
final String body;
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
userId: (json['userId'] as num?)?.toInt() ?? 0,
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '',
);
}
Map<String, dynamic> toJson() => {
'userId': userId,
'id': id,
'title': title,
'body': body,
};
}
Create lib/data/api_client.dart. All networking configuration (base URL, timeouts, logging) lives in one place:
import 'package:dio/dio.dart';
Dio createDio() {
final dio = Dio(
BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {'Accept': 'application/json'},
),
);
dio.interceptors.add(
LogInterceptor(requestBody: true, responseBody: false),
);
return dio;
}
Create lib/data/repositories/post_repository.dart:
import 'package:dio/dio.dart';
import '../models/post.dart';
class PostRepository {
PostRepository(this._dio);
final Dio _dio;
Future<List<Post>> fetchPosts() async {
final response = await _dio.get<List>('/posts');
final data = response.data ?? [];
return data
.whereType<Map<String, dynamic>>()
.map(Post.fromJson)
.toList();
}
}
Note: the repository shows no UI and never silently swallows exceptions. Exceptions propagate so the provider can turn them into AsyncError automatically in the next step.
Create lib/data/providers.dart. The provider turns technical exceptions into messages suitable for users:
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import 'api_client.dart';
import 'models/post.dart';
import 'repositories/post_repository.dart';
final dioProvider = Provider<Dio>((ref) => createDio());
final postRepositoryProvider = Provider<PostRepository>(
(ref) => PostRepository(ref.watch(dioProvider)),
);
class PostListNotifier extends AsyncNotifier<List<Post>> {
@override
Future<List<Post>> build() async {
// Exceptions from the repository automatically become AsyncError.
// Automatic retry is disabled in the provider declaration below
// so errors are final and easy to test.
final repository = ref.watch(postRepositoryProvider);
return repository.fetchPosts();
}
Future<void> refresh() async {
state = const AsyncLoading();
try {
final repository = ref.read(postRepositoryProvider);
state = AsyncData(await repository.fetchPosts());
} catch (e, st) {
state = AsyncError(e, st);
}
}
}
final postListProvider =
AsyncNotifierProvider<PostListNotifier, List<Post>>(
PostListNotifier.new,
// Disable Riverpod 3 automatic retry so errors are final
// and testable (otherwise the provider future in tests
// would retry and hang).
retry: (retryCount, error) => null);
String friendlyErrorMessage(Object error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return 'Slow connection or timeout. Check your internet and retry.';
case DioExceptionType.connectionError:
return 'Cannot reach the server. Check your internet connection.';
case DioExceptionType.badResponse:
final code = error.response?.statusCode;
if (code == 404) return 'Data not found (404).';
if (code == 401 || code == 403) {
return 'Access denied ($code). Check your credentials.';
}
return 'Server problem ($code). Try again later.';
default:
return 'A network error occurred. Try again.';
}
}
return 'An unexpected error occurred: $error';
}
Create lib/pages/post_list_page.dart. Each state gets its own presentation:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/providers.dart';
class PostListPage extends ConsumerWidget {
const PostListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final postsAsync = ref.watch(postListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Posts API'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () =>
ref.read(postListProvider.notifier).refresh(),
),
],
),
body: postsAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(friendlyErrorMessage(err),
textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton(
onPressed: () => ref.invalidate(postListProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (posts) {
if (posts.isEmpty) {
return const Center(
child: Text('No data from the server yet.'));
}
return RefreshIndicator(
onRefresh: () =>
ref.read(postListProvider.notifier).refresh(),
child: ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
leading: CircleAvatar(
child: Text(post.id.toString())),
title: Text(post.title,
maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: Text(post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis),
);
},
),
);
},
),
);
}
}
Fill in lib/main.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'pages/post_list_page.dart';
void main() => runApp(const ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Week 4 - REST API',
theme: ThemeData(
colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const PostListPage(),
);
}
baseUrl to a wrong URL, observe the connection error message. Restore it after the test.APIs with large data do not send everything at once, but page by page. JSONPlaceholder supports the query ?_page=N&_limit=M. UI strategy: infinite scroll load the next page as the user approaches the end of the list, show a small indicator at the bottom without discarding old data.
Add this method to PostRepository:
Future<List<Post>> fetchPostsPage({
required int page,
int limit = 10,
}) async {
final response = await _dio.get<List>(
'/posts',
queryParameters: {'_page': page, '_limit': limit},
);
final data = response.data ?? [];
return data
.whereType<Map<String, dynamic>>()
.map(Post.fromJson)
.toList();
}
Create lib/data/paged_posts.dart immutable state plus a notifier with a double-request guard:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'models/post.dart';
import 'providers.dart';
class PagedPostsState {
const PagedPostsState({
this.items = const [],
this.page = 0,
this.isLoadingMore = false,
this.hasMore = true,
this.error,
});
final List<Post> items;
final int page;
final bool isLoadingMore;
final bool hasMore;
final Object? error;
}
class PagedPostsNotifier extends Notifier<PagedPostsState> {
@override
PagedPostsState build() {
Future.microtask(loadFirstPage);
return const PagedPostsState();
}
Future<void> loadFirstPage() async {
state = const PagedPostsState(isLoadingMore: true);
final repository = ref.read(postRepositoryProvider);
try {
final items =
await repository.fetchPostsPage(page: 1, limit: 10);
state = PagedPostsState(
items: items,
page: 1,
hasMore: items.length == 10,
);
} catch (e) {
state = PagedPostsState(error: e);
}
}
Future<void> loadNextPage() async {
if (state.isLoadingMore || !state.hasMore) return;
final repo = ref.read(postRepositoryProvider);
final currentItems = state.items;
final currentPage = state.page;
state = PagedPostsState(
items: currentItems,
page: currentPage,
isLoadingMore: true,
hasMore: state.hasMore,
);
try {
final next = currentPage + 1;
final items =
await repo.fetchPostsPage(page: next, limit: 10);
state = PagedPostsState(
items: [...currentItems, ...items],
page: next,
hasMore: items.length == 10,
);
} catch (e) {
state = PagedPostsState(
items: currentItems,
page: currentPage,
error: e,
);
}
}
}
final pagedPostsProvider =
NotifierProvider<PagedPostsNotifier, PagedPostsState>(
PagedPostsNotifier.new);
Create lib/pages/paged_post_page.dart with a ScrollController that triggers the next page 200px before the end of the list:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/paged_posts.dart';
import '../data/providers.dart';
class PagedPostPage extends ConsumerStatefulWidget {
const PagedPostPage({super.key});
@override
ConsumerState<PagedPostPage> createState() =>
_PagedPostPageState();
}
class _PagedPostPageState
extends ConsumerState<PagedPostPage> {
final _controller = ScrollController();
@override
void initState() {
super.initState();
_controller.addListener(() {
if (_controller.position.pixels >=
_controller.position.maxScrollExtent - 200) {
ref.read(pagedPostsProvider.notifier).loadNextPage();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(pagedPostsProvider);
if (state.error != null && state.items.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Posts Paged')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(friendlyErrorMessage(state.error!)),
const SizedBox(height: 12),
FilledButton(
onPressed: () => ref
.read(pagedPostsProvider.notifier)
.loadFirstPage(),
child: const Text('Retry'),
),
],
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Posts Paged')),
body: ListView.builder(
controller: _controller,
itemCount: state.items.length + 1,
itemBuilder: (context, index) {
if (index == state.items.length) {
if (!state.hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('All data loaded.')),
);
}
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final post = state.items[index];
return ListTile(
leading: CircleAvatar(
child: Text(post.id.toString())),
title: Text(post.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
);
},
),
);
}
}
Change home in main.dart to PagedPostPage, run, and scroll to the bottom. Observe: page 1 appears first, the indicator shows, and data grows without a full reload.
Week 4 is advanced material: AI may help design the repository layer, but you must test the error handling and fix the logic. The grade is not based on how much code AI produces, but on prompt quality, verification, and documentation.
Ask an AI coding assistant (Cursor, Copilot, Claude Code, or equivalent) with the following prompt:
Create a Flutter repository layer for the GET /comments?postId={id}
endpoint of JSONPlaceholder using Dio + flutter_riverpod.
Requirements:
- Comment model with null-safe fromJson (postId, id, name, email, body).
- CommentRepository with fetchComments(postId) + 10-second timeout.
- AsyncNotifierProvider with automatic error handling (AsyncError)
and a user-friendly error-message function for timeout,
connection error, 404, and 500.
- One unit test for fromJson with missing fields.
Explain each part of the code with comments.
Before accepting AI-generated code, verify the following and record your findings in the README:
fromJson null-safe, or does it still use direct casts that can crash?DioExceptionType values (timeout, connectionError, badResponse) mapped to user messages?baseUrl/timeouts centralized in one client instead of scattered across methods?flutter analyze and flutter test, does the AI output pass without warnings?Perform the following refactoring on your API project, then commit with clear messages:
PostTile so ListView.builder stays short and testable.friendlyErrorMessage into lib/data/network_errors.dart so both the paged and non-paged pages can reuse it./post/:id) showing the full title and body the detail state comes from the already-loaded list, or via the repository when opened directly.Create test/post_test.dart, test null-safe parsing, error mapping, and the provider with a fake repository (no internet). First the imports and the fake:
import 'package:flutter_test/flutter_test.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:week4_api/data/models/post.dart';
import 'package:week4_api/data/providers.dart';
import 'package:week4_api/data/repositories/post_repository.dart';
class FakePostRepository extends PostRepository {
FakePostRepository({this.items, this.throwError = false})
: super(Dio());
final List<Post>? items;
final bool throwError;
@override
Future<List<Post>> fetchPosts() async {
if (throwError) {
throw DioException(
requestOptions: RequestOptions(path: '/posts'),
type: DioExceptionType.connectionError,
);
}
return items ?? const [];
}
@override
Future<List<Post>> fetchPostsPage(
{required int page, int limit = 10}) async {
return fetchPosts();
}
}
void main() {
test('fromJson is safe against missing fields', () {
final post = Post.fromJson({'id': 7});
expect(post.id, 7);
expect(post.title, '');
expect(post.userId, 0);
});
test('friendlyErrorMessage for connection error', () {
final err = DioException(
requestOptions: RequestOptions(path: '/posts'),
type: DioExceptionType.connectionError,
);
expect(friendlyErrorMessage(err), contains('reach'));
});
test('provider succeeds with a fake repository', () async {
final container = ProviderContainer(
overrides: [
postRepositoryProvider.overrideWithValue(
FakePostRepository(items: [
const Post(
userId: 1, id: 1, title: 'Test', body: 'Body'),
]),
),
],
);
addTearDown(container.dispose);
// Use the readPostsOnce helper (see providers.dart).
final posts = await readPostsOnce(container);
expect(posts.length, 1);
expect(posts.first.title, 'Test');
});
test('provider errors with a fake repository', () async {
final container = ProviderContainer(
overrides: [
postRepositoryProvider.overrideWithValue(
FakePostRepository(throwError: true),
),
],
);
addTearDown(container.dispose);
// Use the readPostsErrorOnce helper (see providers.dart).
final err = await readPostsErrorOnce(container);
expect(err, isA<DioException>());
expect(friendlyErrorMessage(err!), contains('reach'));
});
}
Final test file structure: imports + FakePostRepository + main() with 4 tests. Run:
flutter analyze
flutter test
flutter analyze has no issues and all tests pass.docs/ folder.Build a data-list application backed by a REST API as this week's assignment (extend the codelab project or start a new one):
/posts or another public keyless API). Display it through repository + Riverpod.fromJson model.docs/.04-week-4-networking-rest-api/ with the structure lib/, test/, docs/, README.md, and screenshots/. The README explains the objective, main features, technology stack, run instructions, and achieved results._page/_limit)?AsyncError without try/catch in every widget? When is explicit try/catch still needed?