Terakhir diperbarui: 11 September 2026
Setelah menyelesaikan codelab ini, mahasiswa mampu:
HTTP adalah protokol request–response: client mengirim request (method + URL + header + body), server membalas dengan status code + body. REST adalah gaya arsitektur yang memetakan operasi ke resource melalui URL dan method HTTP:
| Method | Makna pada koleksi resource | Contoh |
|---|---|---|
GET | Membaca data (tanpa efek samping). | GET /posts, GET /posts/1 |
POST | Membuat resource baru. | POST /posts |
PUT / PATCH | Mengganti / memperbarui sebagian resource. | PUT /posts/1 |
DELETE | Menghapus resource. | DELETE /posts/1 |
Status code penting: 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Internal Server Error. Aplikasi mobile wajib menyiapkan UI untuk tiap kelompok: success (2xx), client error (4xx), dan server/network error (5xx/timeout).
JSON (JavaScript Object Notation) adalah format tukar data standar API. Contoh respons GET https://jsonplaceholder.typicode.com/posts/1:
{
"userId": 1,
"id": 1,
"title": "sunt aut facere...",
"body": "quia et suscipit..."
}
Di Dart, JSON mentah (Map<String, dynamic>) harus dipetakan ke class model agar aman terhadap null dan kesalahan ketik. Pola manual fromJson/toJson cukup untuk codelab ini; untuk project besar gunakan code generator (json_serializable / freezed).
Aturan arsitektur minggu ini (jembatan menuju Clean Architecture di Minggu 7):
AsyncValue ke UI (loading/error/data).UI (ConsumerWidget) --watch--> Provider (AsyncValue)
Provider --panggil--> Repository --pakai--> Dio --HTTP--> REST API
flutter create week4_api
cd week4_api
flutter pub add dio flutter_riverpod
Struktur folder:
lib/
├── main.dart
├── data/
│ ├── api_client.dart
│ ├── models/
│ │ └── post.dart
│ └── repositories/
│ └── post_repository.dart
└── pages/
└── post_list_page.dart
Buat lib/data/models/post.dart. API dummy yang dipakai minggu ini adalah JSONPlaceholder (gratis, tanpa API key) dengan 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,
};
}
Buat lib/data/api_client.dart. Seluruh konfigurasi jaringan (base URL, timeout, logging) hidup di satu tempat:
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;
}
Buat 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();
}
}
Perhatikan: repository tidak menampilkan UI apa pun dan tidak menangkap exception menjadi nilai diam-diam. Exception dibiarkan naik agar provider mengubahnya menjadi AsyncError secara otomatis di langkah berikutnya.
Buat lib/data/providers.dart. Provider mengubah exception teknis menjadi pesan yang bisa ditampilkan ke pengguna:
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 {
// Exception dari repository otomatis menjadi AsyncError.
// Inilah ekuivalen deklaratif dari AsyncValue.guard di versi lama.
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,
// Nonaktifkan retry otomatis Riverpod 3 agar error langsung
// final dan mudah diuji (tanpa ini, future provider di-test
// akan me-retry dan menggantung).
retry: (retryCount, error) => null);
/// Helper khusus testing (letakkan di providers.dart): membaca state
/// pertama yang bukan loading lewat listener + completer, sehingga
/// test tidak menunggu retry dan tidak melakukan HTTP sungguhan.
Future<List<Post>> readPostsOnce(ProviderContainer container) {
final completer = Completer<List<Post>>();
final sub = container.listen<AsyncValue<List<Post>>>(
postListProvider,
(previous, next) {
if (next.isLoading || completer.isCompleted) return;
next.whenData(completer.complete);
if (next.hasError) {
completer.completeError(
next.error ?? StateError('unknown error'),
next.stackTrace ?? StackTrace.empty,
);
}
},
fireImmediately: true,
);
return completer.future.whenComplete(sub.close);
}
Future<Object?> readPostsErrorOnce(ProviderContainer container) {
final completer = Completer<Object?>();
final sub = container.listen<AsyncValue<List<Post>>>(
postListProvider,
(previous, next) {
if (next.isLoading || completer.isCompleted) return;
completer.complete(next.error);
},
fireImmediately: true,
);
return completer.future.whenComplete(sub.close);
}
String friendlyErrorMessage(Object error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return 'Koneksi lambat atau timeout. Periksa internet Anda lalu coba lagi.';
case DioExceptionType.connectionError:
return 'Tidak dapat terhubung ke server. Periksa internet Anda.';
case DioExceptionType.badResponse:
final code = error.response?.statusCode;
if (code == 404) return 'Data tidak ditemukan (404).';
if (code == 401 || code == 403) {
return 'Akses ditolak ($code). Periksa kredensial Anda.';
}
return 'Server bermasalah ($code). Coba lagi nanti.';
default:
return 'Terjadi kesalahan jaringan. Coba lagi.';
}
}
return 'Terjadi kesalahan tak terduga: $error';
}
Buat lib/pages/post_list_page.dart. Setiap state mendapat tampilannya sendiri:
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('Coba lagi'),
),
],
),
),
),
data: (posts) {
if (posts.isEmpty) {
return const Center(
child: Text('Belum ada data dari server.'));
}
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),
);
},
),
);
},
),
);
}
}
Isi 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 menjadi URL salah, amati pesan error koneksi. Kembalikan setelah uji.API dengan data besar tidak dikirim sekaligus, melainkan per halaman. JSONPlaceholder mendukung query ?_page=N&_limit=M. Strategi UI: infinite scroll, muat halaman berikut saat pengguna mendekati ujung list, tampilkan indikator kecil di bawah tanpa menghapus data lama.
Tambahkan method berikut ke 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();
}
Lanjutkan lib/data/paged_posts.dart dengan notifier (guard ganda + data lama dipertahankan saat error):
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;
}
Lanjutkan file lib/data/paged_posts.dart dengan notifier:
class PagedPostsNotifier extends Notifier<PagedPostsState> {
@override
PagedPostsState build() {
Future.microtask(loadFirstPage);
return const PagedPostsState();
}
Future<void> loadFirstPage() async {
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);
Buat lib/pages/paged_post_page.dart dengan ScrollController yang memicu halaman berikut 200px sebelum ujung 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('Coba lagi'),
),
],
),
),
);
}
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('Semua data termuat.')),
);
}
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),
);
},
),
);
}
}
Ubah home di main.dart menjadi PagedPostPage, jalankan, dan scroll sampai bawah. Amati: halaman 1 tampil dulu, indikator muncul, data bertambah tanpa reload penuh.
Minggu 4 adalah materi lanjutan: AI boleh membantu merancang repository layer, tetapi Anda wajib menguji error handling dan memperbaiki logic. Nilai bukan pada banyaknya kode yang dihasilkan AI, melainkan pada kualitas prompt, verifikasi, dan dokumentasi.
Minta AI coding assistant (Cursor, Copilot, Claude Code, atau tool setara) dengan prompt berikut:
Buatkan repository layer Flutter untuk endpoint GET /comments?postId={id}
dari JSONPlaceholder menggunakan Dio + flutter_riverpod.
Requirements:
- Model Comment dengan fromJson aman null (postId, id, name, email, body).
- CommentRepository dengan method fetchComments(postId) + timeout 10 detik.
- AsyncNotifierProvider dengan penanganan error otomatis (AsyncError)
dan fungsi pesan error
ramah pengguna untuk timeout, connection error, 404, dan 500.
- Satu unit test untuk fromJson dengan field yang hilang.
Jelaskan setiap bagian kode dalam komentar.
Sebelum kode AI diterima, verifikasi hal berikut dan catat temuan Anda di README:
fromJson aman null, atau masih memakai cast langsung yang bisa crash?DioExceptionType (timeout, connectionError, badResponse) dipetakan ke pesan pengguna?baseUrl/timeout terpusat di satu client, bukan tersebar di tiap method?flutter analyze dan flutter test, apakah hasil AI lolos tanpa warning?Lakukan refactoring berikut pada project API Anda, lalu commit dengan pesan yang jelas:
PostTile tersendiri agar ListView.builder pendek dan mudah diuji.friendlyErrorMessage ke file lib/data/network_errors.dart agar bisa dipakai ulang halaman paged dan non-paged./post/:id) yang menampilkan title dan body lengkap, state detail diambil dari list yang sudah dimuat atau via repository bila langsung dibuka.Buat test/post_test.dart, uji parsing aman null, mapping error, dan provider dengan repository palsu (tanpa internet):
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 aman terhadap field yang hilang', () {
final post = Post.fromJson({'id': 7});
expect(post.id, 7);
expect(post.title, '');
expect(post.userId, 0);
});
test('friendlyErrorMessage untuk connection error', () {
final err = DioException(
requestOptions: RequestOptions(path: '/posts'),
type: DioExceptionType.connectionError,
);
expect(friendlyErrorMessage(err), contains('terhubung'));
});
test('provider sukses dengan repository palsu', () async {
final container = ProviderContainer(
overrides: [
postRepositoryProvider.overrideWithValue(
FakePostRepository(items: [
const Post(
userId: 1, id: 1, title: 'Tes', body: 'Isi'),
]),
),
],
);
addTearDown(container.dispose);
// Gunakan helper readPostsOnce (lihat providers.dart).
final posts = await readPostsOnce(container);
expect(posts.length, 1);
expect(posts.first.title, 'Tes');
});
test('provider error dengan repository palsu', () async {
final container = ProviderContainer(
overrides: [
postRepositoryProvider.overrideWithValue(
FakePostRepository(throwError: true),
),
],
);
addTearDown(container.dispose);
// Gunakan helper readPostsErrorOnce (lihat providers.dart).
final err = await readPostsErrorOnce(container);
expect(err, isA<DioException>());
expect(friendlyErrorMessage(err!), contains('terhubung'));
});
}
Struktur akhir file test: import + FakePostRepository + main() berisi 4 test. Jalankan:
flutter analyze
flutter test
flutter analyze tanpa issue dan semua test lulus.docs/.Bangun aplikasi daftar data dari REST API sebagai tugas minggu ini (kembangkan project codelab atau buat baru):
/posts atau API publik lain tanpa key). Tampilkan ke UI melalui repository + Riverpod.fromJson aman null.docs/.04-week-4-networking-rest-api/ dengan struktur lib/, test/, docs/, README.md, dan screenshots/. README menjelaskan tujuan, fitur utama, stack teknologi, cara menjalankan, dan hasil yang dicapai._page/_limit)?AsyncError tanpa try/catch di setiap widget? Kapan try/catch eksplisit tetap dibutuhkan?