Last updated: September 11, 2026

Bahasa Indonesia | English

Learning Objectives

After completing this codelab, students will be able to:

Prerequisites

HTTP and REST APIs

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:

MethodMeaning on a resource collectionExample
GETReads data (no side effects).GET /posts, GET /posts/1
POSTCreates a new resource.POST /posts
PUT / PATCHReplaces / partially updates a resource.PUT /posts/1
DELETEDeletes 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 and Dart models

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).

Basic repository pattern

This week's architecture rule (a bridge to Clean Architecture in Week 7):

UI (ConsumerWidget) --watch--> Provider (AsyncValue)
Provider --calls--> Repository --uses--> Dio --HTTP--> REST API

Set up the project

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

1. Null-safe fromJson model

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,
      };
}

2. Centralized Dio configuration

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;
}

3. Repository as the data gateway

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.

4. AsyncNotifier provider + user-friendly errors

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';
}

5. UI: loading, error, empty, success

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),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

6. Entry point with ProviderScope

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(),
      );
}

Test three error scenarios

  1. Run the app with normal internet, observe loading, then the list of 100 posts.
  2. Turn off the internet (airplane mode), press refresh, observe the friendly message + Retry button. Turn the internet back on, press Retry.
  3. Temporarily change baseUrl to a wrong URL, observe the connection error message. Restore it after the test.

Pagination concept

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.

7. Paginated repository

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();
}

8. Notifier with page state

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);

9. Infinite-scroll UI

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.

The role of AI in this codelab

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.

AI Prompt Challenge

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.

AI Verification Checklist

Before accepting AI-generated code, verify the following and record your findings in the README:

Refactoring Challenge

Perform the following refactoring on your API project, then commit with clear messages:

  1. Extract the post row widget into a separate PostTile so ListView.builder stays short and testable.
  2. Move friendlyErrorMessage into lib/data/network_errors.dart so both the paged and non-paged pages can reuse it.
  3. Add a post detail page with GoRouter (/post/:id) showing the full title and body the detail state comes from the already-loaded list, or via the repository when opened directly.

Testing: unit tests + fake repository

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

Self-verification checklist

Mini project / Industry Challenge

Build a data-list application backed by a REST API as this week's assignment (extend the codelab project or start a new one):

  1. Fetch data from a dummy API (JSONPlaceholder /posts or another public keyless API). Display it through repository + Riverpod.
  2. Apply centralized Dio (base URL, timeouts, logging interceptor) and a null-safe fromJson model.
  3. Show all four states: loading, error (+ retry button), empty, success.
  4. Add basic pagination (infinite scroll, 10 items per page) with a double-request guard.
  5. Include at least 2 passing tests (1 model/error-mapping unit test + 1 provider test with a fake repository).
  6. Complete the AI Challenge section and document the prompt, AI output, fixes, and reasons for your technical decisions in docs/.
  7. Push to the portfolio repository under 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.

Reflection

References