Aller au contenu
⬅️ Retour au sommaire Flutter ⬅️ Chapitre précédent

Chapitre 6 - Complément : go_router, Riverpod, Dio et Freezed

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 :

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.

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

⚠️ À 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.

Sommaire du chapitre

  1. Pourquoi aller plus loin ?
  2. go_router : la navigation avancée
  3. Riverpod : la gestion d’état moderne
  4. Dio : des appels HTTP robustes
  5. Freezed et json_serializable : générer les modèles
  6. Tout assembler : l’architecture finale
  7. Erreurs fréquentes
  8. Exercices du jour

1. Pourquoi aller plus loin ?

Chaque outil répond à une limite précise de ce que nous avons fait jusqu’ici :

Outil Problème résolu Ce qu’il remplace Équivalent côté Java / Spring
go_router Navigation par URL, paramètres, redirections automatiques Navigator.push / pop @RequestMapping, @PathVariable, filtres Spring Security
riverpod État partagé, injection de dépendances, indépendant de BuildContext Provider + ChangeNotifier @Bean, @Autowired, @MockBean
dio Intercepteurs, timeouts, erreurs HTTP automatiques, retry http RestClient avec toEntity(...), ClientHttpRequestInterceptor
freezed + json_serializable Modèles immuables sans écrire le code répétitif fromJson, toJson, copyWith, == écrits à la main Lombok (@Data), record, Jackson

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.


2. go_router : la navigation avancée

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

Voici le guide visuel des quatre notions à retenir. Les sections suivantes détaillent chacune d’elles.

Flutter - go_router

2.1 Installation

flutter pub add go_router

2.2 Déclarer les routes

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

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.

2.3 Naviguer

Besoin Avant (Navigator) Maintenant (go_router)
Aller sur un écran en empilant Navigator.push(context, MaterialPageRoute(...)) context.push('/etudiants/nouveau')
Aller sur un écran en remplaçant la pile Navigator.pushReplacement(...) context.go('/etudiants')
Revenir en arrière Navigator.pop(context) context.pop()
Passer un identifiant argument du constructeur paramètre de chemin : /etudiants/12
Passer un filtre argument du constructeur paramètre de requête : /etudiants?promotion=L3

Lecture des paramètres dans le 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 :

2.4 Redirections : protéger les écrans

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.

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.

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.


3. Riverpod : la gestion d’état moderne

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

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.

3.1 Correspondances avec ce que vous connaissez

Provider (chapitres 3-5) Riverpod 3 Équivalent Spring
ChangeNotifier + notifyListeners() Notifier / AsyncNotifier + state = ... un @Service avec état
ChangeNotifierProvider NotifierProvider / AsyncNotifierProvider déclaration d’un @Bean
MultiProvider(...) dans main ProviderScope(...) l’ApplicationContext
context.watch<T>() ref.watch(monProvider) injection dans un @Autowired
context.read<T>() ref.read(monProvider) appel ponctuel d’un bean
Consumer / StatelessWidget ConsumerWidget  
Faux service dans un test overrides: [...] @MockBean

3.2 Installation

flutter pub add flutter_riverpod

On enveloppe l’application dans un ProviderScope, une seule fois, dans main.dart :

import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MonApplication()));
}

3.3 Un provider simple : fournir un service

C’est l’injection de dépendances. Le ref.watch à l’intérieur dit « ce provider dépend de cet autre » :

final tokenStorageProvider = Provider<TokenStorage>((ref) => TokenStorage());

final etudiantServiceProvider = Provider<EtudiantService>(
  (ref) => EtudiantService(ref.watch(dioProvider)), // dioProvider : section 4
);

3.4 Un AsyncNotifier : charger une liste depuis l’API

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.

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

3.5 Consommer dans un écran

On remplace StatelessWidget par ConsumerWidget, qui reçoit un WidgetRef en plus du context. La méthode .when impose de traiter les trois cas :

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

3.6 La règle d’or : watch ou read ?

Où êtes-vous ? Utilisez Pourquoi
Dans build() d’un widget ou d’un provider ref.watch l’écran se reconstruit quand la valeur change
Dans un callback (onPressed, onTap, une méthode du notifier) ref.read on lit une seule fois, sans s’abonner

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.

3.7 Tester avec des overrides

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.

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.


4. Dio : des appels HTTP robustes

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

  http (chapitres 4-5) dio
