Ce chapitre est un complément facultatif au cours de formation des 5 jours sur Flutter. À la fin du chapitre 5, je vous ai donné 4 pistes pour continuer :
go_router
riverpod
dio
freezed
json_serializable
Ici, nous les voyons une par une, en reprenant l’application « Gestion Scolaire » que vous connaissez déjà (étudiants, JWT, API Spring Boot). Vous n’avez rien à regretter de ce que vous avez appris : ces outils remplacent des morceaux de code que vous avez écrits à la main (Navigator, ChangeNotifier, http, fromJson/toJson) par des versions plus robustes et moins verbeuses.
Navigator
ChangeNotifier
http
fromJson
toJson
💡 Prérequis : avoir terminé les chapitres 1 à 5, en particulier la navigation (Jour 3), Provider (Jour 3), l’API REST (Jour 4) et le JWT (Jour 5). Les exemples utilisent Dart 3 (sealed, switch avec motifs).
sealed
switch
⚠️ À propos des versions : les numéros de version ci-dessous étaient les bons lorque j’ai rédiger le cours et les TP. Si flutter pub get signale une incompatibilité avec votre SDK, utilisez plutôt flutter pub add nom_du_package : la commande choisit automatiquement une version compatible.
flutter pub get
flutter pub add nom_du_package
Chaque outil répond à une limite précise de ce que nous avons fait jusqu’ici :
Navigator.push
pop
@RequestMapping
@PathVariable
BuildContext
Provider
@Bean
@Autowired
@MockBean
RestClient
toEntity(...)
ClientHttpRequestInterceptor
copyWith
==
@Data
record
Ces outils sont très répandus dans les projets Flutter professionnels, mais aucun n’est obligatoire. Si votre application reste petite, Navigator, Provider et http suffisent largement.
Avec Navigator, chaque écran est ouvert par du code (MaterialPageRoute). Avec go_router, chaque écran possède une URL (/etudiants/12), comme un @GetMapping côté Spring. C’est plus lisible, et cela rend possibles les redirections automatiques (par exemple vers /login si l’utilisateur n’est plus connecté).
MaterialPageRoute
/etudiants/12
@GetMapping
/login
Voici le guide visuel des quatre notions à retenir. Les sections suivantes détaillent chacune d’elles.
flutter pub add go_router
import 'package:go_router/go_router.dart'; final router = GoRouter( initialLocation: '/login', routes: [ GoRoute( path: '/login', builder: (context, state) => const LoginScreen(), ), GoRoute( path: '/etudiants', builder: (context, state) => const EtudiantsScreen(), routes: [ // 'nouveau' AVANT ':id' (voir l'encadré ci-dessous) GoRoute( path: 'nouveau', builder: (context, state) => const EtudiantFormScreen(), ), GoRoute( path: ':id', builder: (context, state) { final id = int.parse(state.pathParameters['id']!); return EtudiantFormScreen(id: id); }, ), ], ), ], );
On branche ensuite le routeur sur l’application avec MaterialApp.router (et non plus MaterialApp) :
MaterialApp.router
MaterialApp
class MonApplication extends StatelessWidget { const MonApplication({super.key}); @override Widget build(BuildContext context) { return MaterialApp.router( title: 'Gestion Scolaire', routerConfig: router, ); } }
⚠️ L’ordre des routes compte. Si :id est déclaré avant nouveau, l’URL /etudiants/nouveau sera lue comme un étudiant dont l’id est « nouveau », et int.parse plantera. Les routes fixes se déclarent toujours avant les routes à paramètre. 💡 Routes imbriquées : nouveau et :id sont déclarées dans la route /etudiants. Leurs chemins sont donc relatifs (sans / au début), et l’écran de la liste reste dessous dans la pile : le bouton retour fonctionne naturellement.
⚠️ L’ordre des routes compte. Si :id est déclaré avant nouveau, l’URL /etudiants/nouveau sera lue comme un étudiant dont l’id est « nouveau », et int.parse plantera. Les routes fixes se déclarent toujours avant les routes à paramètre.
:id
nouveau
/etudiants/nouveau
int.parse
💡 Routes imbriquées : nouveau et :id sont déclarées dans la route /etudiants. Leurs chemins sont donc relatifs (sans / au début), et l’écran de la liste reste dessous dans la pile : le bouton retour fonctionne naturellement.
/etudiants
/
Navigator.push(context, MaterialPageRoute(...))
context.push('/etudiants/nouveau')
Navigator.pushReplacement(...)
context.go('/etudiants')
Navigator.pop(context)
context.pop()
/etudiants?promotion=L3
Lecture des paramètres dans le builder :
builder
final id = state.pathParameters['id']; // /etudiants/:id final promotion = state.uri.queryParameters['promotion']; // ?promotion=L3
La différence entre go et push est essentielle :
go
push
context.go(...)
context.push(...)
Future
context.pop(valeur)
C’est l’équivalent d’un filtre Spring Security : avant d’afficher une route, redirect décide si l’utilisateur a le droit d’y aller. Il peut renvoyer une autre URL, ou null pour laisser passer.
redirect
null
final router = GoRouter( initialLocation: '/etudiants', redirect: (context, state) async { final connecte = await AuthService().estConnecte(); // chapitre 5 final surLogin = state.matchedLocation == '/login'; if (!connecte && !surLogin) return '/login'; // non connecté → login if (connecte && surLogin) return '/etudiants'; // déjà connecté → accueil return null; // sinon, on laisse passer }, routes: [ /* ... */ ], );
Attention à la Boucle infinie : si vous redirigez vers /login sans vérifier que vous n’y êtes pas déjà, redirect s’appelle lui-même sans fin. Le test !surLogin ci-dessus est indispensable.
!surLogin
Cette version a une limite : redirect n’est évalué que lorsque l’on navigue. Si la session expire (erreur 401) pendant que l’utilisateur est sur un écran, rien ne le ramène au login. La solution, refreshListenable, est présentée en section 6, une fois Riverpod en place.
401
refreshListenable
Avec Provider, tout passe par BuildContext : context.watch, context.read. Cela pose 2 problèmes classiques : on ne peut pas lire un état en dehors d’un widget, et une erreur de configuration n’apparaît qu’à l’exécution (ProviderNotFoundException).
context.watch
context.read
ProviderNotFoundException
Riverpod est écrit par le même auteur que Provider. Ses providers sont déclarés comme des variables globales (mais l’état, lui, n’est pas global) et sont accessibles partout via un objet ref.
ref
notifyListeners()
Notifier
AsyncNotifier
state = ...
@Service
ChangeNotifierProvider
NotifierProvider
AsyncNotifierProvider
MultiProvider(...)
main
ProviderScope(...)
ApplicationContext
context.watch<T>()
ref.watch(monProvider)
context.read<T>()
ref.read(monProvider)
Consumer
StatelessWidget
ConsumerWidget
overrides: [...]
flutter pub add flutter_riverpod
On enveloppe l’application dans un ProviderScope, une seule fois, dans main.dart :
ProviderScope
main.dart
import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { runApp(const ProviderScope(child: MonApplication())); }
C’est l’injection de dépendances. Le ref.watch à l’intérieur dit « ce provider dépend de cet autre » :
ref.watch
final tokenStorageProvider = Provider<TokenStorage>((ref) => TokenStorage()); final etudiantServiceProvider = Provider<EtudiantService>( (ref) => EtudiantService(ref.watch(dioProvider)), // dioProvider : section 4 );
Un AsyncNotifier est fait pour un état qui vient d’un appel asynchrone. Sa valeur est un AsyncValue, qui représente à lui seul les trois états que vous codiez à la main au Jour 4 : chargement, erreur, données.
AsyncValue
class EtudiantsNotifier extends AsyncNotifier<List<Etudiant>> { // build() est appelé au premier accès : c'est le chargement initial @override Future<List<Etudiant>> build() { return ref.watch(etudiantServiceProvider).getTous(); } Future<void> ajouter(Etudiant etudiant) async { await ref.read(etudiantServiceProvider).creer(etudiant); ref.invalidateSelf(); // on relance build() pour recharger la liste await future; // on attend la fin du rechargement } Future<void> supprimer(int id) async { await ref.read(etudiantServiceProvider).supprimer(id); final actuels = state.value ?? []; state = AsyncData(actuels.where((e) => e.id != id).toList()); } } final etudiantsProvider = AsyncNotifierProvider<EtudiantsNotifier, List<Etudiant>>( EtudiantsNotifier.new, );
On remplace StatelessWidget par ConsumerWidget, qui reçoit un WidgetRef en plus du context. La méthode .when impose de traiter les trois cas :
WidgetRef
context
.when
class EtudiantsScreen extends ConsumerWidget { const EtudiantsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final etudiants = ref.watch(etudiantsProvider); return Scaffold( appBar: AppBar(title: const Text('Étudiants')), body: etudiants.when( loading: () => const Center(child: CircularProgressIndicator()), error: (erreur, _) => Center(child: Text('Erreur : $erreur')), data: (liste) => RefreshIndicator( // pull-to-refresh : on relance le chargement et on attend le résultat onRefresh: () => ref.refresh(etudiantsProvider.future), child: ListView.builder( itemCount: liste.length, itemBuilder: (context, index) { final etudiant = liste[index]; return ListTile( title: Text(etudiant.nom), subtitle: Text(etudiant.promotion), onTap: () => context.push('/etudiants/${etudiant.id}'), ); }, ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => context.push('/etudiants/nouveau'), child: const Icon(Icons.add), ), ); } }
watch
read
build()
onPressed
onTap
ref.read
Attention avec le ref.read dans build() est l’erreur la plus fréquente : l’écran ne se met jamais à jour. À l’inverse, un ref.watch dans un onPressed est refusé par Riverpod.
C’est l’équivalent d’un @MockBean : on remplace le vrai service par le faux FakeEtudiantService du chapitre 5, sans toucher au code de l’écran.
FakeEtudiantService
testWidgets('affiche les étudiants chargés', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ etudiantServiceProvider.overrideWithValue(FakeEtudiantService()), ], child: const MaterialApp(home: EtudiantsScreen()), ), ); await tester.pumpAndSettle(); // laisse le temps à la liste de se charger expect(find.text('Test'), findsOneWidget); });
Remarque avec Riverpod 3 et les nouvelles tentatives : depuis la version 3, un provider en erreur est relancé automatiquement par défaut (comportement configurable, voir la documentation sur riverpod.dev). Gardez cela en tête si vous ajoutez aussi un retry avec Dio (section 4.5) : deux mécanismes de retry cumulés multiplient les requêtes.
Avec le package http, vous deviez écrire à chaque appel : l’URL complète, les en-têtes, jsonEncode, jsonDecode, et vérifier statusCode. Dio centralise tout cela dans un seul client configuré.
jsonEncode
jsonDecode
statusCode
baseUrl
data:
response.data
DioException
Authorization
connectTimeout
receiveTimeout
dio_smart_retry
flutter pub add dio dio_smart_retry
import 'package:dio/dio.dart'; import 'package:dio_smart_retry/dio_smart_retry.dart'; final dioProvider = Provider<Dio>((ref) { final dio = Dio( BaseOptions( baseUrl: 'http://10.0.2.2:8080/api', // à retirer avant publication ! connectTimeout: const Duration(seconds: 5), receiveTimeout: const Duration(seconds: 10), headers: {'Content-Type': 'application/json'}, ), ); dio.interceptors.addAll([ // intercepteur d'authentification : voir 4.3 et 4.4 // intercepteur de retry : voir 4.5 ]); return dio; });
C’est ce que le chapitre 5 annonçait : l’équivalent d’un filtre appliqué à toutes les requêtes sortantes. Fini le requeteAuthentifiee() à appeler partout.
requeteAuthentifiee()
InterceptorsWrapper( onRequest: (options, handler) async { final token = await ref.read(tokenStorageProvider).lire(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); // on laisse la requête continuer }, ),
Même intercepteur, deuxième méthode : onError. Le scénario est celui du diagramme « token expiré » du chapitre 5, mais traité une seule fois pour toute l’application :
onError
InterceptorsWrapper( // onRequest : voir 4.3 onError: (erreur, handler) async { final estLogin = erreur.requestOptions.path == '/auth/login'; if (erreur.response?.statusCode == 401 && !estLogin) { await ref.read(authProvider.notifier).logout(); // section 6 } handler.next(erreur); // l'erreur continue vers l'appelant }, ),
Remarque si on exclut /auth/login : un mauvais mot de passe renvoie aussi un 401, mais ce n’est pas une session expirée, et il ne faut pas déconnecter l’utilisateur.
/auth/login
Dio n’a pas de retry intégré : on ajoute le package dio_smart_retry, qui fournit un RetryInterceptor.
RetryInterceptor
RetryInterceptor( dio: dio, retries: 3, retryDelays: const [ Duration(seconds: 1), // 1 s avant la 1re tentative Duration(seconds: 2), Duration(seconds: 3), ], // On ne rejoue que les GET, et seulement en cas de problème réseau retryEvaluator: (erreur, tentative) { final estGet = erreur.requestOptions.method == 'GET'; final problemeReseau = erreur.type == DioExceptionType.connectionTimeout || erreur.type == DioExceptionType.receiveTimeout || erreur.type == DioExceptionType.connectionError; return estGet && problemeReseau; }, ),
Attention, Ne rejouez jamais aveuglément un POST. Si la requête est arrivée au serveur mais que la réponse s’est perdue, un retry créerait un doublon (2 étudiants identiques). C’est pourquoi le retryEvaluator ci-dessus limite le retry aux GET, qui sont sans effet sur les données. Il ne faut pas non plus réessayer un 400 ou un 401 : le résultat sera le même à chaque fois.
Attention, Ne rejouez jamais aveuglément un POST. Si la requête est arrivée au serveur mais que la réponse s’est perdue, un retry créerait un doublon (2 étudiants identiques). C’est pourquoi le retryEvaluator ci-dessus limite le retry aux GET, qui sont sans effet sur les données.
POST
retryEvaluator
GET
Il ne faut pas non plus réessayer un 400 ou un 401 : le résultat sera le même à chaque fois.
400
Comparez avec EtudiantService du Jour 4 : plus de jsonDecode, plus de test de statusCode. En cas d’erreur HTTP, Dio lève lui-même une DioException.
EtudiantService
class EtudiantService { final Dio _dio; EtudiantService(this._dio); Future<List<Etudiant>> getTous() async { final reponse = await _dio.get('/etudiants'); return (reponse.data as List) .map((json) => Etudiant.fromJson(json as Map<String, dynamic>)) .toList(); } Future<Etudiant> creer(Etudiant etudiant) async { final reponse = await _dio.post('/etudiants', data: etudiant.toJson()); return Etudiant.fromJson(reponse.data as Map<String, dynamic>); } Future<void> supprimer(int id) => _dio.delete('/etudiants/$id'); }
try { await service.creer(etudiant); } on DioException catch (e) { final message = switch (e.type) { DioExceptionType.connectionTimeout || DioExceptionType.receiveTimeout || DioExceptionType.connectionError => 'Serveur injoignable. Vérifiez votre connexion.', DioExceptionType.badResponse => 'Erreur ${e.response?.statusCode} renvoyée par le serveur.', _ => 'Une erreur inattendue est survenue.', }; // afficher "message" à l'utilisateur }
Au Jour 2 et au Jour 4, vous avez écrit à la main fromJson, toJson, et parfois copyWith, == et hashCode. C’est long, répétitif, et une faute de frappe dans une clé JSON ne se voit qu’à l’exécution.
hashCode
Deux packages génèrent ce code à votre place :
toString
flutter pub add freezed_annotation json_annotation flutter pub add dev:build_runner dev:freezed dev:json_serializable
Les deux premiers sont des dépendances normales (les annotations), les trois autres des dev_dependencies (les générateurs, utilisés uniquement pendant le développement).
dev_dependencies
Etudiant
// lib/models/etudiant.dart import 'package:freezed_annotation/freezed_annotation.dart'; part 'etudiant.freezed.dart'; // généré par Freezed part 'etudiant.g.dart'; // généré par json_serializable @freezed abstract class Etudiant with _$Etudiant { const factory Etudiant({ @JsonKey(includeIfNull: false) int? id, // absent du JSON si null required String nom, required String email, required String promotion, }) = _Etudiant; factory Etudiant.fromJson(Map<String, dynamic> json) => _$EtudiantFromJson(json); }
Ce que l’on écrit :
part
abstract
class
factory
Remarque si l’analyseur affiche l’avertissement invalid_annotation_target sur @JsonKey, ajoutez ceci dans analysis_options.yaml : analyzer: errors: invalid_annotation_target: ignore
Remarque si l’analyseur affiche l’avertissement invalid_annotation_target sur @JsonKey, ajoutez ceci dans analysis_options.yaml :
invalid_annotation_target
@JsonKey
analysis_options.yaml
analyzer: errors: invalid_annotation_target: ignore
dart run build_runner build --delete-conflicting-outputs
Pendant que vous développez, vous pouvez laisser tourner la version « surveillance », qui régénère à chaque sauvegarde :
dart run build_runner watch --delete-conflicting-outputs
Deux fichiers apparaissent à côté du modèle : etudiant.freezed.dart et etudiant.g.dart. Ne les modifiez jamais à la main : ils seraient écrasés à la prochaine génération. Selon les conventions de votre équipe, on les versionne ou on les ajoute au .gitignore (dans ce cas, chacun relance build_runner après un git clone).
etudiant.freezed.dart
etudiant.g.dart
.gitignore
build_runner
git clone
final alice = Etudiant(nom: 'Alice', email: 'alice@test.fr', promotion: 'L3'); // Sérialisation : plus rien à écrire final json = alice.toJson(); final relu = Etudiant.fromJson(json); // Immuabilité : on ne modifie pas, on crée une copie modifiée final promue = alice.copyWith(promotion: 'M1'); // Égalité par valeur (comme un record Java) print(alice == relu); // true print(alice == promue); // false print(alice); // Etudiant(id: null, nom: Alice, ...)
✅ Les tests unitaires du chapitre 5 (fromJson désérialise correctement, toJson ne contient pas id si null) passent sans changement avec ce nouveau modèle. C’est précisément le rôle de @JsonKey(includeIfNull: false).
id
@JsonKey(includeIfNull: false)
Freezed sait aussi modéliser un état qui peut prendre plusieurs formes, chacune avec ses propres données. Exemple : l’état de la connexion.
@freezed sealed class EtatConnexion with _$EtatConnexion { const factory EtatConnexion.deconnecte() = Deconnecte; const factory EtatConnexion.enCours() = EnCours; const factory EtatConnexion.connecte(String token) = Connecte; const factory EtatConnexion.erreur(String message) = ErreurConnexion; }
On les traite ensuite avec un switch Dart 3. Le compilateur vérifie que tous les cas sont couverts : si vous ajoutez une forme et oubliez de la traiter, il refuse de compiler.
final texte = switch (etat) { Deconnecte() => 'Non connecté', EnCours() => 'Connexion…', Connecte() => 'Connecté', ErreurConnexion(:final message) => 'Erreur : $message', };
Voici comment les quatre outils s’articulent dans l’application « Gestion Scolaire ».
On garde les couches du chapitre 4, en ajoutant un dossier router/ :
router/
lib/ ├── main.dart # ProviderScope + MaterialApp.router ├── models/ # Etudiant (Freezed) + fichiers générés ├── services/ # EtudiantService (Dio), TokenStorage ├── providers/ # dioProvider, authProvider, etudiantsProvider... ├── router/ # routerProvider (GoRouter) ├── screens/ # LoginScreen, EtudiantsScreen, EtudiantFormScreen └── widgets/ # composants réutilisables
On remplace le AuthService du chapitre 5 par un AsyncNotifier dont l’état est le token (null si déconnecté). Au démarrage de l’application, build() lit le token dans le stockage sécurisé.
AuthService
class AuthNotifier extends AsyncNotifier<String?> { @override Future<String?> build() => ref.read(tokenStorageProvider).lire(); Future<bool> login(String username, String password) async { try { final reponse = await ref.read(dioProvider).post( '/auth/login', data: {'username': username, 'password': password}, ); final token = reponse.data['token'] as String; await ref.read(tokenStorageProvider).sauvegarder(token); state = AsyncData(token); return true; } on DioException { return false; // identifiants refusés ou serveur injoignable } } Future<void> logout() async { await ref.read(tokenStorageProvider).supprimer(); state = const AsyncData(null); } } final authProvider = AsyncNotifierProvider<AuthNotifier, String?>(AuthNotifier.new);
C’est ici que refreshListenable résout la limite vue en 2.4 : dès que authProvider change, le routeur réévalue redirect. Quand l’intercepteur Dio déclenche logout() après un 401, l’utilisateur est renvoyé au login automatiquement, quel que soit l’écran où il se trouve.
authProvider
logout()
final routerProvider = Provider<GoRouter>((ref) { // petit "signal" que GoRouter écoute pour réévaluer redirect final signal = ValueNotifier<int>(0); ref.listen(authProvider, (_, __) => signal.value++); final router = GoRouter( initialLocation: '/splash', refreshListenable: signal, redirect: (context, state) { final auth = ref.read(authProvider); final lieu = state.matchedLocation; // 1. Lecture du token au démarrage : écran d'attente if (auth.isLoading) return lieu == '/splash' ? null : '/splash'; final connecte = auth.value != null; if (!connecte) return lieu == '/login' ? null : '/login'; if (lieu == '/login' || lieu == '/splash') return '/etudiants'; return null; }, routes: [ GoRoute( path: '/splash', builder: (context, state) => const Scaffold( body: Center(child: CircularProgressIndicator()), ), ), GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), GoRoute( path: '/etudiants', builder: (context, state) => const EtudiantsScreen(), routes: [ GoRoute(path: 'nouveau', builder: (context, state) => const EtudiantFormScreen()), GoRoute( path: ':id', builder: (context, state) => EtudiantFormScreen(id: int.parse(state.pathParameters['id']!)), ), ], ), ], ); ref.onDispose(() { signal.dispose(); router.dispose(); }); return router; });
Important ! Le routeur utilise ref.listen et ref.read, pas ref.watch : si on utilisait watch, le provider reconstruirait un tout nouveau GoRouter à chaque changement d’authentification, et la navigation repartirait de zéro.
ref.listen
GoRouter
void main() { runApp(const ProviderScope(child: MonApplication())); } class MonApplication extends ConsumerWidget { const MonApplication({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp.router( title: 'Gestion Scolaire', routerConfig: ref.watch(routerProvider), ); } }
Le bouton appelle login(). Aucune navigation manuelle n’est nécessaire en cas de succès : l’état d’authentification change, le routeur réévalue redirect, et l’utilisateur arrive sur /etudiants.
login()
onPressed: () async { final ok = await ref .read(authProvider.notifier) .login(_username.text, _motDePasse.text); if (!ok && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Identifiants incorrects')), ); } },
Target of URI hasn't been generated: 'etudiant.freezed.dart'
The non-abstract class '_$Etudiant' is missing implementations...
abstract class Etudiant with _$Etudiant
No ProviderScope found
runApp
No GoRouter found in context
MaterialApp.router(routerConfig: ...)
FormatException
lieu == '/login' ? null : '/login'
DioException [connection error]
localhost
10.0.2.2
Ces exercices s’enchaînent : chacun améliore le projet du chapitre 5. Faites-les dans l’ordre, et lancez l’application après chaque étape pour vérifier que rien n’est cassé. Je n’ai pas écrit de corrections pour cette partie car il n’y a pas de concepts particuliers, seulement une simplification d’écriture grâce à des packages spécifiques.
Installez go_router et déclarez les routes /login, /etudiants, /etudiants/nouveau et /etudiants/:id. Remplacez tous les Navigator.push / pushReplacement / pop par context.push / go / pop.
/etudiants/:id
pushReplacement
context.push
Critère de validation : le bouton retour du téléphone ramène bien à la liste depuis le formulaire, et /etudiants/nouveau ouvre un formulaire vide.
Remplacez votre ChangeNotifier des étudiants par un AsyncNotifier, et vos Consumer par des ConsumerWidget avec .when. Conservez le pull-to-refresh.
Critère de validation : les états chargement, erreur (arrêtez le backend) et liste vide s’affichent correctement, sans aucun bool _chargement écrit à la main.
bool _chargement
Créez le dioProvider avec baseUrl et timeouts, puis un intercepteur qui ajoute le header Authorization. Supprimez la fonction requeteAuthentifiee().
dioProvider
Critère de validation : la liste des étudiants se charge toujours, et il n’existe plus aucun jsonDecode ni test de statusCode dans vos services.
Ajoutez l’intercepteur onError et l’AuthNotifier, puis branchez refreshListenable sur le routeur. Pour simuler l’expiration, modifiez le token stocké (ou raccourcissez sa durée de vie côté Spring Boot).
AuthNotifier
Critère de validation : après l’expiration, la prochaine action sur l’application ramène automatiquement à l’écran de login, sans code de navigation dans l’écran concerné.
Transformez Etudiant en classe Freezed + json_serializable, supprimez vos fromJson / toJson écrits à la main, et relancez flutter test.
flutter test
Critère de validation : les trois tests unitaires de l’exercice 5.2 passent sans modification.
Ajoutez le RetryInterceptor limité aux GET. Coupez le backend, ouvrez la liste, redémarrez le backend pendant les tentatives.
Critère de validation : la liste finit par s’afficher sans action de l’utilisateur, et un POST n’est jamais rejoué.
Note pour vous aider : commencez par l’exercice 6.1 sur un seul écran, vérifiez qu’il fonctionne, puis migrez les autres. Migrer tout d’un coup rend les erreurs très difficiles à localiser.
Vous connaissez maintenant la « boîte à outils » de la plupart des projets Flutter professionnels :
overrides
Pistes pour la suite :
go_router_builder
riverpod_generator
StatefulShellRoute
QueuedInterceptor
Documentation officielle (toujours la référence la plus à jour) :
⬅️ Retour au sommaire Flutter