Last updated: August 31, 2026
After completing this codelab, students will be able to:
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 is the declarative router recommended by Flutter. Its core concepts are:
| Concept | Explanation |
|---|---|
GoRoute | Defines 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 parameter | A dynamic value in the path, accessed through state.pathParameters. |
extra | Sends an object between routes (use with care; it is not preserved across a web restart). |
redirect | A centralized navigation guard, e.g. checking the login status. |
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.
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.
| Concept | Explanation |
|---|---|
ProviderScope | The global container that stores all providers; it wraps the application root. |
Provider | A read-only/immutable value (e.g. configuration or a service). |
Notifier + NotifierProvider | Mutable state changed through methods; the UI calls methods rather than mutating state directly. |
ConsumerWidget | A widget that can read providers through ref. |
ref.watch vs ref.read | watch: rebuilds when state changes (inside build). read: reads once (inside callbacks/events). |
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.
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).
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])),
),
),
);
}
}
build() to throw an error: throw Exception('Failed to connect to the server');. Run and observe the error screen with its Retry button.ref.invalidate re-runs the provider. Restore the code and confirm the success state is shown.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.
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.
Before accepting AI-generated code, verify the following and record your findings in the README:
state.add() or direct list mutation)?ref.watch used only inside build, and ref.read inside callbacks?StateProvider antipattern, deprecated StateNotifierProvider, or unnecessary nested Consumer)? Fix them to use the Notifier/ConsumerWidget pattern.flutter analyze and flutter test does the AI output pass without warnings?Perform the following refactoring on your ToDo application, then commit with clear messages:
TodoTile so build is shorter and easier to test.Provider that reads todoListProvider./ for the list and /stats for a statistics page, adding a NavigationBar to switch between them.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
flutter analyze has no issues and all tests pass.docs/ folder.Build a ToDo application with navigation and Riverpod as this week's assignment:
Notifier), the UI using ConsumerWidget.AsyncValue: loading, error, and success states must display correctly.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.setState still enough, and when should state be lifted into Riverpod?context.go and context.push, and when should each be used?AsyncValue prevent bugs compared with three separate booleans?