URL de base à répéter dans chaque service baseUrl défini une fois
JSON jsonEncode / jsonDecode manuels automatique (data: et response.data)
Erreur 4xx / 5xx à tester à la main (statusCode) lève une DioException
En-tête Authorization ajouté dans chaque requête intercepteur unique
Timeouts à gérer soi-même connectTimeout, receiveTimeout
Retry à écrire via un intercepteur (dio_smart_retry)

4.1 Installation

flutter pub add dio dio_smart_retry

4.2 Un client configuré, fourni par Riverpod

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

4.3 L’intercepteur qui ajoute le token

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.

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

4.4 L’intercepteur qui gère le 401

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 :

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.

4.5 Réessayer automatiquement (retry)

Dio n’a pas de retry intégré : on ajoute le package dio_smart_retry, qui fournit un 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.

4.6 Le service réécrit avec Dio

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.

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

4.7 Lire proprement une DioException

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
}

5. Freezed et json_serializable : générer les modèles

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.

Deux packages génèrent ce code à votre place :

5.1 Installation

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

5.2 Le modèle Etudiant avec Freezed

// 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 :

Remarque si l’analyseur affiche l’avertissement invalid_annotation_target sur @JsonKey, ajoutez ceci dans analysis_options.yaml :

analyzer:
  errors:
    invalid_annotation_target: ignore

5.3 Lancer la génération

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

5.4 Ce que le modèle sait faire maintenant

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

5.5 Bonus : les types « union » avec sealed

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

6. Tout assembler : l’architecture finale

Voici comment les quatre outils s’articulent dans l’application « Gestion Scolaire ».

6.1 Le flux d’une requête

Flutter et riverpod dio

6.2 Organisation des dossiers

On garde les couches du chapitre 4, en ajoutant un dossier 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

6.3 L’état d’authentification

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

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

6.4 Le routeur qui réagit à la déconnexion

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.

Flutter routeur et déconnexion le flux

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.

6.5 main.dart final

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

6.6 L’écran de connexion

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.

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

7. Erreurs fréquentes

Message ou symptôme Cause probable Solution
Target of URI hasn't been generated: 'etudiant.freezed.dart' build_runner n’a pas été lancé, ou une ligne part est absente Lancer dart run build_runner build --delete-conflicting-outputs et vérifier les deux lignes part
The non-abstract class '_$Etudiant' is missing implementations... Classe Freezed sans abstract (Freezed 3) Écrire abstract class Etudiant with _$Etudiant
No ProviderScope found ProviderScope absent de runApp Envelopper l’application dans ProviderScope
No GoRouter found in context MaterialApp utilisé au lieu de MaterialApp.router Passer par MaterialApp.router(routerConfig: ...)
/etudiants/nouveau plante avec FormatException :id déclaré avant nouveau Mettre les routes fixes avant les routes à paramètre
L’application tourne sans fin entre deux écrans redirect renvoie vers l’écran où l’on est déjà Tester lieu == '/login' ? null : '/login'
L’écran ne se met pas à jour ref.read utilisé dans build() Utiliser ref.watch dans build()
DioException [connection error] sur émulateur Android URL en localhost ou backend arrêté Utiliser 10.0.2.2 et vérifier que Spring Boot tourne
Un étudiant est créé en double après une coupure réseau Retry appliqué aux POST Limiter le retry aux GET (section 4.5)

8. Exercices du jour

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.

Exercice 6.1 — Migrer la navigation vers go_router

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.

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.

Exercice 6.2 — Migrer l’état vers Riverpod

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.

Exercice 6.3 — Passer de http à Dio

Créez le dioProvider avec baseUrl et timeouts, puis un intercepteur qui ajoute le header Authorization. Supprimez la fonction requeteAuthentifiee().

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.

Exercice 6.4 — Gérer le 401 de bout en bout

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

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

Exercice 6.5 — Générer le modèle avec Freezed

Transformez Etudiant en classe Freezed + json_serializable, supprimez vos fromJson / toJson écrits à la main, et relancez flutter test.

Critère de validation : les trois tests unitaires de l’exercice 5.2 passent sans modification.

Exercice 6.6 — Bonus : retry

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.


Pour aller encore plus loin

Vous connaissez maintenant la « boîte à outils » de la plupart des projets Flutter professionnels :

Pistes pour la suite :

Documentation officielle (toujours la référence la plus à jour) :

⬅️ Retour au sommaire Flutter