Last updated: August 31, 2026

Bahasa Indonesia | English

Learning Objectives

After completing this codelab, students will be able to:

Prerequisites

Basic navigation in Flutter

Navigation is the mechanism for moving between screens. In Flutter, each screen is a route pushed onto the Navigator (a stack). The classic approach (Navigator 1.0) uses Navigator.push and Navigator.pop:

Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => const DetailPage()),
);

This approach is simple but hard to manage in larger applications: routes are not structured, deep links are complicated, and guards (for example login redirects) are scattered across many places.

GoRouter

GoRouter is the declarative router recommended by Flutter. Its core concepts are:

ConceptExplanation
GoRouteDefines a path and the target widget, e.g. /, /detail/:id.
context.go()Navigates by replacing the stack (useful for login redirects).
context.push()Pushes a new route on top of the stack (useful for detail screens).
path parameterA dynamic value in the path, accessed through state.pathParameters.
extraSends an object between routes (use with care; it is not preserved across a web restart).
redirectA centralized navigation guard, e.g. checking the login status.

Lab 1 — Multi-page application with GoRouter

Create a new project:

flutter create week3_navigation
cd week3_navigation
flutter pub add go_router

Organize the folder structure:

lib/
├── main.dart
└── pages/
    ├── home_page.dart
    └── detail_page.dart

1. Define the router in lib/main.dart:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'pages/detail_page.dart';
import 'pages/home_page.dart';

void main() => runApp(const MyApp());

final _router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
      routes: [
        GoRoute(
          path: 'detail/:id',
          builder: (context, state) => DetailPage(
            id: state.pathParameters['id']!,
          ),
        ),
      ],
    ),
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Week 3 - Navigation',
      routerConfig: _router,
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
    );
  }
}

2. Home page (lib/pages/home_page.dart):

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) => ListTile(
          title: Text('Item ${index + 1}'),
          onTap: () => context.go('/detail/${index + 1}'),
        ),
      ),
    );
  }
}

3. Detail page (lib/pages/detail_page.dart):

import 'package:flutter/material.dart';

class DetailPage extends StatelessWidget {
  final String id;
  const DetailPage({super.key, required this.id});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Detail $id')),
      body: Center(child: Text('You opened item with id: $id')),
    );
  }
}

4. Run and observe. Open an item, then press the system back button. Notice that the path changes with the active screen the same path can also be accessed directly without going through Home. This is the advantage of a declarative router over Navigator 1.0.

Why do we need state management?

setState is enough for state local to a single widget. However, when state must be shared across many screens (for example a ToDo list shown in Home and modified in another screen), lifting state up the widget tree makes the code complex (prop drilling). State management moves state out of the widgets so that:

In this course we use Riverpod a Provider-based solution that is compile-safe, not tied to BuildContext, and easy to test.

Core Riverpod concepts

ConceptExplanation
ProviderScopeThe global container that stores all providers; it wraps the application root.
ProviderA read-only/immutable value (e.g. configuration or a service).
Notifier + NotifierProviderMutable state changed through methods; the UI calls methods rather than mutating state directly.
ConsumerWidgetA widget that can read providers through ref.
ref.watch vs ref.readwatch: rebuilds when state changes (inside build). read: reads once (inside callbacks/events).

Lab 2 — ToDo application with Riverpod

flutter create week3_todo
cd week3_todo
flutter pub add flutter_riverpod

1. Wrap the application with ProviderScope in lib/main.dart:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'pages/todo_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 3 - ToDo',
        theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
        home: const TodoPage(),
      );
}

2. Create the state and provider (lib/providers/todo_provider.dart):

import 'package:flutter_riverpod/flutter_riverpod.dart';

class Todo {
  Todo(this.title, {this.done = false});
  final String title;
  final bool done;

  Todo copyWith({String? title, bool? done}) =>
      Todo(title ?? this.title, done: done ?? this.done);
}

class TodoListNotifier extends Notifier<List<Todo>> {
  @override
  List<Todo> build() => const [];

  void add(String title) => state = [...state, Todo(title)];

  void toggle(int index) {
    final todos = [...state];
    todos[index] = todos[index].copyWith(done: !todos[index].done);
    state = todos;
  }

  void remove(int index) => state = [...state]..removeAt(index);
}

final todoListProvider =
    NotifierProvider<TodoListNotifier, List<Todo>>(TodoListNotifier.new);

3. Build the UI with ConsumerWidget (lib/pages/todo_page.dart):

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_provider.dart';

class TodoPage extends ConsumerWidget {
  const TodoPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(todoListProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('ToDo Riverpod')),
      body: todos.isEmpty
          ? const Center(child: Text('No tasks yet'))
          : ListView.builder(
              itemCount: todos.length,
              itemBuilder: (context, index) => ListTile(
                leading: Checkbox(
                  value: todos[index].done,
                  onChanged: (_) =>
                      ref.read(todoListProvider.notifier).toggle(index),
                ),
                title: Text(
                  todos[index].title,
                  style: TextStyle(
                      decoration: todos[index].done
                          ? TextDecoration.lineThrough
                          : null),
                ),
                trailing: IconButton(
                  icon: const Icon(Icons.delete),
                  onPressed: () =>
                      ref.read(todoListProvider.notifier).remove(index),
                ),
              ),
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('New task'),
        content: TextField(controller: controller, autofocus: true),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              if (controller.text.trim().isNotEmpty) {
                ref
                    .read(todoListProvider.notifier)
                    .add(controller.text.trim());
              }
              Navigator.pop(context);
            },
            child: const Text('Add'),
          ),
        ],
      ),
    );
  }
}

4. Note the important patterns: ref.watch inside build automatically rebuilds the page when the list changes; ref.read(todoListProvider.notifier) inside a callback only calls a method without subscribing.

The problem with async state

Much state comes from asynchronous processes (reading a database, calling an API). The UI must show three possibilities: loading (in progress), error (failed), and success (data ready). Managing three boolean flags manually is error-prone (isLoading and hasError can become inconsistent).

AsyncValue

Riverpod provides AsyncValue<T>, which models all three conditions in a single type. Use AsyncNotifier for async state:

class ProductsNotifier extends AsyncNotifier<List<String>> {
  @override
  Future<List<String>> build() async {
    await Future.delayed(const Duration(seconds: 2)); // simulate network
    return ['Keyboard', 'Mouse', 'Monitor'];
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => _fetch());
  }

  Future<List<String>> _fetch() async {
    await Future.delayed(const Duration(seconds: 1));
    return ['Keyboard', 'Mouse', 'Monitor', 'Headset'];
  }
}

final productsProvider =
    AsyncNotifierProvider<ProductsNotifier, List<String>>(
        ProductsNotifier.new);

On the UI side, an AsyncValue can be matched with when or if-case:

class ProductPage extends ConsumerWidget {
  const ProductPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: productsAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('Failed to load: $err'),
              FilledButton(
                onPressed: () => ref.invalidate(productsProvider),
                child: const Text('Retry'),
              ),
            ],
          ),
        ),
        data: (products) => ListView.builder(
          itemCount: products.length,
          itemBuilder: (context, index) =>
              ListTile(title: Text(products[index])),
        ),
      ),
    );
  }
}

Lab 3 — Test all three states

  1. Copy the code above into your ToDo project (or a separate project) and run it. Observe the loading screen for the first 2 seconds.
  2. Temporarily change build() to throw an error: throw Exception('Failed to connect to the server');. Run and observe the error screen with its Retry button.
  3. Press Retry ref.invalidate re-runs the provider. Restore the code and confirm the success state is shown.
  4. Reflect: why is showing stale data with a refresh indicator sometimes better than blanking the screen? When is that pattern important?

The role of AI in this codelab

For navigation and state management, AI may be used as a co-developer to help create boilerplate but you must still read, explain, verify, fix, and test the result. 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 page named StatsPage using flutter_riverpod.
Requirements:
- A ConsumerWidget with one AsyncNotifierProvider that simulates
  fetching statistics (2-second delay, ~30% chance of failure).
- The UI must handle loading (spinner), error (message + retry button),
  and success (ListView with 3 items).
- Provide a unit test for the notifier.
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 ToDo application, then commit with clear messages:

  1. Split the ToDo row widget into a separate TodoTile so build is shorter and easier to test.
  2. Extract filtering logic (for example, show only unfinished tasks) into a derived Provider that reads todoListProvider.
  3. Integrate the ToDo application with GoRouter: / for the list and /stats for a statistics page, adding a NavigationBar to switch between them.

Testing

A widget test to make sure the UI reacts to provider state changes:

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:week3_todo/main.dart';

void main() {
  testWidgets('adds a new task', (tester) async {
    await tester.pumpWidget(const ProviderScope(child: MyApp()));
    expect(find.text('No tasks yet'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.add));
    await tester.pumpAndSettle();

    await tester.enterText(find.byType(TextField), 'Do week 3 homework');
    await tester.tap(find.text('Add'));
    await tester.pump();

    expect(find.text('Do week 3 homework'), findsOneWidget);
  });
}

Run all verifications:

flutter analyze
flutter test

Self-verification checklist

Mini project / Industry Challenge

Build a ToDo application with navigation and Riverpod as this week's assignment:

  1. At least 2 pages with GoRouter: a task list and a detail/statistics page.
  2. State managed with Riverpod (Notifier), the UI using ConsumerWidget.
  3. Add a simulated async feature with AsyncValue: loading, error, and success states must display correctly.
  4. Include at least 1 unit/widget test that passes.
  5. Complete the AI Challenge section and document the prompt, AI output, fixes, and the reasons for your technical decisions.
  6. Push to the portfolio repository under 03-week-3-navigation-state-management/ with the structure lib/, test/, README.md, and screenshots/. The README should explain the objective, main features, technology stack, run instructions, and achieved results.

Reflection

References