Skip to content

11. Étude de cas : le client Flutter de l'application RdvMedecins

11.1. Le problème

L'application [RdvMedecins] permet de prendre des rendez-vous dans un cabinet médical. Le cours [NestJS] l'a écrite sous la forme d'une application HTML, dont le serveur fabriquait les pages ; les cours [Vue.js], [Angular] et [React] en ont écrit des clients web, qui fabriquent les MÊMES pages dans le navigateur, à partir des données JSON du serveur présenté au chapitre « Le serveur RdvMedecins, une boîte noire ». Nous en écrivons ici le client [Flutter] : une application mobile ([Android]), qui peut aussi être compilée pour le web.

Le client doit offrir tout ce qu'offraient les autres :

  • un écran de connexion, protégé par un captcha, avec la possibilité de créer un compte de patient ;
  • l'agenda d'un médecin pour un jour donné, avec réservation (fenêtre de choix du patient) et annulation ;
  • pour l'administrateur, la gestion des médecins et des clients (liste, ajout, modification, suppression) ;
  • pour le patient, son espace (son rendez-vous à venir, qu'il peut annuler) et la gestion de son compte ;
  • deux langues, le français et l'anglais ;
  • les mêmes adresses que les clients web ([/agenda?idMedecin=1&jour=...]). Sur le téléphone, elles sont invisibles, mais organisent l'application ; sur le web, on peut les recharger, les mettre en favori, et parcourir l'application avec les boutons Précédent / Suivant du navigateur.

Une application mobile ajoute ses propres exigences : un écran étroit (pas de tableau à cinq colonnes), un clavier qui cache la moitié de l'écran, un bouton Retour, un gestionnaire de mots de passe, et l'absence de navigateur pour garder le cookie du jeton.

11.2. Installation et exécution

Le serveur JSON doit être installé et lancé (cf. chapitre « Le serveur RdvMedecins, une boîte noire »). On n'a PAS besoin du mode test du captcha : on peut laisser [CAPTCHA_TEST] vide dans le fichier [.env] du serveur.

Dans une console ouverte sur le dossier [rdvmedecins_flutter] :

flutter create --platforms=android,web --org fr.stahe --project-name rdvmedecins_flutter .
flutter pub get

La première commande ajoute au projet les dossiers propres à chaque plate-forme ([android/], [web/]), comme pour les exemples ; elle ne modifie aucun fichier existant, et le fichier [android/app/src/main/AndroidManifest.xml] livré (celui de l'exemple 22) est conservé. Puis, au choix :

1
2
3
flutter run -d emulator-5554
flutter run -d R58N12ABCDE --dart-define=SERVEUR=http://192.168.1.20:8080
flutter run -d chrome
  • la première ligne lance l'application sur l'émulateur [Android] (la commande [flutter devices] donne le nom des appareils disponibles). L'émulateur joint le serveur à l'adresse [10.0.2.2:8080] (cf. exemple 22) ;
  • la deuxième, sur un vrai téléphone, branché en USB (avec le « débogage USB » activé dans ses options pour les développeurs), ou relié par Wi-Fi. Le téléphone et l'ordinateur doivent être sur le même réseau ; on donne l'adresse de l'ordinateur sur ce réseau ([ipconfig] sous [Windows]). Le pare-feu de [Windows] doit laisser passer les connexions entrantes sur le port 8080 ;
  • la troisième, dans [Chrome], à l'adresse http://localhost:5173 : le serveur de développement web de [Flutter] transmet les requêtes /api au serveur JSON (fichier [web_dev_config.yaml]).

Les comptes de démonstration sont rappelés sur l'écran de connexion (mot de passe = login). Pendant que l'application tourne, la touche [r] de la console la recharge à chaud (« hot reload ») après une modification du code, [R] la redémarre, [q] l'arrête.

11.3. Utilisation de l'application

Les copies d'écran de cette section sont celles d'un téléphone de 1080 × 2340 pixels (412 × 891 points).

11.3.1. L'écran de connexion

L'écran de connexion ; un mauvais mot de passe : le message s'affiche en bas de l'écran, et une nouvelle image est proposée (chacune ne sert qu'une fois) :

11.3.2. L'agenda, vu par l'administrateur et les médecins

Connecté comme [admin], on arrive sur l'agenda. Le bouton ☰ ouvre le menu : les écrans permis à l'administrateur, et, en haut, qui est connecté :

On choisit un médecin et un jour (le jour, dans un calendrier), puis « Voir l'agenda ». L'adresse devient [/agenda?idMedecin=1&jour=2026-10-05]. « Réserver » ouvre la fenêtre de réservation ; l'adresse devient [/agenda?idMedecin=1&jour=2026-10-05&reserver=3] :

Le rendez-vous est enregistré ; « Annuler » demande confirmation :

Un médecin arrive directement sur SON agenda du jour :

Image

11.3.3. L'espace du patient

Un patient ([martin]) arrive sur « Mon espace ». Dans un agenda, il voit « Réservé » en face des créneaux des autres patients, et réserve pour lui-même, sans choisir de patient :

Son rendez-vous apparaît sous son nom ; il n'a plus de bouton « Réserver » (un seul rendez-vous à venir). Dans l'agenda d'un autre médecin, où son rendez-vous n'apparaît pas, un message lui dit pourquoi il ne peut pas réserver. Son espace montre le rendez-vous :

« Gérer mon compte » : toute modification exige le mot de passe actuel ; les erreurs sont celles du serveur :

Image

Le menu d'un patient n'a pas d'entrée « Médecins ». Sur le web, s'il tape lui-même l'adresse /medecins, le routeur lui affiche une page d'erreur 403 (cf. [routeur.dart]).

11.3.4. La gestion des médecins et des clients

La liste des médecins ; un ajout mal rempli :

Un ajout réussi ; un homonyme ; la suppression d'un médecin qui a des rendez-vous est refusée :

11.3.5. La création d'un compte

Sur l'écran de connexion, on recopie le captcha puis on touche « Créer un compte client ». Le formulaire, refusé par le serveur ; une fois le compte créé, retour à l'écran de connexion, login pré-rempli :

11.3.6. En anglais, sur un grand écran

Le menu FR / EN de la barre du haut retraduit l'écran sur place, sans rien demander au serveur. Sur un écran large (le web, une tablette), le menu reste affiché à gauche :

11.3.7. Session expirée

Si le jeton expire (ou si le cookie disparaît), la requête suivante reçoit 401 ; l'application renvoie à l'écran de connexion :

Image

11.4. L'architecture du client

rdvmedecins_flutter/
├── pubspec.yaml               les bibliothèques, les ressources
├── analysis_options.yaml      les règles de l'analyseur de code
├── web_dev_config.yaml        le serveur de développement web (proxy /api)
├── android/                   le projet Android (dont AndroidManifest.xml)
├── web/                       la page HTML de la version web
├── assets/
│   ├── i18n/                  les dictionnaires fr.json, en.json
│   └── polices/               la police RobotoMono
├── test/                      tests unitaires ; fabrication des copies d'écran
└── lib/
    ├── main.dart              le démarrage
    ├── config.dart            l'adresse du serveur
    ├── routeur.dart           les écrans, leurs adresses, leurs droits
    ├── formatage.dart         la mise en forme des heures et des jours
    ├── api/                   la couche d'accès à l'API
    ├── i18n/                  les langues
    ├── etat/                  session, messages, cache (ChangeNotifier)
    ├── widgets/               le gabarit et les widgets réutilisables
    └── pages/                 les écrans

C'est l'architecture recommandée par le guide de [Flutter] (« App architecture »), en plus simple :

  • une couche données ([api/]) : la seule à connaître le paquet [http], les URL et le JSON ;
  • une couche état ([etat/]) : des [ChangeNotifier] fournis à toute l'application ([provider]), que les écrans observent ;
  • une couche interface ([pages/], [widgets/]) : des widgets, qui affichent l'état et transmettent les actions de l'utilisateur.

Le tableau suivant met en regard l'application HTML du cours [NestJS], le client [React] et ce client :

Application HTML (NestJS)
Client React
Client Flutter
le serveur fabrique chaque page ([res.render])
des composants fabriquent les pages dans le navigateur
des widgets fabriquent les écrans sur le téléphone
[views/layout.hbs] et ses partiels
[Gabarit.tsx] et les composants
[gabarit.dart] (une [ShellRoute]) et les widgets
les routes des contrôleurs
[React Router]
[go_router]
[JwtAuthGuard], [RolesGuard]
les gardes du routeur (middlewares)
la fonction [redirect] du routeur
le cookie du jeton, rangé par le navigateur
idem
un client HTTP qui gère les cookies (sur le téléphone)
message flash dans un cookie
un magasin [Zustand]
un [ChangeNotifier], [Messages]
dictionnaires lus par le serveur
[i18next]
[Traductions] (exemple 21)
langue mémorisée dans un cookie
dans le [localStorage]
dans [shared_preferences]
les données déjà lues
[TanStack Query]
[Cache] (quelques lignes)
formulaire refusé : le serveur réaffiche la page
réponse 400 / 409, erreurs sous les champs
idem

Tous les fichiers du client sont présentés ci-dessous. Ils sont abondamment commentés : leurs commentaires font partie du cours.

11.5. La configuration du projet

11.5.1. pubspec.yaml

# ==========================================================================
# pubspec.yaml - le client Flutter de l'application RdvMedecins
# ==========================================================================

name: rdvmedecins_flutter
description: Le client Flutter (Android, web) de l'application de prise de rendez-vous RdvMedecins.
publish_to: none
version: 1.0.0

environment:
  sdk: ^3.12.0

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  flutter_web_plugins:
    sdk: flutter
  intl: any
  go_router: ^16.2.0
  provider: ^6.1.5
  shared_preferences: ^2.5.3
  http: ^1.5.0
  flutter_svg: ^2.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0

flutter:
  uses-material-design: true
  assets:
    - assets/i18n/
  fonts:
    - family: RobotoMono
      fonts:
        - asset: assets/polices/RobotoMono-Regular.ttf
        - asset: assets/polices/RobotoMono-Bold.ttf
          weight: 700

Les bibliothèques des exemples, moins [flutter_svg]... qui reste : le captcha est une image SVG. Seules ressources : les dictionnaires et la police à chasse fixe des comptes de démonstration.

11.5.2. analysis_options.yaml

# ==========================================================================
# analysis_options.yaml - les règles de l'analyseur de code Dart
# --------------------------------------------------------------------------
# L'analyseur vérifie le code pendant qu'on le tape (VS Code souligne les
# erreurs) et par la commande flutter analyze. On part des règles
# recommandées par l'équipe de Flutter (paquet flutter_lints)...
# ==========================================================================

include: package:flutter_lints/flutter.yaml

analyzer:
  language:
    # ... en plus strict : pas de conversion implicite d'un type dynamic
    strict-casts: true
    strict-raw-types: true

linter:
  rules:
    # des guillemets simples partout, comme dans les exemples du cours
    prefer_single_quotes: true
    # "final" pour une variable locale qui n'est jamais réaffectée
    prefer_final_locals: true
    # les chaînes de caractères trop longues sont permises (textes en français)
    lines_longer_than_80_chars: false

Les règles de l'analyseur, celles des exemples :

  • ligne 9 : les règles recommandées par l'équipe de [Flutter] ;
  • lignes 14-15 : plus de sévérité sur les types : une valeur [dynamic] (celles que renvoie [jsonDecode]) ne peut pas être utilisée comme une [String] sans conversion explicite ([as String], ou le filtrage de [Dart] 3) ;
  • lignes 20-24 : trois règles de présentation.

La commande [flutter analyze] applique ces règles à tout le projet ; [VS Code] le fait au fil de la frappe.

11.5.3. web_dev_config.yaml

# ==========================================================================
# web_dev_config.yaml - le serveur de développement WEB de Flutter
# --------------------------------------------------------------------------
# flutter run -d chrome sert l'application sur http://localhost:5173 et
# transmet toute requête /api/... au serveur JSON RdvMedecins (port 8080) :
# pour le navigateur, l'application et l'API ont la MÊME origine (pas de
# CORS, et le cookie du jeton circule).
# ==========================================================================

server:
  host: localhost
  port: 5173
  proxy:
    - target: "http://localhost:8080/"
      prefix: "/api/"

Celui de l'exemple 22 : en développement web, les requêtes /api sont transmises au serveur JSON.

11.6. Le démarrage de l'application

11.6.1. lib/config.dart

// =========================================================================
// lib/config.dart - OÙ est le serveur JSON ?
// -------------------------------------------------------------------------
// La question ne se pose pas pour une application web servie par le
// serveur lui-même : les requêtes partent vers la MÊME origine que la page
// (une adresse relative, /api/...). Mais une application MOBILE n'a pas
// d'origine : il faut lui donner l'adresse complète du serveur.
//   - l'émulateur Android voit l'ordinateur qui le fait tourner à
//     l'adresse 10.0.2.2 (localhost, pour lui, c'est l'émulateur lui-même) ;
//   - un vrai téléphone, relié au même réseau Wi-Fi que l'ordinateur, doit
//     utiliser l'adresse de l'ordinateur sur ce réseau (192.168.1.20...) :
//     on la donne au lancement de l'application :
//         flutter run --dart-define=SERVEUR=http://192.168.1.20:8080 ...
//   - en développement web (flutter run -d chrome), le serveur de
//     développement de Flutter transmet les requêtes /api au serveur JSON
//     (fichier web_dev_config.yaml) : une adresse relative suffit ;
//   - sur un ordinateur (Windows, macOS, Linux) : localhost.
// =========================================================================

import 'package:flutter/foundation.dart';

// la valeur donnée par --dart-define=SERVEUR=..., '' par défaut.
// String.fromEnvironment est évaluée à la COMPILATION : c'est une constante
const _serveurImpose = String.fromEnvironment('SERVEUR');

class Config {
  // kIsWeb : vrai si l'application tourne dans un navigateur ;
  // defaultTargetPlatform : le système (android, iOS, windows...)
  static String urlServeur = _serveurImpose.isNotEmpty
      ? _serveurImpose
      : kIsWeb
      ? '' // même origine : adresses relatives
      : defaultTargetPlatform == TargetPlatform.android
      ? 'http://10.0.2.2:8080'
      : 'http://localhost:8080';
}

L'adresse du serveur, selon la plate-forme (cf. exemple 22) :

  • ligne 24 : la valeur donnée au lancement par [--dart-define=SERVEUR=...]. [String.fromEnvironment] est évaluée à la compilation : l'adresse est inscrite dans l'application construite ;
  • lignes 29-35 : sinon, une adresse relative sur le web (le serveur de développement, puis le serveur JSON lui-même en production, servent la page ET l'API), [10.0.2.2] sur [Android] (l'ordinateur, vu de l'émulateur), [localhost] ailleurs. [static] : une variable de la classe, et non d'un objet ; pas [final], pour que les tests puissent la changer.

11.6.2. lib/main.dart

// =========================================================================
// lib/main.dart - le démarrage de l'application
// -------------------------------------------------------------------------
// On fabrique, une fois pour toutes, les objets partagés par toute
// l'application, puis on les FOURNIT à tous les écrans (provider) :
//   - le client HTTP (qui gère le cookie du jeton sur le téléphone) et la
//     couche d'accès à l'API ;
//   - la session (qui est connecté ?), les messages, le cache des données ;
//   - les traductions (français, anglais) ;
//   - le routeur, qui affiche l'écran de l'adresse courante.
// Deux mécanismes relient ces objets entre eux :
//   1. la réponse 401 d'une requête protégée (session expirée) : on oublie
//      l'utilisateur et l'on prépare le message ; le routeur, qui écoute la
//      session, renvoie alors à l'écran de connexion (cf. api/client_api.dart) ;
//   2. les messages "flash" : à la fin de CHAQUE navigation, les messages
//      de l'écran précédent disparaissent, ceux déposés pour l'écran
//      suivant apparaissent (cf. etat/messages.dart).
// =========================================================================

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'api/client_api.dart';
import 'api/client_http.dart';
import 'api/rdvmedecins_api.dart';
import 'etat/cache.dart';
import 'etat/messages.dart';
import 'etat/session.dart';
import 'i18n/traductions.dart';
import 'routeur.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  usePathUrlStrategy(); // sur le web : /agenda plutôt que /#/agenda
  await initializeDateFormatting();
  final stockage = await SharedPreferences.getInstance();
  final traductions = Traductions(stockage);
  await traductions.demarrer();
  runApp(Application(stockage: stockage, traductions: traductions));
}

class Application extends StatefulWidget {
  final SharedPreferences stockage;
  final Traductions traductions;

  const Application({super.key, required this.stockage, required this.traductions});

  @override
  State<Application> createState() => _ApplicationState();
}

class _ApplicationState extends State<Application> {
  late final ClientApi _client = ClientApi(ClientAvecCookies(widget.stockage));
  late final RdvMedecinsApi _api = RdvMedecinsApi(_client);
  late final Session _session = Session(_api);
  final Messages _messages = Messages();
  final Cache _cache = Cache();
  late final GoRouter _routeur = creerRouteur(_session, _messages, _api);
  String? _adresse;

  @override
  void initState() {
    super.initState();
    // 1. la session expirée
    _client.onSessionExpiree = () {
      _session.oublier();
      _cache.vider();
      _messages.flash(succes: false, cle: 'ERRORS.SESSION_EXPIRED');
      // pas de _routeur.go('/login') : le garde du routeur s'en charge, et
      // retient l'écran courant (?redirect=...) pour y revenir
    };
    // la session change (connexion, déconnexion) : les données lues pour
    // l'utilisateur précédent ne valent plus rien
    _session.addListener(_cache.vider);
    // 2. à chaque navigation terminée (l'adresse a changé) : nouvelle page
    _routeur.routerDelegate.addListener(() {
      // state : l'écran du DESSUS de la pile (un écran empilé par push compris)
      final adresse = _routeur.state.uri.toString();
      if (adresse != _adresse) {
        _adresse = adresse;
        _messages.nouvellePage();
      }
    });
  }

  @override
  void dispose() {
    _routeur.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        // des objets ordinaires...
        Provider.value(value: _api),
        // ... et des objets qui préviennent de leurs changements
        ChangeNotifierProvider.value(value: _cache),
        ChangeNotifierProvider.value(value: _session),
        ChangeNotifierProvider.value(value: _messages),
        ChangeNotifierProvider.value(value: widget.traductions),
      ],
      child: Consumer<Traductions>(
        builder: (context, traductions, _) => MaterialApp.router(
          debugShowCheckedModeBanner: false,
          onGenerateTitle: (context) => traductions.t('APP.TITLE'),
          theme: ThemeData(colorSchemeSeed: const Color(0xFF0D6EFD)),
          // les textes de Flutter lui-même (calendrier...) dans la langue choisie
          locale: Locale(traductions.langue),
          supportedLocales: [for (final l in langues) Locale(l)],
          localizationsDelegates: GlobalMaterialLocalizations.delegates,
          routerConfig: _routeur,
        ),
      ),
    );
  }
}
  • lignes 37-44 : avant d'afficher quoi que ce soit, on prépare ce qui doit l'être : les adresses sans [#] sur le web (exemple 17), les noms des jours et des mois dans toutes les langues ([intl], exemple 09), le stockage de l'appareil, et le dictionnaire de la langue de départ. [WidgetsFlutterBinding.ensureInitialized()] : ces opérations ont besoin du moteur de [Flutter], qu'on démarre donc avant [runApp] ;
  • lignes 58-63 : les objets partagés, créés UNE fois, dans l'état du widget racine. Chacun reçoit ceux dont il a besoin à sa création : c'est l'injection de dépendances, faite à la main. [late] : l'initialisation a lieu à la première utilisation, quand [widget] existe déjà ;
  • lignes 70-76 : 1. la session expirée. La couche d'accès à l'API ne connaît ni la session ni les messages ; on lui confie cette fonction, qu'elle appellera à chaque réponse 401. On oublie l'utilisateur, on vide le cache, et l'on prépare le message de l'écran suivant. Le changement d'écran, lui, est l'affaire du routeur, qui écoute la session ;
  • ligne 79 : à chaque changement d'utilisateur, le cache est vidé : les données lues pour l'utilisateur précédent ne valent rien pour le suivant (un patient ne doit pas voir l'agenda lu pour l'administrateur) ;
  • lignes 81-88 : 2. les messages flash. Le [routerDelegate] du routeur prévient ses auditeurs à chaque navigation ; on compare l'adresse à la précédente ([_routeur.state] : l'état de l'écran du DESSUS de la pile, y compris un écran empilé par [push], comme la fenêtre de réservation), et, si elle a changé, on passe à une « nouvelle page » : les messages de l'écran précédent disparaissent, ceux déposés pour celui-ci apparaissent (cf. [messages.dart]) ;
  • lignes 99-107 : on fournit les objets partagés à tous les écrans (exemple 20). [.value] : l'objet existe déjà, le fournisseur ne le crée pas (et ne le détruira pas) ;
  • lignes 109-118 : l'application elle-même, reconstruite à chaque changement de langue ([Consumer], cf. exemple 20) : le titre ([onGenerateTitle]), et la langue des textes de [Flutter] lui-même (le calendrier, les infobulles). [colorSchemeSeed] : une couleur, dont [Material] 3 déduit toute la palette ; c'est le bleu de [Bootstrap], celui des clients web.

11.7. La couche d'accès à l'API

11.7.1. lib/api/client_http.dart

// =========================================================================
// lib/api/client_http.dart - un client HTTP qui gère les COOKIES
// -------------------------------------------------------------------------
// Le serveur RdvMedecins dépose le jeton JWT dans un COOKIE (httpOnly).
// Dans un navigateur, c'est le navigateur qui range les cookies et les
// renvoie avec chaque requête : le code de l'application n'a rien à faire.
// Sur un téléphone, il n'y a PAS de navigateur : c'est à l'application de
// jouer son rôle, c'est-à-dire :
//   1. lire l'entête "Set-Cookie" de chaque réponse, et ranger les cookies ;
//   2. les renvoyer dans l'entête "Cookie" de chaque requête.
// Ce client HTTP le fait, en ENVELOPPANT le client du paquet http
// (http.BaseClient : il suffit d'écrire la méthode send). Les cookies sont
// aussi rangés sur l'appareil (shared_preferences) : on reste connecté
// quand on arrête puis relance l'application.
// Sur le web, il ne fait rien de plus que le client ordinaire : le
// navigateur interdit à l'application de lire ou d'écrire ces entêtes.
// =========================================================================

import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

class ClientAvecCookies extends http.BaseClient {
  static const _cle = 'rdvmedecins.cookies';

  final http.Client _interne = http.Client();
  final SharedPreferences? _stockage;
  // nom du cookie -> sa valeur
  final Map<String, String> _cookies = {};

  ClientAvecCookies([this._stockage]) {
    // les cookies mémorisés lors d'une utilisation précédente
    final memorises = _stockage?.getString(_cle);
    if (memorises != null) {
      _cookies.addAll((jsonDecode(memorises) as Map<String, Object?>).cast<String, String>());
    }
  }

  @override
  Future<http.StreamedResponse> send(http.BaseRequest requete) async {
    if (!kIsWeb && _cookies.isNotEmpty) {
      // "rdvmedecins_jwt=eyJhbGci...; autre=valeur"
      requete.headers['cookie'] = [for (final MapEntry(:key, :value) in _cookies.entries) '$key=$value'].join('; ');
    }
    final reponse = await _interne.send(requete);
    final setCookie = reponse.headers['set-cookie'];
    if (!kIsWeb && setCookie != null) {
      _memoriser(setCookie);
    }
    return reponse;
  }

  // Set-Cookie: rdvmedecins_jwt=eyJ...; Max-Age=7200; Path=/api; Expires=Tue, 29 Sep 2026 ...; HttpOnly
  // (plusieurs cookies sont séparés par des virgules). On ne garde que le
  // nom et la valeur de chaque cookie ; une valeur vide (déconnexion) le supprime
  void _memoriser(String setCookie) {
    // un nom suivi de "=", en début d'entête ou après une virgule
    for (final m in RegExp(r'(?:^|,)\s*([^=;,\s]+)=([^;]*)').allMatches(setCookie)) {
      final nom = m[1]!;
      final valeur = m[2]!.trim();
      if (valeur.isEmpty) {
        _cookies.remove(nom);
      } else {
        _cookies[nom] = valeur;
      }
    }
    _stockage?.setString(_cle, jsonEncode(_cookies));
  }

  @override
  void close() => _interne.close();
}

Le client HTTP de l'exemple 23, sans changement : il range les cookies reçus (entête [Set-Cookie]) et les renvoie (entête [Cookie]) sur le téléphone ; sur le web, il laisse faire le navigateur.

11.7.2. lib/api/client_api.dart

// =========================================================================
// lib/api/client_api.dart - TOUTES les requêtes vers l'API passent par ici
// -------------------------------------------------------------------------
// Repris de commun/api.dart des exemples, avec une nouveauté : la gestion
// centralisée de la réponse 401.
//
// Un 401 peut arriver n'importe quand : le jeton a expiré (2 heures), le
// compte a été supprimé, le serveur a redémarré avec une autre clé
// secrète... Plutôt que de traiter ce cas dans chaque écran, on le traite
// ICI, une fois. Mais ce fichier ne connaît ni le routeur ni les messages
// (il ne dépend de rien) : il appelle une fonction qu'on lui a confiée au
// démarrage (onSessionExpiree, cf. main.dart), qui oublie l'utilisateur et
// renvoie vers la page de connexion.
//
// Certaines requêtes traitent elles-mêmes leur 401 (la connexion, GET
// /api/auth/moi...) : elles sont "publiques" (cf. rdvmedecins_api.dart).
// =========================================================================

import 'dart:convert';

import 'package:http/http.dart' as http;

import '../config.dart';
import 'erreur_api.dart';

class ClientApi {
  final http.Client _client;
  // ce qu'il faut faire quand le serveur répond 401 à une requête protégée
  void Function() onSessionExpiree = () {};

  ClientApi(this._client);

  // publique : true pour les routes qui traitent elles-mêmes le 401
  Future<Object?> requete(String methode, String url, {Object? corps, bool publique = false}) async {
    final requete = http.Request(methode, Uri.parse('${Config.urlServeur}/api$url'));
    if (corps != null) {
      requete.headers['content-type'] = 'application/json';
      requete.body = jsonEncode(corps);
    }
    final http.Response reponse;
    try {
      reponse = await http.Response.fromStream(await _client.send(requete));
    } catch (_) {
      // aucune réponse : réseau coupé, serveur arrêté, mauvaise adresse
      throw const ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
    }
    if (reponse.statusCode >= 400) {
      final erreur = ErreurApi.depuisReponse(reponse);
      if (erreur.statusCode == 401 && !publique) {
        onSessionExpiree();
        // l'écran qui attendait la réponse affichera, lui aussi, CE message
        throw const ErreurApi(401, 'ERRORS.SESSION_EXPIRED');
      }
      throw erreur;
    }
    return reponse.bodyBytes.isEmpty ? null : jsonDecode(utf8.decode(reponse.bodyBytes));
  }
}

Le fichier [commun/api.dart] des exemples, avec une nouveauté : la réponse 401.

  • ligne 29 : une fonction à appeler en cas de 401, donnée au démarrage (cf. [main.dart]). Par défaut, elle ne fait rien ;
  • ligne 34 : [publique] est un paramètre nommé et facultatif (entre accolades) : [requete('GET', '/captcha', publique: true)] ;
  • lignes 35-38 : la requête, construite pièce à pièce : méthode, adresse, entête et corps JSON ;
  • lignes 40-45 : l'envoi. Une exception, ici, signifie qu'aucune réponse n'est arrivée (réseau coupé, serveur arrêté, mauvaise adresse). [final http.Response reponse;] : une variable [final] déclarée sans valeur ; [Dart] vérifie qu'elle en reçoit une, une seule fois, avant d'être lue ;
  • lignes 47-54 : une réponse d'erreur devient une [ErreurApi]. Un 401 sur une requête protégée signifie que la session a expiré : on appelle [onSessionExpiree], puis on lance quand même une erreur (l'écran qui attendait la réponse doit savoir qu'elle n'est pas venue), avec la clé [ERRORS.SESSION_EXPIRED] : si cet écran affiche l'erreur, c'est le même message ;
  • ligne 56 : une réponse sans corps (204) donne [null]. On décode les octets en UTF-8 : sans entête [charset], le paquet [http] décoderait [reponse.body] en Latin-1, et les accents seraient abîmés.

11.7.3. lib/api/erreur_api.dart

// =========================================================================
// lib/api/erreur_api.dart - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Toutes les réponses d'erreur du serveur RdvMedecins ont la même forme :
//   { "statusCode": 409, "cle": "ERRORS.LOGIN_TAKEN", "params": {...}, "champs": {...} }
// Mais une requête peut aussi échouer AUTREMENT : pas de réponse du tout
// (réseau coupé, serveur arrêté, mauvaise adresse), ou une réponse qui ne
// vient pas de notre serveur (une page d'erreur d'un proxy). On convertit
// tout cela en UNE classe, ErreurApi, qui porte la clé de traduction
// envoyée par le serveur (et, s'il y en a, ses paramètres et l'erreur de
// chaque champ).
// =========================================================================

import 'dart:convert';

import 'package:http/http.dart' as http;

// "implements Exception" : un objet qu'on peut lancer (throw) et attraper (catch)
class ErreurApi implements Exception {
  final int statusCode; // 0 : le serveur n'a pas répondu
  final String cle; // ex : 'ERRORS.LOGIN_TAKEN'
  final Map<String, String> params;
  final Map<String, String> champs;

  const ErreurApi(this.statusCode, this.cle, {this.params = const {}, this.champs = const {}});

  // une réponse d'erreur (4xx, 5xx) -> une ErreurApi
  factory ErreurApi.depuisReponse(http.Response reponse) {
    Object? corps;
    try {
      corps = jsonDecode(utf8.decode(reponse.bodyBytes));
    } on FormatException {
      corps = null; // pas du JSON : ce n'est pas notre serveur qui a répondu
    }
    // le "filtrage" (pattern matching) d'une Map : si le corps a une clé
    // "cle" de type String, elle est rangée dans la variable cle
    if (corps case {'cle': final String cle}) {
      final json = corps as Map<String, Object?>;
      return ErreurApi(reponse.statusCode, cle, params: _chaines(json['params']), champs: _chaines(json['champs']));
    }
    // pas de clé : le proxy ou le réseau ont répondu à sa place
    return ErreurApi(
      reponse.statusCode,
      reponse.statusCode >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR',
    );
  }

  // un objet JSON { "nom": "ERRORS..." } -> Map<String, String> (vide sinon)
  static Map<String, String> _chaines(Object? json) =>
      json is Map<String, Object?> ? {for (final e in json.entries) e.key: '${e.value}'} : const {};

  @override
  String toString() => '$statusCode $cle';
}

Celui de l'exemple 24 : toute requête qui échoue se termine par une [ErreurApi], qui porte la clé de traduction du message, ses paramètres, et l'erreur de chaque champ.

11.7.4. lib/api/types.dart

// =========================================================================
// lib/api/types.dart - les objets JSON échangés avec le serveur
// -------------------------------------------------------------------------
// La traduction, en classes Dart, de l'API décrite au chapitre "Le
// serveur, une boîte noire" (cf. commun/types.dart des exemples). Chaque
// classe lit son JSON par le filtrage de Dart 3 : une réponse qui n'a pas
// la forme attendue est détectée dès sa réception.
// =========================================================================

enum Role { admin, doctor, user }

Role _role(String nom) => Role.values.byName(nom.toLowerCase());

Never _inattendu(String quoi, Object? json) => throw FormatException('$quoi inattendu : $json');

// POST /api/auth/login, GET /api/auth/moi, PUT /api/mon-compte
class Utilisateur {
  final String login;
  final String nom; // nom affiché : "Marie PELISSIER"
  final Role role;

  const Utilisateur(this.login, this.nom, this.role);

  factory Utilisateur.depuisJson(Object? json) => switch (json) {
    {'login': final String login, 'nom': final String nom, 'role': final String role} => Utilisateur(
      login,
      nom,
      _role(role),
    ),
    _ => _inattendu('Utilisateur', json),
  };
}

// GET /api/captcha
class Captcha {
  final String id;
  final String svg;

  const Captcha(this.id, this.svg);

  factory Captcha.depuisJson(Object? json) => switch (json) {
    {'id': final String id, 'svg': final String svg} => Captcha(id, svg),
    _ => _inattendu('Captcha', json),
  };
}

// un titre, un prénom, un nom
class Identite {
  final String titre;
  final String prenom;
  final String nom;

  const Identite(this.titre, this.prenom, this.nom);

  factory Identite.depuisJson(Object? json) => switch (json) {
    {'titre': final String titre, 'prenom': final String prenom, 'nom': final String nom} => Identite(
      titre,
      prenom,
      nom,
    ),
    _ => _inattendu('Identité', json),
  };

  // "Mme Alice DURAND"
  String get libelle => '$titre $prenom $nom';
}

// un médecin ou un client : GET /api/medecins, GET /api/clients/:id...
class Personne extends Identite {
  final int id;
  final int version; // verrou optimiste : à renvoyer avec une modification
  final String? login; // seulement pour l'ADMIN

  const Personne(this.id, this.version, super.titre, super.prenom, super.nom, [this.login]);

  factory Personne.depuisJson(Object? json) => switch (json) {
    {
      'id': final int id,
      'version': final int version,
      'titre': final String titre,
      'prenom': final String prenom,
      'nom': final String nom,
    } =>
      Personne(id, version, titre, prenom, nom, (json as Map<String, Object?>)['login'] as String?),
    _ => _inattendu('Personne', json),
  };

  static List<Personne> liste(Object? json) => [for (final p in json as List<Object?>) Personne.depuisJson(p)];
}

// un créneau horaire
class Creneau {
  final int id;
  final int hdebut;
  final int mdebut;
  final int hfin;
  final int mfin;

  const Creneau(this.id, this.hdebut, this.mdebut, this.hfin, this.mfin);

  factory Creneau.depuisJson(Object? json) => switch (json) {
    {'id': final int id, 'hdebut': final int hd, 'mdebut': final int md, 'hfin': final int hf, 'mfin': final int mf} =>
      Creneau(id, hd, md, hf, mf),
    _ => _inattendu('Créneau', json),
  };
}

// une ligne de l'agenda, avec les DROITS de l'utilisateur connecté
class CreneauAgenda extends Creneau {
  final bool libre;
  final Identite? patient; // null : libre, ou occupé par un autre patient
  final bool moi; // le rendez-vous du patient connecté
  final bool reservable;
  final int? idRv; // renseigné si l'utilisateur peut annuler

  const CreneauAgenda(
    super.id,
    super.hdebut,
    super.mdebut,
    super.hfin,
    super.mfin,
    this.libre,
    this.patient,
    this.moi,
    this.reservable,
    this.idRv,
  );

  factory CreneauAgenda.depuisJson(Object? json) {
    final c = Creneau.depuisJson(json);
    return switch (json) {
      {
        'libre': final bool libre,
        'patient': final Object? patient,
        'moi': final bool moi,
        'reservable': final bool reservable,
        'idRv': final int? idRv,
      } =>
        CreneauAgenda(
          c.id,
          c.hdebut,
          c.mdebut,
          c.hfin,
          c.mfin,
          libre,
          patient == null ? null : Identite.depuisJson(patient),
          moi,
          reservable,
          idRv,
        ),
      _ => _inattendu('Créneau', json),
    };
  }
}

// GET /api/agenda?idMedecin=...&jour=...
class Agenda {
  final Personne medecin;
  final String jour; // "yyyy-MM-dd"
  final List<CreneauAgenda> creneaux;
  final String? info; // clé de traduction (patient qui a déjà un rendez-vous)

  const Agenda(this.medecin, this.jour, this.creneaux, this.info);

  factory Agenda.depuisJson(Object? json) => switch (json) {
    {
      'medecin': final Object medecin,
      'jour': final String jour,
      'creneaux': final List<Object?> creneaux,
      'info': final String? info,
    } =>
      Agenda(Personne.depuisJson(medecin), jour, [for (final c in creneaux) CreneauAgenda.depuisJson(c)], info),
    _ => _inattendu('Agenda', json),
  };
}

// un rendez-vous de GET /api/mon-espace
class RendezVous {
  final int id;
  final String jour;
  final Creneau creneau;
  final Personne medecin;

  const RendezVous(this.id, this.jour, this.creneau, this.medecin);

  factory RendezVous.depuisJson(Object? json) => switch (json) {
    {'id': final int id, 'jour': final String jour, 'creneau': final Object creneau, 'medecin': final Object medecin} =>
      RendezVous(id, jour, Creneau.depuisJson(creneau), Personne.depuisJson(medecin)),
    _ => _inattendu('Rendez-vous', json),
  };
}

// GET /api/mon-espace
class MonEspace {
  final Identite patient;
  final List<RendezVous> rendezVous;

  const MonEspace(this.patient, this.rendezVous);

  factory MonEspace.depuisJson(Object? json) => switch (json) {
    {'patient': final Object patient, 'rendezVous': final List<Object?> rv} => MonEspace(Identite.depuisJson(patient), [
      for (final r in rv) RendezVous.depuisJson(r),
    ]),
    _ => _inattendu('Espace', json),
  };
}

// GET /api/mon-compte
class MonCompte extends Identite {
  final String login;
  final int version;

  const MonCompte(super.titre, super.prenom, super.nom, this.login, this.version);

  factory MonCompte.depuisJson(Object? json) {
    final i = Identite.depuisJson(json);
    return switch (json) {
      {'login': final String login, 'version': final int version} => MonCompte(
        i.titre,
        i.prenom,
        i.nom,
        login,
        version,
      ),
      _ => _inattendu('Compte', json),
    };
  }
}

Les objets JSON de l'API, en classes [Dart] (cf. exemple 24) :

  • ligne 14 : une fonction qui ne se termine jamais normalement (elle lance une exception) : son type de retour est [Never]. Elle peut donc servir de dernier cas à un [switch] de n'importe quel type ;
  • lignes 24-30 : le filtrage d'une [Map] : le cas s'applique si le JSON a (au moins) ces trois clés, avec des valeurs de ces types, rangées au passage dans des variables ;
  • lignes 69-88 : un médecin ou un client : une [Identite], plus un identifiant, une version (le verrou optimiste : elle repartira avec une modification) et un login, que seul l'ADMIN reçoit (d'où le paramètre facultatif, entre crochets) ;
  • lignes 109-114 : une ligne de l'agenda, avec les DROITS de l'utilisateur connecté, calculés par le serveur : [patient] n'est donné qu'à qui a le droit de le voir, [reservable] dit s'il peut réserver, [idRv] n'est donné qu'à qui peut annuler ;
  • lignes 129-151 : on lit d'abord le créneau lui-même ([Creneau.depuisJson]), puis ses champs propres ;
  • lignes 209-213 : le compte du patient connecté : son identité, son login et sa version. [super.titre] : le paramètre est transmis tel quel au constructeur de la classe mère.

11.7.5. lib/api/rdvmedecins_api.dart

// =========================================================================
// lib/api/rdvmedecins_api.dart - une méthode par route de l'API
// -------------------------------------------------------------------------
// La "couche d'accès aux données" du client (un "repository", dans le
// vocabulaire du guide d'architecture de Flutter) : les écrans appellent
// ces méthodes, typées, sans rien savoir des URL, du paquet http ni du
// JSON. Si l'API change, seul ce fichier change.
// Chaque méthode renvoie un Future ; en cas d'échec, il se termine par une
// ErreurApi (cf. client_api.dart).
// Les données envoyées (formulaires) sont de simples Map<String, Object?> :
// leurs clés sont les noms des champs attendus par le serveur - et ceux
// des erreurs qu'il renvoie (ErreurApi.champs).
// =========================================================================

import 'client_api.dart';
import 'types.dart';

// /api/medecins ou /api/clients : les deux se gèrent de la même façon
enum TypePersonnes { medecins, clients }

typedef Donnees = Map<String, Object?>;

class RdvMedecinsApi {
  final ClientApi _client;

  RdvMedecinsApi(this._client);

  Future<Object?> _get(String url, {bool publique = false}) => _client.requete('GET', url, publique: publique);
  Future<Object?> _post(String url, Object corps, {bool publique = false}) =>
      _client.requete('POST', url, corps: corps, publique: publique);

  // ------------------------------------------------ connexion (routes publiques)
  Future<Captcha> captcha() async => Captcha.depuisJson(await _get('/captcha', publique: true));

  Future<Utilisateur> moi() async => Utilisateur.depuisJson(await _get('/auth/moi', publique: true));

  Future<Utilisateur> connecter(String login, String password, String captchaId, String captcha) async =>
      Utilisateur.depuisJson(
        await _post('/auth/login', {
          'login': login,
          'password': password,
          'captchaId': captchaId,
          'captcha': captcha,
        }, publique: true),
      );

  Future<void> deconnecter() => _post('/auth/logout', {}, publique: true);

  // ------------------------------------------------ création de compte (publique)
  Future<void> accesInscription(String captchaId, String captcha) =>
      _post('/inscription/acces', {'captchaId': captchaId, 'captcha': captcha}, publique: true);

  Future<bool> etatAccesInscription() async => switch (await _get('/inscription/acces', publique: true)) {
    {'valide': true} => true,
    _ => false,
  };

  Future<String> inscrire(Donnees donnees) async => switch (await _post('/inscription', donnees, publique: true)) {
    {'login': final String login} => login,
    final autre => throw FormatException('Réponse inattendue : $autre'),
  };

  // ------------------------------------------------ agenda et rendez-vous
  // idMedecin null : pour un médecin connecté, SON agenda
  Future<Agenda> agenda(int? idMedecin, String jour) async {
    final parametres = Uri(queryParameters: {'jour': jour, 'idMedecin': ?idMedecin?.toString()}).query;
    return Agenda.depuisJson(await _get('/agenda?$parametres'));
  }

  Future<void> reserver(String jour, int idCreneau, int? idClient) =>
      _post('/rendez-vous', {'jour': jour, 'idCreneau': idCreneau, 'idClient': ?idClient});

  Future<void> annulerRv(int idRv) => _client.requete('DELETE', '/rendez-vous/$idRv');

  // ------------------------------------------------ médecins et clients
  Future<List<Personne>> lister(TypePersonnes type) async => Personne.liste(await _get('/${type.name}'));

  Future<Personne> lire(TypePersonnes type, int id) async => Personne.depuisJson(await _get('/${type.name}/$id'));

  Future<Personne> ajouter(TypePersonnes type, Donnees donnees) async =>
      Personne.depuisJson(await _post('/${type.name}', donnees));

  Future<Personne> modifier(TypePersonnes type, int id, Donnees donnees) async =>
      Personne.depuisJson(await _client.requete('PUT', '/${type.name}/$id', corps: donnees));

  Future<void> supprimer(TypePersonnes type, int id) => _client.requete('DELETE', '/${type.name}/$id');

  // ------------------------------------------------ l'espace du patient
  Future<MonEspace> monEspace() async => MonEspace.depuisJson(await _get('/mon-espace'));

  Future<MonCompte> monCompte() async => MonCompte.depuisJson(await _get('/mon-compte'));

  Future<Utilisateur> modifierMonCompte(Donnees donnees) async =>
      Utilisateur.depuisJson(await _client.requete('PUT', '/mon-compte', corps: donnees));

  Future<void> supprimerMonCompte(String motDePasseActuel) =>
      _client.requete('DELETE', '/mon-compte', corps: {'motDePasseActuel': motDePasseActuel});
}

Une méthode par route de l'API : c'est la classe [RdvMedecins] de l'exemple 24, complétée.

  • ligne 19 : [/api/medecins] et [/api/clients] se gèrent de la même façon : une énumération, dont le nom de chaque valeur ([TypePersonnes.medecins.name], « medecins ») est celui de la route ;
  • ligne 21 : un alias de type : un nom plus parlant pour un type existant ;
  • lignes 33-47 : les routes de la connexion, publiques : un 401 y a son sens propre (« personne n'est connecté », « mauvais mot de passe ») et ne signifie pas que la session a expiré ;
  • ligne 53 : le laissez-passer de la création de compte est-il valide ? Un [switch] expression sur la réponse : [true] seulement pour [{"valide": true}] ;
  • lignes 65-67 : les paramètres de l'adresse, construits par [Uri] (qui les encode). ['idMedecin': ?idMedecin?.toString()] : un élément de [Map] facultatif ([Dart] 3.8) : la clé n'est ajoutée que si la valeur n'est pas [null]. Un médecin connecté demande l'agenda sans [idMedecin] : le serveur lui renvoie le sien ;
  • ligne 71 : de même, [idClient] n'est envoyé que s'il est connu (un patient réserve pour lui-même) ;
  • lignes 76-86 : les cinq opérations sur les médecins ou les clients : [/api/${type.name}] ;
  • lignes 89-97 : l'espace du patient. La suppression du compte envoie un corps avec une requête [DELETE] (le mot de passe actuel) : c'est permis par HTTP, et le paquet [http] le permet aussi, par une [http.Request] construite à la main (cf. [client_api.dart]).

11.8. Les langues

11.8.1. lib/i18n/traductions.dart

// =========================================================================
// lib/i18n/traductions.dart - les dictionnaires, la langue courante
// -------------------------------------------------------------------------
// Repris de l'exemple 21. Les dictionnaires (assets/i18n/fr.json, en.json)
// sont ceux des clients Vue.js, Angular et React : mêmes clés, mêmes
// conventions ({{param}}, pluriels _one / _other). Le serveur ne renvoie
// que des CLÉS ("ERRORS.LOGIN_TAKEN") : c'est ici qu'elles deviennent du
// texte, dans la langue choisie par l'utilisateur.
//   - t('CLE', {'param': valeur}) : le texte de la langue courante ;
//   - changerLangue('en')         : charge l'autre dictionnaire, prévient
//                                   les écrans (ChangeNotifier), mémorise
//                                   le choix sur l'appareil.
// =========================================================================

import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';

const langues = ['fr', 'en'];

class Traductions extends ChangeNotifier {
  final SharedPreferences? _stockage;
  String _langue = 'fr';
  Map<String, Object?> _dictionnaire = {};

  Traductions([this._stockage]);

  String get langue => _langue;

  // la langue de départ : celle mémorisée sur l'appareil, sinon celle du
  // téléphone (si elle est connue), sinon le français
  Future<void> demarrer() async {
    final memorisee = _stockage?.getString('rdvmedecins.langue');
    final appareil = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
    await changerLangue(memorisee ?? (langues.contains(appareil) ? appareil : 'fr'));
  }

  Future<void> changerLangue(String langue) async {
    // rootBundle : les fichiers livrés avec l'application (section assets du pubspec)
    final json = await rootBundle.loadString('assets/i18n/$langue.json');
    _dictionnaire = jsonDecode(json) as Map<String, Object?>;
    _langue = langue;
    await _stockage?.setString('rdvmedecins.langue', langue);
    notifyListeners();
  }

  // "ACCUEIL.BONJOUR" -> dictionnaire['ACCUEIL']['BONJOUR'] ; null si absente
  String? _chercher(String cle) {
    Object? noeud = _dictionnaire;
    for (final partie in cle.split('.')) {
      noeud = noeud is Map<String, Object?> ? noeud[partie] : null;
    }
    return noeud is String ? noeud : null;
  }

  String t(String cle, [Map<String, Object?> params = const {}]) {
    String? texte;
    final count = params['count'];
    if (count is int) {
      // Intl.pluralLogic : la forme du pluriel, selon les règles de la langue
      texte = Intl.pluralLogic(
        count,
        locale: _langue,
        zero: _chercher('${cle}_zero'),
        one: _chercher('${cle}_one'),
        other: _chercher('${cle}_other'),
      );
    }
    texte ??= _chercher(cle);
    // une clé inconnue s'affiche telle quelle : on voit tout de suite ce qui manque
    if (texte == null) return cle;
    // {{nom}} -> la valeur du paramètre nom
    return texte.replaceAllMapped(RegExp(r'\{\{(\w+)\}\}'), (m) => '${params[m[1]] ?? m[0]}');
  }
}

La classe [Traductions] de l'exemple 21, simplifiée : les dictionnaires ont toujours le même nom ([assets/i18n/fr.json]).

  • lignes 35-38 : la langue de départ : celle choisie lors d'une utilisation précédente, sinon celle du téléphone si l'application la connaît, sinon le français. [platformDispatcher.locale] : la langue du système ;
  • lignes 59-76 : la traduction d'une clé, avec les pluriels ([_zero], [_one], [_other]) et les paramètres ([{{nom}}]), selon les conventions d'[i18next], celles des dictionnaires des autres clients.

11.8.2. Les dictionnaires

Les dictionnaires sont ceux du client [React], complétés de quelques clés propres à l'application mobile (le menu « Mon compte », « Choisir le jour »...). Le début du dictionnaire français :

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_AGENDA": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_SPACE": "Mon espace",
    "LOADING": "Chargement...",
    "LANGUAGE": "Langue",
    "MENU_ACCOUNT": "Mon compte",
    "CONNECTED_AS": "Connecté : {{nom}}",
    "RETRY": "Réessayer"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "SUBMIT": "Se connecter",
    "ERROR": "Login ou mot de passe incorrect.",
    "DEMO_ACCOUNTS": "Comptes de démonstration (mot de passe = login) :",
    "DEMO_ADMIN": "rôle ADMIN : rendez-vous + gestion des médecins et des clients",
    "DEMO_DOCTOR": "rôle DOCTOR : prise et annulation de rendez-vous",
    "DEMO_USER": "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte",
    "CREATE_ACCOUNT": "Créer un compte client",
    "CREATE_ACCOUNT_HELP": "Pour créer un compte, recopiez d'abord les caractères de l'image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Médecin",
    "CHOOSE_DOCTOR": "-- choisir un médecin --",
    "DAY_LABEL": "Jour",
    "VIEW_AGENDA": "Voir l'agenda",
    "CHOOSE_DAY": "Choisir le jour"
  },

Un fichier par langue, les mêmes clés dans chacun ; un groupe de clés par écran ; les clés [ERRORS.*] sont celles que renvoie le serveur.

11.9. L'état partagé

Trois [ChangeNotifier], fournis à toute l'application par [main.dart].

11.9.1. lib/etat/session.dart

// =========================================================================
// lib/etat/session.dart - QUI est connecté ?
// -------------------------------------------------------------------------
// L'utilisateur connecté (ou null), dans un ChangeNotifier fourni à toute
// l'application (provider, cf. exemple 20) : lisible par les écrans ET par
// le routeur, qui l'écoute pour réévaluer ses gardes (refreshListenable).
//
// Le jeton JWT lui-même n'est PAS ici : il est dans un cookie (géré par le
// navigateur sur le web, par ClientAvecCookies sur le téléphone). La
// session ne garde que la DESCRIPTION de l'utilisateur ({ login, nom, role }).
//
// Au démarrage de l'application, la session est vide : verifier() demande
// au serveur, une seule fois, qui est connecté (GET /api/auth/moi).
// =========================================================================

import 'package:flutter/foundation.dart';

import '../api/rdvmedecins_api.dart';
import '../api/types.dart';

class Session extends ChangeNotifier {
  final RdvMedecinsApi _api;
  Utilisateur? _utilisateur;
  // vrai après une déconnexion VOULUE (bouton, suppression du compte) ; faux
  // après une session expirée. Le routeur en tient compte (cf. routeur.dart)
  bool _departVolontaire = false;
  // la question "qui est connecté ?" a-t-elle déjà été posée au serveur ?
  Future<void>? _verification;

  Session(this._api);

  Utilisateur? get utilisateur => _utilisateur;
  bool get departVolontaire => _departVolontaire;

  // ne questionne le serveur que la première fois (le Future est gardé)
  Future<void> verifier() => _verification ??= _api.moi().then(
    (u) => _changer(u),
    onError: (Object _) => _changer(null), // 401 : personne ; ou serveur injoignable
  );

  Future<void> connecter(String login, String password, String captchaId, String captcha) async {
    _changer(await _api.connecter(login, password, captchaId, captcha));
  }

  // l'utilisateur a changé de nom ou de login (PUT /api/mon-compte)
  void mettreAJour(Utilisateur u) => _changer(u);

  Future<void> deconnecter() async {
    try {
      await _api.deconnecter();
    } finally {
      oublier(volontaire: true);
    }
  }

  // sans rien demander au serveur : session expirée, compte supprimé
  void oublier({bool volontaire = false}) => _changer(null, volontaire: volontaire);

  // notifyListeners : le routeur (refreshListenable) réévalue ses gardes, et
  // quitte de lui-même un écran que le nouvel utilisateur n'a pas le droit de voir
  void _changer(Utilisateur? u, {bool volontaire = false}) {
    _utilisateur = u;
    _departVolontaire = volontaire;
    notifyListeners();
  }
}

// ce qu'on déduit de l'utilisateur : des extensions (des "getters" ajoutés à Utilisateur?)
extension Droits on Utilisateur? {
  bool get estAdmin => this?.role == Role.admin;
  bool get estPatient => this?.role == Role.user;
  // ADMIN et DOCTOR : prennent et annulent les rendez-vous de n'importe quel patient
  bool get estGestionnaire => this?.role == Role.admin || this?.role == Role.doctor;
  // la page d'accueil de chaque rôle, juste après la connexion
  String get pageAccueil => estPatient ? '/mon-espace' : '/agenda';
}

Qui est connecté ? La session garde la DESCRIPTION de l'utilisateur (login, nom, rôle), et non le jeton, qui reste dans son cookie.

  • lignes 36-38 : la question « qui est connecté ? », posée au serveur ([GET /api/auth/moi]) UNE seule fois, au démarrage : [??=] n'affecte le [Future] que si [_verification] est [null]. Les appels suivants renvoient le même [Future], déjà terminé : le routeur peut appeler [verifier()] avant chaque navigation sans rien coûter. Au démarrage, si le cookie mémorisé contient un jeton encore valide, l'utilisateur est toujours connecté ;
  • lignes 41-42 : la connexion : si le serveur refuse, [_api.connecter] lance une [ErreurApi] et [_changer] n'est pas appelée ;
  • lignes 48-52 : la déconnexion : on oublie l'utilisateur même si le serveur ne répond pas ([finally]) ;
  • lignes 61-64 : tout changement passe par ici, et prévient les auditeurs : les écrans qui affichent l'utilisateur (le gabarit), le cache (qui se vide) et le routeur (qui réévalue ses gardes). [_departVolontaire] distingue une déconnexion voulue d'une session expirée (cf. [routeur.dart]) ;
  • lignes 69-75 : une extension ajoute des getters à un type existant, ici [Utilisateur?] : [session.utilisateur.estAdmin] est [false] si personne n'est connecté, sans test de [null] préalable ([this?.role]).

11.9.2. lib/etat/messages.dart

// =========================================================================
// lib/etat/messages.dart - les messages affichés à l'utilisateur
// -------------------------------------------------------------------------
// Même présentation que les autres clients de RdvMedecins :
//   - un SUCCÈS s'affiche en HAUT de l'écran (bandeau vert) ;
//   - une ERREUR en BAS (bandeau rose), toujours visible.
// Un message est une CLÉ de traduction (+ ses paramètres) : il se retraduit
// donc tout seul si l'utilisateur change de langue.
//
// Les messages "flash" : une action qui se termine par un changement
// d'écran (réservation, ajout d'un médecin...) dépose son message avec
// flash() ; il s'affichera sur l'écran SUIVANT, puis disparaîtra à la
// navigation d'après. C'est le routeur qui appelle nouvellePage() à la
// fin de chaque navigation (cf. routeur.dart).
// =========================================================================

import 'package:flutter/foundation.dart';

import '../api/erreur_api.dart';

typedef Message = ({String cle, Map<String, String> params});

class Messages extends ChangeNotifier {
  Message? succes;
  Message? erreur;
  // les messages à afficher sur l'écran suivant
  Message? _succesEnAttente;
  Message? _erreurEnAttente;

  void afficherSucces(String cle, [Map<String, String> params = const {}]) {
    succes = (cle: cle, params: params);
    erreur = null;
    notifyListeners();
  }

  void afficherErreur(String cle, [Map<String, String> params = const {}]) {
    erreur = (cle: cle, params: params);
    succes = null;
    notifyListeners();
  }

  // une ErreurApi -> le message de l'écran ; toute autre erreur est une
  // anomalie du client, qu'on laisse remonter
  void afficherErreurApi(Object e) {
    if (e is! ErreurApi) throw e;
    afficherErreur(e.cle, e.params);
  }

  void effacer() {
    succes = null;
    erreur = null;
    notifyListeners();
  }

  // pour l'écran SUIVANT
  void flash({bool succes = true, required String cle, Map<String, String> params = const {}}) {
    if (succes) {
      _succesEnAttente = (cle: cle, params: params);
    } else {
      _erreurEnAttente = (cle: cle, params: params);
    }
  }

  // un nouvel écran : les messages du précédent disparaissent, les messages
  // flash apparaissent
  void nouvellePage() {
    succes = _succesEnAttente;
    erreur = _erreurEnAttente;
    _succesEnAttente = null;
    _erreurEnAttente = null;
    notifyListeners();
  }
}

Les messages de l'application : un succès en haut de l'écran, une erreur en bas (cf. [gabarit.dart]).

  • ligne 21 : un message est une CLÉ de traduction et ses paramètres (un record). Il n'est traduit qu'à l'affichage : changer de langue retraduit les messages affichés ;
  • lignes 44-46 : une [ErreurApi] devient le message d'erreur de l'écran. Toute autre exception est une anomalie du client : on la relance, pour qu'elle apparaisse dans la console pendant le développement, plutôt que de la cacher ;
  • lignes 56-60 : un message pour l'écran SUIVANT : une action qui se termine par un changement d'écran (une réservation, un ajout...) dépose son message ici ;
  • lignes 66-71 : appelée à chaque changement d'adresse (cf. [main.dart]) : les messages de l'écran précédent disparaissent, les messages flash apparaissent.

11.9.3. lib/etat/cache.dart

// =========================================================================
// lib/etat/cache.dart - les données déjà lues sur le serveur
// -------------------------------------------------------------------------
// La liste des médecins ne change presque jamais : inutile de la relire à
// chaque visite de l'agenda. Le cache garde le Future de chaque lecture,
// sous une CLÉ ('medecins', 'agenda/1/2026-10-05'...) :
//
//     cache.lire('medecins', () => api.lister(TypePersonnes.medecins))
//
// renvoie le Future déjà gardé, ou lance la lecture et la garde.
// Après une modification, on INVALIDE les clés concernées (elles seront
// relues à la prochaine demande) : cache.invalider('agenda').
// Une lecture en échec n'est pas gardée : la suivante réessaiera.
// Le cache est un ChangeNotifier : invalider() prévient ses auditeurs. Un
// écran encore vivant (même caché sous un autre dans la pile, comme
// l'agenda sous sa fenêtre de réservation) peut ainsi relire ses données.
// C'est, en quelques lignes, ce que fait la bibliothèque TanStack Query du
// client React.
// =========================================================================

import 'package:flutter/foundation.dart';

class Cache extends ChangeNotifier {
  final Map<String, Future<Object?>> _futurs = {};

  Future<T> lire<T>(String cle, Future<T> Function() charger) {
    final futur = _futurs.putIfAbsent(cle, () {
      final f = charger();
      // en cas d'échec, on oublie ce Future (la prochaine demande réessaiera)
      f.then((_) {}, onError: (Object _) => _futurs.remove(cle));
      return f;
    });
    return futur.then((v) => v as T);
  }

  // oublie toutes les clés qui commencent par prefixe, et prévient les écrans
  void invalider(String prefixe) {
    _futurs.removeWhere((cle, _) => cle.startsWith(prefixe));
    notifyListeners();
  }

  // changement d'utilisateur : on oublie tout, SANS prévenir (les écrans de
  // l'utilisateur précédent vont disparaître, ils ne doivent rien relire)
  void vider() => _futurs.clear();
}

Les données déjà lues sur le serveur : l'équivalent, en quelques lignes, de [TanStack Query] dans le client [React].

  • ligne 24 : le cache garde des [Future], et non des valeurs : deux écrans qui demandent la même donnée pendant qu'elle est en route reçoivent le MÊME [Future], et une seule requête part ;
  • lignes 26-33 : une méthode générique : [lire<List<Personne>>(...)] renvoie un [Future<List<Personne>>] (le type [T] est le plus souvent déduit de la fonction [charger]). [putIfAbsent] : la valeur de la clé, ou, si la clé est absente, le résultat de la fonction, rangé sous cette clé. Une lecture en échec est oubliée : la prochaine demande réessaiera ;
  • lignes 37-39 : après une modification, on oublie les clés concernées, et l'on prévient les écrans. L'écran de l'agenda l'écoute : après une réservation, il se relit, même caché sous la fenêtre de réservation ;
  • ligne 44 : au changement d'utilisateur, on oublie tout, sans prévenir personne : les écrans de l'utilisateur précédent vont disparaître, ils n'ont rien à relire.

11.10. La mise en forme

11.10.1. lib/formatage.dart

// =========================================================================
// lib/formatage.dart - l'affichage des heures et des jours
// -------------------------------------------------------------------------
// Le serveur envoie des données BRUTES (hdebut: 8, mdebut: 0 ; jour:
// "2026-10-05") ; la mise en forme, qui dépend de la langue, est l'affaire
// du client (cf. exemple 09).
// =========================================================================

import 'package:intl/intl.dart';

import 'api/types.dart';

String _deux(int n) => n.toString().padLeft(2, '0');

// "8:05" plutôt que "8:5"
String heure(int h, int m) => '$h:${_deux(m)}';

// "8:00 - 8:20"
String horaire(Creneau c) => '${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}';

// une date -> "yyyy-MM-dd" (le format de l'API)
String versJour(DateTime d) => '${d.year}-${_deux(d.month)}-${_deux(d.day)}';

String aujourdhui() => versJour(DateTime.now());

// vrai si jour est une date qui existe, au format yyyy-MM-dd
bool estUneDateValide(String jour) {
  if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(jour)) return false;
  final d = DateTime.tryParse(jour);
  return d != null && versJour(d) == jour; // 2026-02-30 -> 2026-03-02 : refusé
}

// "lundi 5 octobre 2026" / "Monday, October 5, 2026"
String jourLong(String jour, String langue) => DateFormat.yMMMMEEEEd(langue).format(DateTime.parse(jour));

La mise en forme des données brutes du serveur (cf. exemple 09) :

  • ligne 22 : un [DateTime] au format de l'API (« 2026-10-05 ») ;
  • lignes 27-30 : une date de l'adresse est-elle valide ? [DateTime.tryParse] accepte « 2026-02-30 » (le 2 mars) : on vérifie qu'on retrouve la même chaîne ;
  • ligne 34 : « lundi 5 octobre 2026 » ou « Monday, October 5, 2026 », selon la langue ([DateFormat.yMMMMEEEEd] de [intl]).

11.11. Le routeur

11.11.1. lib/routeur.dart

// =========================================================================
// lib/routeur.dart - les écrans de l'application et leurs droits d'accès
// -------------------------------------------------------------------------
// Les MÊMES adresses que les autres clients (/login, /agenda, /medecins,
// /mon-espace...). Sur le web, elles s'affichent dans la barre d'adresse ;
// sur le téléphone, elles restent invisibles, mais organisent tout autant
// l'application (et la pile des écrans du bouton Retour).
//
// Tous les écrans sont dans une ShellRoute, dont le widget, Gabarit, les
// affiche entre la barre du haut et la zone d'erreur.
//
// Les GARDES : la fonction redirect du GoRouter est appelée avant CHAQUE
// navigation. Elle peut être asynchrone : la première fois (démarrage de
// l'application, F5 sur le web), elle attend la réponse du serveur à la
// question "qui est connecté ?" (session.verifier()). Puis elle vérifie les
// droits de l'écran demandé (droitsDe), et redirige si besoin.
// RAPPEL : ces contrôles ne sont qu'un CONFORT (ne pas afficher un écran
// inutile). La protection des données est assurée par le serveur, qui
// revérifie le jeton et le rôle à CHAQUE requête.
// =========================================================================

import 'package:go_router/go_router.dart';

import 'api/rdvmedecins_api.dart';
import 'api/types.dart';
import 'etat/messages.dart';
import 'etat/session.dart';
import 'pages/agenda.dart';
import 'pages/connexion.dart';
import 'pages/espace_patient.dart';
import 'pages/inscription.dart';
import 'pages/mon_compte.dart';
import 'pages/page_erreur.dart';
import 'pages/personne_formulaire.dart';
import 'pages/personnes.dart';
import 'widgets/gabarit.dart';

// les droits d'accès d'un écran : public (sans connexion), ou réservé à
// certains rôles (null : tous les rôles connectés)
typedef Droits = ({bool public, Set<Role>? roles});

Droits droitsDe(String chemin) => switch (chemin) {
  '/login' || '/inscription' => (public: true, roles: null),
  _ when chemin.startsWith('/medecins') || chemin.startsWith('/clients') => (public: false, roles: {Role.admin}),
  '/mon-espace' || '/mon-compte' => (public: false, roles: {Role.user}),
  '/agenda' || '/interdit' || '/' => (public: false, roles: null),
  _ => (public: true, roles: null), // adresse inconnue : la page 404, pour tous
};

// seulement un chemin INTERNE ("/medecins") : jamais "//autre-site.com"
String? cheminInterne(String? r) => r != null && r.startsWith('/') && !r.startsWith('//') ? r : null;

// "3" -> 3 ; "", "abc", "-2", absent (null) -> null
int? entierPositif(String? valeur) {
  final n = int.tryParse(valeur ?? '');
  return n != null && n > 0 ? n : null;
}

GoRouter creerRouteur(Session session, Messages messages, RdvMedecinsApi api) {
  // les trois écrans de gestion des médecins OU des clients (ADMIN)
  List<GoRoute> routesPersonnes(TypePersonnes type) => [
    GoRoute(
      path: '/${type.name}',
      builder: (_, _) => PagePersonnes(type: type),
    ),
    GoRoute(
      path: '/${type.name}/ajouter',
      builder: (_, _) => PagePersonneFormulaire(type: type),
    ),
    GoRoute(
      path: '/${type.name}/:id/modifier',
      builder: (_, etat) {
        final id = entierPositif(etat.pathParameters['id']);
        // /medecins/abc/modifier : cette adresse n'existe pas
        return id == null ? const PageErreur(code: 404) : PagePersonneFormulaire(type: type, id: id);
      },
    ),
  ];

  return GoRouter(
    initialLocation: '/login',
    // la session change (connexion, déconnexion, expiration) : les gardes
    // sont réévalués pour l'écran courant
    refreshListenable: session,
    redirect: (context, etat) async {
      await session.verifier();
      final utilisateur = session.utilisateur;
      final chemin = etat.uri.path;
      final droits = droitsDe(chemin);
      if (droits.public) {
        // connecté : l'écran de connexion n'a plus de sens. C'est ICI qu'on le
        // quitte, juste après la connexion : la session a changé, le routeur
        // (qui l'écoute) réévalue ses gardes. On va à l'écran demandé avant
        // la connexion (?redirect=...), sinon à l'accueil du rôle
        if (chemin == '/login' && utilisateur != null) {
          return cheminInterne(etat.uri.queryParameters['redirect']) ?? utilisateur.pageAccueil;
        }
        return null;
      }
      if (utilisateur == null) {
        // déconnexion voulue : l'écran de connexion, tout simplement
        if (session.departVolontaire) return '/login';
        // session expirée, adresse ouverte sans être connecté : on retient
        // l'écran demandé, pour y revenir après la connexion
        return Uri(path: '/login', queryParameters: {'redirect': etat.uri.toString()}).toString();
      }
      if (chemin == '/') return utilisateur.pageAccueil;
      if (droits.roles != null && !droits.roles!.contains(utilisateur.role)) return '/interdit';
      return null;
    },
    routes: [
      ShellRoute(
        builder: (context, etat, child) => Gabarit(child: child),
        routes: [
          GoRoute(path: '/', builder: (_, _) => const PageErreur(code: 404)), // (redirigé plus haut)
          GoRoute(
            path: '/login',
            builder: (_, etat) {
              // le login de l'adresse : 30 caractères au plus
              final login = etat.uri.queryParameters['login'] ?? '';
              return PageConnexion(login: login.length > 30 ? login.substring(0, 30) : login);
            },
          ),
          GoRoute(
            path: '/agenda',
            builder: (_, etat) => PageAgenda(
              idMedecin: entierPositif(etat.uri.queryParameters['idMedecin']),
              jour: etat.uri.queryParameters['jour'],
              reserver: entierPositif(etat.uri.queryParameters['reserver']),
            ),
          ),
          ...routesPersonnes(TypePersonnes.medecins),
          ...routesPersonnes(TypePersonnes.clients),
          GoRoute(
            path: '/inscription',
            // un garde PROPRE à cette route : sans laissez-passer (captcha
            // résolu sur l'écran de connexion), retour à l'écran de connexion
            redirect: (context, etat) async {
              final valide = await api.etatAccesInscription().catchError((_) => false);
              if (valide) return null;
              messages.flash(succes: false, cle: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
              return '/login';
            },
            builder: (_, _) => const PageInscription(),
          ),
          GoRoute(path: '/mon-espace', builder: (_, _) => const PageEspacePatient()),
          GoRoute(path: '/mon-compte', builder: (_, _) => const PageMonCompte()),
          GoRoute(path: '/interdit', builder: (_, _) => const PageErreur(code: 403)),
        ],
      ),
    ],
    // une adresse qu'aucune route ne décrit : la page 404, dans le gabarit
    errorBuilder: (context, etat) => const Gabarit(child: PageErreur(code: 404)),
  );
}

Les écrans de l'application, leurs adresses et leurs droits d'accès (cf. exemples 17 et 18).

  • ligne 40 : les droits d'un écran : public, ou réservé à certains rôles ([null] : tous les rôles) ;
  • lignes 42-47 : les droits de chaque adresse, en UN seul endroit : un [switch] expression sur des chaînes. ['/login' || '/inscription'] : un motif « ou » ; [_ when ...] : un motif « n'importe quoi », avec une condition (une garde) ;
  • ligne 51 : l'adresse [?redirect=] n'est suivie que si c'est un chemin INTERNE : sur le web, une adresse [/login?redirect=//site-pirate.com] envoyée par courriel ne doit pas emmener l'utilisateur ailleurs après sa connexion ;
  • lignes 54-56 : les paramètres d'une adresse sont des chaînes, écrites par n'importe qui (sur le web) : « abc » ou « -2 » deviennent [null] ;
  • lignes 61-78 : une fonction locale, qui fabrique les trois routes des médecins OU des clients : [/medecins], [/medecins/ajouter], [/medecins/:id/modifier]. [:id] : un paramètre de chemin, lu par [etat.pathParameters['id']] ;
  • ligne 81 : l'écran de départ, sur le téléphone (sur le web, c'est l'adresse tapée) ;
  • ligne 84 : le routeur écoute la session : à chaque changement (connexion, déconnexion, expiration), il réévalue ses gardes pour l'écran courant, et change d'écran si besoin ;
  • lignes 85-109 : LE garde, appelé avant chaque navigation :
    • ligne 86 : la première fois, on attend la réponse du serveur à la question « qui est connecté ? » ; ensuite, cet appel ne coûte rien ;
    • lignes 90-98 : un écran public est permis à tous ; mais un utilisateur connecté n'a rien à faire sur l'écran de connexion : il va à l'écran demandé avant sa connexion, ou à l'accueil de son rôle. C'est ainsi que l'application quitte l'écran de connexion après une connexion réussie : l'écran de connexion n'a pas à naviguer lui-même ;
    • lignes 100-105 : personne n'est connecté : direction l'écran de connexion. Après une session expirée, ou si l'on a ouvert directement une adresse protégée (sur le web), on retient l'écran demandé ([?redirect=]), pour y revenir après la connexion ; après une déconnexion voulue, non ;
    • lignes 107-108 : [/] mène à l'accueil du rôle ; un rôle insuffisant, à la page 403 ;
  • lignes 112-113 : tous les écrans s'affichent dans le gabarit (la barre du haut, le menu, les messages) ;
  • lignes 117-121 : [/login?login=jbernard] (après la création d'un compte) pré-remplit le champ login ;
  • lignes 125-129 : les trois paramètres de l'agenda, lus dans l'adresse ; tout l'état de l'écran y est (cf. [agenda.dart]) ;
  • lignes 135-142 : un garde PROPRE à une route : la création de compte exige un laissez-passer, que le serveur délivre quand on a recopié le captcha ([POST /api/inscription/acces], cf. écran de connexion). Sans lui, retour à l'écran de connexion, avec un message ;
  • ligne 153 : une adresse qu'aucune route ne décrit : la page 404, dans le gabarit.

Ces gardes ne sont qu'un CONFORT : elles évitent d'afficher un écran inutile. La protection des données est assurée par le serveur, qui revérifie le jeton et le rôle à chaque requête.

11.12. Les widgets

11.12.1. lib/widgets/gabarit.dart

// =========================================================================
// lib/widgets/gabarit.dart - le GABARIT commun à tous les écrans
// -------------------------------------------------------------------------
// Le widget de la ShellRoute du routeur (cf. routeur.dart) :
//   - la barre du haut : titre, langue (FR / EN), déconnexion ;
//   - le menu latéral ("tiroir", Drawer), ouvert par le bouton ☰ : les
//     écrans permis au rôle de l'utilisateur. "Médecins" et "Clients"
//     n'apparaissent que pour l'ADMIN : un simple confort d'affichage, la
//     vraie protection étant celle du serveur ;
//   - un message de succès en HAUT ;
//   - l'écran courant (child) ;
//   - la zone d'erreur en BAS.
// Sur un grand écran (web, tablette), le menu est affiché en permanence
// à gauche : un NavigationRail à la place du tiroir.
// =========================================================================

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

import '../etat/messages.dart';
import '../etat/session.dart';
import '../i18n/traductions.dart';

// une entrée du menu : chemin, clé du libellé, icône
typedef EntreeMenu = ({String chemin, String cle, IconData icone});

List<EntreeMenu> entreesMenu(Session session) {
  final u = session.utilisateur;
  if (u == null) return const [];
  return [
    if (u.estPatient) (chemin: '/mon-espace', cle: 'APP.MENU_MY_SPACE', icone: Icons.home),
    (chemin: '/agenda', cle: 'APP.MENU_AGENDA', icone: Icons.calendar_month),
    if (u.estAdmin) ...[
      (chemin: '/medecins', cle: 'APP.MENU_DOCTORS', icone: Icons.medical_services),
      (chemin: '/clients', cle: 'APP.MENU_PATIENTS', icone: Icons.people),
    ],
    if (u.estPatient) (chemin: '/mon-compte', cle: 'APP.MENU_ACCOUNT', icone: Icons.manage_accounts),
  ];
}

class Gabarit extends StatelessWidget {
  final Widget child;

  const Gabarit({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    final traductions = context.watch<Traductions>();
    final t = traductions.t;
    final session = context.watch<Session>();
    final u = session.utilisateur;
    final entrees = entreesMenu(session);
    // l'adresse courante, telle que la connaît le routeur
    final chemin = GoRouter.of(context).routerDelegate.currentConfiguration.uri.path;
    final actif = entrees.indexWhere((e) => chemin.startsWith(e.chemin));
    // MediaQuery : les dimensions de l'écran ; au-delà de 800 points, un grand écran
    final grandEcran = MediaQuery.sizeOf(context).width >= 800;

    // la session se vide ; le routeur, qui l'écoute, affiche l'écran de connexion
    Future<void> seDeconnecter() => session.deconnecter().catchError((_) {}); // même si le serveur ne répond pas

    final contenu = Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        const _MessageSucces(),
        Expanded(child: child),
        const _ZoneErreur(),
      ],
    );

    return Scaffold(
      appBar: AppBar(
        title: Text(t('APP.TITLE')),
        backgroundColor: Theme.of(context).colorScheme.primary,
        foregroundColor: Theme.of(context).colorScheme.onPrimary,
        actions: [
          // FR / EN : aucune requête au serveur, l'écran se retraduit sur place
          PopupMenuButton<String>(
            tooltip: t('APP.LANGUAGE'),
            // le texte, de la couleur des icônes de la barre (onPrimary)
            icon: Text(
              traductions.langue.toUpperCase(),
              style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimary),
            ),
            onSelected: traductions.changerLangue,
            itemBuilder: (_) => [for (final l in langues) PopupMenuItem(value: l, child: Text(l.toUpperCase()))],
          ),
          if (u != null) IconButton(tooltip: t('APP.LOGOUT'), icon: const Icon(Icons.logout), onPressed: seDeconnecter),
        ],
      ),
      // le tiroir : seulement connecté, et seulement sur un petit écran
      drawer: u == null || grandEcran
          ? null
          : NavigationDrawer(
              selectedIndex: actif < 0 ? null : actif,
              onDestinationSelected: (i) {
                Navigator.pop(context); // ferme le tiroir
                context.go(entrees[i].chemin);
              },
              children: [
                // l'en-tête : qui est connecté
                Padding(
                  padding: const EdgeInsets.fromLTRB(28, 16, 16, 10),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(u.nom, style: Theme.of(context).textTheme.titleMedium),
                      Chip(label: Text(u.role.name.toUpperCase())),
                    ],
                  ),
                ),
                for (final e in entrees) NavigationDrawerDestination(icon: Icon(e.icone), label: Text(t(e.cle))),
              ],
            ),
      body: u == null || !grandEcran
          ? contenu
          : Row(
              children: [
                NavigationRail(
                  selectedIndex: actif < 0 ? null : actif,
                  labelType: NavigationRailLabelType.all,
                  onDestinationSelected: (i) => context.go(entrees[i].chemin),
                  destinations: [
                    for (final e in entrees) NavigationRailDestination(icon: Icon(e.icone), label: Text(t(e.cle))),
                  ],
                ),
                const VerticalDivider(width: 1),
                Expanded(child: contenu),
              ],
            ),
    );
  }
}

// le message de succès, en haut
class _MessageSucces extends StatelessWidget {
  const _MessageSucces();

  @override
  Widget build(BuildContext context) {
    final succes = context.watch<Messages>().succes;
    if (succes == null) return const SizedBox.shrink();
    final t = context.watch<Traductions>().t;
    return Material(
      color: Colors.green.shade50,
      child: ListTile(
        leading: Icon(Icons.check_circle, color: Colors.green.shade700),
        title: Text(t(succes.cle, succes.params), style: TextStyle(color: Colors.green.shade900)),
        trailing: IconButton(icon: const Icon(Icons.close), onPressed: context.read<Messages>().effacer),
      ),
    );
  }
}

// la zone d'erreur, en bas : toujours visible, même si l'écran défile
class _ZoneErreur extends StatelessWidget {
  const _ZoneErreur();

  @override
  Widget build(BuildContext context) {
    final erreur = context.watch<Messages>().erreur;
    if (erreur == null) return const SizedBox.shrink();
    final t = context.watch<Traductions>().t;
    return Material(
      color: const Color(0xFFF8D7DA),
      // SafeArea : au-dessus de la barre de navigation du téléphone
      child: SafeArea(
        top: false,
        child: ListTile(
          leading: Icon(Icons.error, color: Colors.red.shade800),
          title: Text(t(erreur.cle, erreur.params), style: TextStyle(color: Colors.red.shade900)),
          trailing: IconButton(icon: const Icon(Icons.close), onPressed: context.read<Messages>().effacer),
        ),
      ),
    );
  }
}

Le widget de la [ShellRoute] : ce qui entoure tous les écrans.

  • ligne 26 : une entrée du menu : un record ;
  • lignes 28-38 : les entrées permises au rôle de l'utilisateur, par un [if] dans une liste (exemple 03). [...[...]] : l'opérateur de décomposition insère plusieurs éléments d'un coup ;
  • ligne 55 : l'adresse courante, pour marquer l'entrée active du menu ;
  • ligne 58 : la largeur de l'écran, en points. Au-delà de 800 points (le web, une tablette en paysage), le menu sera affiché en permanence ;
  • ligne 61 : la déconnexion : la session se vide, et le routeur, qui l'écoute, affiche l'écran de connexion. [catchError] : même si le serveur ne répond pas, on considère l'utilisateur déconnecté ;
  • lignes 63-68 : le message de succès en haut, l'écran au milieu ([Expanded] : toute la place restante), l'erreur en bas ;
  • lignes 73-89 : la barre du haut : le titre, le menu des langues ([PopupMenuButton] : un bouton qui ouvre une liste ; son icône est ici un texte, « FR » ou « EN »), la déconnexion ;
  • lignes 93-113 : sur un petit écran, un tiroir ([NavigationDrawer]), qui s'ouvre par le bouton ☰ que le [Scaffold] ajoute de lui-même à la barre du haut. Toucher une entrée ferme le tiroir ([Navigator.pop]) et change d'adresse ;
  • lignes 116-129 : sur un grand écran, un [NavigationRail] : le menu, en colonne, à gauche du contenu ;
  • lignes 137-154 : le message de succès. [context.watch<Messages>()] : le widget est reconstruit à chaque changement des messages ; sans message, il n'occupe aucune place ([SizedBox.shrink]) ;
  • lignes 168-169 : la zone d'erreur est au bas de l'écran : [SafeArea] l'écarte de la barre de navigation du téléphone (les boutons ou la barre de gestes d'[Android]).

11.12.2. lib/widgets/titre_page.dart

// =========================================================================
// lib/widgets/titre_page.dart - le titre de l'écran, traduit
// -------------------------------------------------------------------------
// Sur le web : le titre de l'onglet ; sur Android : le nom de l'application
// dans la liste des applications récentes. "Agenda - RdvMedecins"
// =========================================================================

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

import '../i18n/traductions.dart';

class TitrePage extends StatelessWidget {
  final String cle;
  final Widget child;

  const TitrePage({super.key, required this.cle, required this.child});

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    return Title(title: '${t(cle)} - ${t('APP.TITLE')}', color: Theme.of(context).colorScheme.primary, child: child);
  }
}

Le titre de l'écran, traduit ([Title], cf. exemple 02) : le titre de l'onglet sur le web, le nom de l'application dans la liste des applications récentes d'[Android].

11.12.3. lib/widgets/envoi_formulaire.dart

// =========================================================================
// lib/widgets/envoi_formulaire.dart - envoyer un formulaire au serveur
// -------------------------------------------------------------------------
// Tous les formulaires de l'application suivent le même schéma que
// l'exemple 25 : aucune règle n'est vérifiée sur le téléphone (elles vivent
// en UN seul endroit, le serveur), et en cas de refus :
//   - l'erreur de chaque champ (ErreurApi.champs) est affichée sous le champ ;
//   - le message général (ErreurApi.cle) s'affiche en bas de l'écran.
// Cette classe regroupe ces gestes (c'est la Commande de l'exemple 23,
// complétée des erreurs des champs). Un écran en crée une par formulaire :
//
//     final _envoi = EnvoiFormulaire(messages);
//     if (await _envoi.envoyer(() => api.inscrire(saisie))) { ... }
//     ChampSaisie(..., erreur: _envoi.erreurs['nom'])
// =========================================================================

import 'package:flutter/foundation.dart';

import '../api/erreur_api.dart';
import '../etat/messages.dart';

class EnvoiFormulaire extends ChangeNotifier {
  final Messages _messages;
  // l'erreur de chaque champ : une CLÉ de traduction
  Map<String, String> erreurs = {};
  // les paramètres des messages d'erreur ({{min}}...), renvoyés par le serveur
  Map<String, String> params = {};
  bool enCours = false;

  EnvoiFormulaire(this._messages);

  // exécute l'envoi ; renvoie true s'il a réussi. En cas d'ErreurApi, les
  // erreurs sont affichées et on renvoie false
  Future<bool> envoyer(Future<void> Function() action) async {
    if (enCours) return false; // pas de double envoi
    erreurs = {};
    params = {};
    enCours = true;
    notifyListeners();
    try {
      await action();
      return true;
    } on ErreurApi catch (e) {
      _messages.afficherErreurApi(e);
      erreurs = e.champs;
      params = e.params;
      return false;
    } finally {
      enCours = false;
      notifyListeners();
    }
  }

  // l'utilisateur modifie un champ : son erreur disparaît
  void effacerErreur(String champ) {
    if (erreurs.containsKey(champ)) {
      erreurs = {...erreurs}..remove(champ);
      notifyListeners();
    }
  }
}

L'envoi d'un formulaire au serveur : la [Commande] de l'exemple 23, complétée des erreurs des champs (exemple 25).

  • lignes 34-50 : l'envoi : pas de double envoi ; pendant l'envoi, [enCours] (les boutons se désactivent) ; en cas d'[ErreurApi], le message général en bas de l'écran et l'erreur de chaque champ. [finally] s'exécute dans tous les cas, même après un [return] ;
  • lignes 55-57 : l'utilisateur corrige un champ : son erreur disparaît. On remplace la [Map] par une copie, plutôt que de la modifier : l'ancienne a peut-être été donnée par le serveur ([e.champs]), et une [Map] constante ([const {}]) ne se modifie pas.

11.12.4. lib/widgets/champ_saisie.dart

// =========================================================================
// lib/widgets/champ_saisie.dart - un champ de formulaire complet
// -------------------------------------------------------------------------
// Libellé + champ + message d'aide OU message d'erreur. Tous les
// formulaires de l'application l'utilisent :
//
//   ChampSaisie(libelle: 'PERSONNES.LASTNAME_LABEL', longueurMax: 20,
//               valeur: _saisie['nom'], onChanged: (v) => _modifier('nom', v),
//               erreur: _envoi.erreurs['nom'])
//
// C'est un widget CONTRÔLÉ (cf. exemple 11) : la valeur appartient au
// formulaire parent ; didUpdateWidget recopie dans le champ une valeur que
// le parent a changée lui-même. libelle, aide et erreur sont des CLÉS de
// traduction ; params, les paramètres de leurs messages ({{min}}...).
// =========================================================================

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

import '../i18n/traductions.dart';

class ChampSaisie extends StatefulWidget {
  final String libelle;
  final String valeur;
  final ValueChanged<String> onChanged;
  final String? erreur;
  final String? aide;
  final Map<String, String> params;
  final bool secret; // un mot de passe : caractères masqués
  final int? longueurMax;
  final bool autofocus;
  final Iterable<String>? autofill; // pour le gestionnaire de mots de passe du téléphone

  const ChampSaisie({
    super.key,
    required this.libelle,
    required this.valeur,
    required this.onChanged,
    this.erreur,
    this.aide,
    this.params = const {},
    this.secret = false,
    this.longueurMax,
    this.autofocus = false,
    this.autofill,
  });

  @override
  State<ChampSaisie> createState() => _ChampSaisieState();
}

class _ChampSaisieState extends State<ChampSaisie> {
  late final _controleur = TextEditingController(text: widget.valeur);

  @override
  void didUpdateWidget(ChampSaisie ancien) {
    super.didUpdateWidget(ancien);
    if (widget.valeur != _controleur.text) _controleur.text = widget.valeur;
  }

  @override
  void dispose() {
    _controleur.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    final erreur = widget.erreur;
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: TextField(
        controller: _controleur,
        obscureText: widget.secret,
        autocorrect: !widget.secret,
        autofocus: widget.autofocus,
        maxLength: widget.longueurMax,
        autofillHints: widget.autofill,
        decoration: InputDecoration(
          labelText: t(widget.libelle),
          errorText: erreur == null ? null : t(erreur, widget.params),
          helperText: widget.aide == null ? null : t(widget.aide!, widget.params),
          helperMaxLines: 2,
          errorMaxLines: 3,
          counterText: '', // pas de compteur "0/20" sous le champ
        ),
        onChanged: widget.onChanged,
      ),
    );
  }
}

Un champ de formulaire complet : libellé, champ, message d'aide ou d'erreur. Tous les formulaires l'utilisent.

  • lignes 22-32 : les paramètres. Le libellé, l'aide et l'erreur sont des CLÉS de traduction ;
  • lignes 53-58 : un widget contrôlé (exemple 11) : la valeur appartient au parent. Si le parent la change lui-même (le captcha effacé après un échec), [didUpdateWidget] la recopie dans le champ ;
  • lignes 75-79 : un mot de passe est masqué, et le correcteur orthographique ne s'en mêle pas ; [autofillHints] indique au gestionnaire de mots de passe du téléphone ce que contient le champ (un login, un mot de passe, un nouveau mot de passe) ;
  • lignes 80-86 : le libellé, l'erreur (qui remplace l'aide), l'aide. [maxLength] limite la saisie, et afficherait un compteur (« 3/20 ») sous le champ : [counterText: ''] le cache.

11.12.5. lib/widgets/champs_personne.dart

// =========================================================================
// lib/widgets/champs_personne.dart - titre, prénom, nom
// -------------------------------------------------------------------------
// Les champs d'identité, communs à trois formulaires : médecin / client de
// l'ADMIN, "Créer un compte client" et "Gérer mon compte". Le parent lui
// passe les valeurs, les erreurs, et la fonction qui modifie un champ :
//     ChampsPersonne(valeurs: _saisie, erreurs: _envoi.erreurs, onChanged: _modifier)
// =========================================================================

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

import '../i18n/traductions.dart';
import 'champ_saisie.dart';

const titres = ['Mr', 'Mme', 'Melle'];

class ChampsPersonne extends StatelessWidget {
  final Map<String, Object?> valeurs;
  final Map<String, String> erreurs;
  final void Function(String champ, String valeur) onChanged;

  const ChampsPersonne({super.key, required this.valeurs, required this.erreurs, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    final erreurTitre = erreurs['titre'];
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        // DropdownButtonFormField : une liste déroulante avec libellé et erreur
        DropdownButtonFormField<String>(
          // key : si la valeur change par le code, le champ est recréé avec elle
          key: ValueKey(valeurs['titre']),
          initialValue: valeurs['titre'] as String,
          decoration: InputDecoration(
            labelText: t('PERSONNES.TITLE_LABEL'),
            errorText: erreurTitre == null ? null : t(erreurTitre),
          ),
          items: [for (final titre in titres) DropdownMenuItem(value: titre, child: Text(titre))],
          onChanged: (titre) => onChanged('titre', titre!),
        ),
        const SizedBox(height: 8),
        ChampSaisie(
          libelle: 'PERSONNES.FIRSTNAME_LABEL',
          longueurMax: 20,
          valeur: valeurs['prenom'] as String,
          onChanged: (v) => onChanged('prenom', v),
          erreur: erreurs['prenom'],
        ),
        ChampSaisie(
          libelle: 'PERSONNES.LASTNAME_LABEL',
          longueurMax: 20,
          valeur: valeurs['nom'] as String,
          onChanged: (v) => onChanged('nom', v),
          erreur: erreurs['nom'],
        ),
      ],
    );
  }
}

Les champs d'identité (titre, prénom, nom), communs aux formulaires de l'ADMIN, de la création de compte et de « Gérer mon compte ».

  • ligne 21 : le parent passe les valeurs, les erreurs, et UNE fonction pour modifier n'importe quel champ ;
  • lignes 33-42 : le titre, dans une liste déroulante, avec libellé et erreur, comme un champ de saisie. [initialValue] n'est lue qu'à la création du champ ; la clé ([ValueKey]) le recrée si la valeur change par le code.

11.12.6. lib/widgets/champ_captcha.dart

// =========================================================================
// lib/widgets/champ_captcha.dart - l'image du captcha et le champ de saisie
// -------------------------------------------------------------------------
// Un widget d'affichage : l'image (svg), le bouton "Autre image"
// (onRecharger), et le champ, CONTRÔLÉ par le formulaire parent (valeur,
// onChanged). L'image et son identifiant sont demandés au serveur par la
// page de connexion, qui en a besoin pour envoyer la réponse.
// =========================================================================

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';

import '../i18n/traductions.dart';
import 'champ_saisie.dart';

class ChampCaptcha extends StatelessWidget {
  final String? svg;
  final String valeur;
  final ValueChanged<String> onChanged;
  final VoidCallback onRecharger;
  final String? erreur;

  const ChampCaptcha({
    super.key,
    required this.svg,
    required this.valeur,
    required this.onChanged,
    required this.onRecharger,
    this.erreur,
  });

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(t('CAPTCHA.LABEL'), style: Theme.of(context).textTheme.bodySmall),
        Row(
          children: [
            Container(
              height: 54,
              width: 160,
              decoration: BoxDecoration(
                color: const Color(0xFFF5F7FA),
                border: Border.all(color: Colors.grey.shade300),
                borderRadius: BorderRadius.circular(6),
              ),
              // Semantics : ce que lit un lecteur d'écran à la place de l'image
              child: svg == null
                  ? null
                  : Semantics(
                      label: t('CAPTCHA.IMAGE_ALT'),
                      child: SvgPicture.string(svg!, fit: BoxFit.contain),
                    ),
            ),
            TextButton.icon(
              onPressed: onRecharger,
              icon: const Icon(Icons.refresh),
              label: Text(t('CAPTCHA.NEW_IMAGE')),
            ),
          ],
        ),
        ChampSaisie(
          libelle: 'CAPTCHA.LABEL',
          aide: 'CAPTCHA.HELP',
          longueurMax: 10,
          valeur: valeur,
          onChanged: onChanged,
          erreur: erreur,
        ),
      ],
    );
  }
}

L'image du captcha, le bouton « Autre image », et le champ. Le widget n'appelle pas le serveur : c'est l'écran de connexion qui demande l'image, car il a besoin de son identifiant pour envoyer la réponse.

  • lignes 51-53 : l'image SVG, reçue sous forme de texte ([SvgPicture.string], exemple 22). [Semantics] : ce que lit un lecteur d'écran (le [TalkBack] d'[Android]) à la place de l'image.

11.12.7. lib/widgets/confirmation.dart

// =========================================================================
// lib/widgets/confirmation.dart - « Êtes-vous sûr ? »
// -------------------------------------------------------------------------
// Repris de l'exemple 16. Le message est une CLÉ de traduction :
//     if (await confirmer(context, 'AGENDA.CANCEL_CONFIRM')) { ... }
// "Non, revenir" plutôt qu'"Annuler" : la question est parfois « Annuler ce
// rendez-vous ? », et un bouton "Annuler" y serait ambigu. Le focus est sur
// "Non" : un appui distrait sur Entrée ne supprime rien.
// Une nouvelle question efface les messages de l'action précédente.
// =========================================================================

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

import '../etat/messages.dart';
import '../i18n/traductions.dart';

Future<bool> confirmer(BuildContext context, String cle) async {
  context.read<Messages>().effacer();
  final t = context.read<Traductions>().t;
  final oui = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: Text(t('CONFIRMATION.TITLE')),
      content: Text(t(cle)),
      actions: [
        TextButton(autofocus: true, onPressed: () => Navigator.pop(context, false), child: Text(t('CONFIRMATION.NO'))),
        FilledButton(
          style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
          onPressed: () => Navigator.pop(context, true),
          child: Text(t('CONFIRMATION.YES')),
        ),
      ],
    ),
  );
  return oui ?? false;
}

La fenêtre de confirmation de l'exemple 16, traduite : [if (await confirmer(context, 'AGENDA.CANCEL_CONFIRM')) ...]. Le focus est sur « Non » ([autofocus]) : sur le web, un appui distrait sur Entrée ne supprime rien.

11.12.8. lib/widgets/fenetre_reservation.dart

// =========================================================================
// lib/widgets/fenetre_reservation.dart - la fenêtre de réservation d'un créneau
// -------------------------------------------------------------------------
// Une fenêtre au-dessus de l'agenda, fond grisé compris. Elle n'est PAS
// ouverte par showDialog : elle fait partie de l'écran de l'agenda, qui
// l'affiche quand son ADRESSE le demande (/agenda?...&reserver=7, cf.
// pages/agenda.dart). L'adresse décrit donc tout l'écran, fenêtre comprise.
//
// Le widget ne parle pas au serveur : il appelle
//   - onConfirmer, avec le patient choisi (null pour un patient qui réserve
//     pour lui-même) ;
//   - onFermer.
// C'est l'écran de l'agenda qui décide quoi en faire.
// =========================================================================

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

import '../api/types.dart';
import '../i18n/traductions.dart';

class FenetreReservation extends StatefulWidget {
  final String horaire; // "8:00 - 8:20"
  final List<Personne> clients; // ADMIN / DOCTOR : les patients parmi lesquels choisir
  final String? patient; // USER : le patient, c'est lui-même
  final bool envoiEnCours;
  final ValueChanged<int?> onConfirmer;
  final VoidCallback onFermer;

  const FenetreReservation({
    super.key,
    required this.horaire,
    required this.clients,
    required this.patient,
    required this.envoiEnCours,
    required this.onConfirmer,
    required this.onFermer,
  });

  @override
  State<FenetreReservation> createState() => _FenetreReservationState();
}

class _FenetreReservationState extends State<FenetreReservation> {
  int? _idClient;

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    final patient = widget.patient;
    return Stack(
      children: [
        // ModalBarrier : le fond grisé ; le toucher ferme la fenêtre
        ModalBarrier(color: Colors.black54, onDismiss: widget.onFermer),
        Center(
          child: Card(
            margin: const EdgeInsets.all(24),
            child: Padding(
              padding: const EdgeInsets.all(20),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Text(t('BOOKING_DIALOG.TITLE'), style: Theme.of(context).textTheme.titleLarge),
                  const SizedBox(height: 12),
                  Text(t('BOOKING_DIALOG.SLOT', {'horaire': widget.horaire})),
                  const SizedBox(height: 12),
                  // un patient réserve pour lui-même : pas de liste (et le serveur
                  // ignorerait de toute façon un éventuel idClient)
                  if (patient != null)
                    Text.rich(
                      TextSpan(
                        children: [
                          TextSpan(text: '${t('BOOKING_DIALOG.PATIENT_LABEL')} : '),
                          TextSpan(
                            text: patient,
                            style: const TextStyle(fontWeight: FontWeight.bold),
                          ),
                        ],
                      ),
                    )
                  else
                    DropdownButtonFormField<int?>(
                      initialValue: _idClient,
                      isExpanded: true,
                      decoration: InputDecoration(labelText: t('BOOKING_DIALOG.PATIENT_LABEL')),
                      items: [
                        DropdownMenuItem(value: null, child: Text(t('BOOKING_DIALOG.CHOOSE_PATIENT'))),
                        for (final c in widget.clients) DropdownMenuItem(value: c.id, child: Text(c.libelle)),
                      ],
                      onChanged: (id) => setState(() => _idClient = id),
                    ),
                  const SizedBox(height: 20),
                  // OverflowBar : les boutons côte à côte, les uns sous les autres s'ils ne tiennent pas
                  OverflowBar(
                    alignment: MainAxisAlignment.end,
                    spacing: 8,
                    children: [
                      TextButton(onPressed: widget.onFermer, child: Text(t('BOOKING_DIALOG.CANCEL'))),
                      FilledButton(
                        onPressed: widget.envoiEnCours ? null : () => widget.onConfirmer(_idClient),
                        child: Text(t('BOOKING_DIALOG.CONFIRM')),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ),
      ],
    );
  }
}

La fenêtre de réservation d'un créneau. Elle n'est PAS ouverte par [showDialog] : elle fait partie de l'écran de l'agenda, qui l'affiche quand son adresse le demande ([&reserver=7]).

  • lignes 23-28 : le widget ne parle pas au serveur : il reçoit ce qu'il affiche, et deux fonctions, appelées pour confirmer (avec le patient choisi) ou fermer ;
  • lignes 51-54 : un [Stack] superpose ses enfants : d'abord un [ModalBarrier], le fond grisé, qui couvre tout l'écran et arrête les touchers destinés à l'agenda (le toucher ferme la fenêtre), puis la fenêtre elle-même, centrée ;
  • lignes 70-91 : un patient réserve pour lui-même : son nom, sans liste. L'ADMIN ou le médecin choisissent le patient dans une liste déroulante ; le choix est l'état de la fenêtre ;
  • lignes 95-102 : les boutons ([OverflowBar] : côte à côte, ou les uns sous les autres s'ils ne tiennent pas) ; « Confirmer » est désactivé pendant l'envoi.

11.13. Les écrans

11.13.1. lib/pages/connexion.dart

// =========================================================================
// lib/pages/connexion.dart - l'écran de connexion (/login)
// -------------------------------------------------------------------------
// Même écran que dans les autres clients : login, mot de passe, captcha, et
// un second bouton "Créer un compte client" qui vérifie le MÊME captcha
// avant d'ouvrir la création de compte.
//
// /login?login=jbernard : le champ login est pré-rempli (juste après la
// création d'un compte).
// Après une connexion réussie, cet écran ne navigue PAS lui-même : la
// session change, et c'est le routeur qui quitte l'écran de connexion
// (vers /login?redirect=... ou la page d'accueil du rôle, cf. routeur.dart).
// =========================================================================

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

import '../api/rdvmedecins_api.dart';
import '../api/types.dart';
import '../etat/messages.dart';
import '../etat/session.dart';
import '../i18n/traductions.dart';
import '../widgets/champ_captcha.dart';
import '../widgets/champ_saisie.dart';
import '../widgets/envoi_formulaire.dart';
import '../widgets/titre_page.dart';

class PageConnexion extends StatefulWidget {
  final String login; // le login de l'adresse (?login=...)

  const PageConnexion({super.key, this.login = ''});

  @override
  State<PageConnexion> createState() => _PageConnexionState();
}

class _PageConnexionState extends State<PageConnexion> {
  late final _api = context.read<RdvMedecinsApi>();
  late final _messages = context.read<Messages>();
  late final _envoi = EnvoiFormulaire(_messages);
  late final Map<String, String> _saisie = {'login': widget.login, 'password': '', 'captcha': ''};
  Captcha? _captcha;

  @override
  void initState() {
    super.initState();
    _rechargerCaptcha();
  }

  @override
  void dispose() {
    _envoi.dispose();
    super.dispose();
  }

  // une nouvelle image : il le faut après CHAQUE tentative, car une image ne
  // sert qu'UNE fois (le serveur l'oublie dès qu'il l'a vérifiée)
  Future<void> _rechargerCaptcha() async {
    Captcha? captcha;
    try {
      captcha = await _api.captcha();
    } catch (_) {
      captcha = null; // serveur injoignable : la connexion échouera avec un message clair
    }
    if (mounted) setState(() => _captcha = captcha);
  }

  void _modifier(String champ, String valeur) {
    setState(() => _saisie[champ] = valeur);
    _envoi.effacerErreur(champ);
  }

  // les deux boutons partagent ce déroulement : envoi, puis, en cas d'échec,
  // message d'erreur et NOUVELLE image
  Future<void> _tenter(Future<void> Function() action) async {
    _messages.effacer();
    final reussi = await _envoi.envoyer(action);
    if (!reussi && mounted) {
      setState(() => _saisie['captcha'] = '');
      await _rechargerCaptcha();
    }
  }

  // en cas de succès, rien d'autre à faire ici : le routeur change d'écran
  Future<void> _seConnecter() => _tenter(
    () => context.read<Session>().connecter(
      _saisie['login']!.trim(),
      _saisie['password']!,
      _captcha?.id ?? '',
      _saisie['captcha']!,
    ),
  );

  Future<void> _creerCompte() => _tenter(() async {
    await _api.accesInscription(_captcha?.id ?? '', _saisie['captcha']!);
    if (mounted) context.go('/inscription');
  });

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    return TitrePage(
      cle: 'LOGIN.TITLE',
      child: ListenableBuilder(
        listenable: _envoi,
        builder: (context, _) => ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Text(t('LOGIN.TITLE'), style: Theme.of(context).textTheme.headlineSmall),
            const SizedBox(height: 12),
            // AutofillGroup : le gestionnaire de mots de passe du téléphone peut remplir le formulaire
            AutofillGroup(
              child: Column(
                children: [
                  ChampSaisie(
                    libelle: 'LOGIN.LOGIN_LABEL',
                    valeur: _saisie['login']!,
                    onChanged: (v) => _modifier('login', v),
                    erreur: _envoi.erreurs['login'],
                    autofill: const [AutofillHints.username],
                  ),
                  ChampSaisie(
                    libelle: 'LOGIN.PASSWORD_LABEL',
                    secret: true,
                    valeur: _saisie['password']!,
                    onChanged: (v) => _modifier('password', v),
                    erreur: _envoi.erreurs['password'],
                    autofill: const [AutofillHints.password],
                  ),
                ],
              ),
            ),
            ChampCaptcha(
              svg: _captcha?.svg,
              valeur: _saisie['captcha']!,
              onChanged: (v) => _modifier('captcha', v),
              onRecharger: _rechargerCaptcha,
              erreur: _envoi.erreurs['captcha'],
            ),
            const SizedBox(height: 8),
            FilledButton(onPressed: _envoi.enCours ? null : _seConnecter, child: Text(t('LOGIN.SUBMIT'))),
            const SizedBox(height: 12),
            OutlinedButton(onPressed: _envoi.enCours ? null : _creerCompte, child: Text(t('LOGIN.CREATE_ACCOUNT'))),
            Padding(
              padding: const EdgeInsets.only(top: 4),
              child: Text(
                t('LOGIN.CREATE_ACCOUNT_HELP'),
                textAlign: TextAlign.center,
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ),
            const SizedBox(height: 16),
            _ComptesDemo(t: t),
          ],
        ),
      ),
    );
  }
}

// les comptes de démonstration
class _ComptesDemo extends StatelessWidget {
  final String Function(String, [Map<String, Object?>]) t;

  const _ComptesDemo({required this.t});

  @override
  Widget build(BuildContext context) {
    const code = TextStyle(fontFamily: 'RobotoMono', fontSize: 12);
    final style = Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.grey.shade700);
    return DefaultTextStyle(
      style: style,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(t('LOGIN.DEMO_ACCOUNTS')),
          for (final (comptes, cle) in [
            ('admin', 'LOGIN.DEMO_ADMIN'),
            ('pelissier, tatou, dupont', 'LOGIN.DEMO_DOCTOR'),
            ('martin, durand, bistrou, german', 'LOGIN.DEMO_USER'),
          ])
            Text.rich(
              TextSpan(
                children: [
                  const TextSpan(text: '• '),
                  TextSpan(text: comptes, style: code),
                  TextSpan(text: ' - ${t(cle)}'),
                ],
              ),
            ),
        ],
      ),
    );
  }
}

L'écran de connexion.

  • lignes 39-43 : l'état : la couche d'accès à l'API et les messages (lus UNE fois, [late] : à la première utilisation), l'envoi du formulaire, la saisie (une [Map] : nom du champ -> valeur), le captcha reçu ;
  • lignes 59-66 : une nouvelle image, à l'ouverture de l'écran, sur demande, et après CHAQUE tentative (une image ne sert qu'une fois) ;
  • lignes 76-81 : les deux boutons partagent ce déroulement : envoi, puis, en cas d'échec, captcha effacé et nouvelle image ;
  • lignes 86-93 : la connexion. En cas de succès, l'écran ne fait RIEN d'autre : la session change, et le routeur, qui l'écoute, quitte l'écran de connexion (cf. [routeur.dart]) ;
  • lignes 95-97 : « Créer un compte client » : le serveur vérifie le captcha et délivre un laissez-passer (un cookie) ; on ouvre alors l'écran de création de compte ;
  • lignes 105-106 : l'écran est reconstruit quand l'envoi change (en cours, erreurs) ;
  • lignes 113-129 : [AutofillGroup] regroupe le login et le mot de passe : le gestionnaire de mots de passe du téléphone les remplit ensemble, et propose d'enregistrer le compte après la connexion ;
  • lignes 163-{L:TextSpan(text: ' - ' : les comptes de démonstration. [for (final (comptes, cle) in [...])] : une boucle sur une liste de records, déstructurés au passage (exemple 03).

11.13.2. lib/pages/agenda.dart

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
// =========================================================================
// lib/pages/agenda.dart - l'écran de l'agenda (/agenda)
// -------------------------------------------------------------------------
// Trois blocs, comme dans les autres clients :
//   1. le choix du médecin et du jour ;
//   2. l'agenda de ce médecin ce jour-là ;
//   3. la fenêtre de réservation d'un créneau.
//
// TOUT l'état de l'écran est dans son ADRESSE :
//     /agenda?idMedecin=1&jour=2026-10-05             l'agenda
//     /agenda?idMedecin=1&jour=2026-10-05&reserver=7  + la fenêtre de réservation
// Le routeur passe ces trois paramètres au widget ; l'écran en DÉDUIT ce
// qu'il affiche, et les actions de l'utilisateur changent l'adresse
// (context.go, context.push). Sur le web, on peut donc recharger la page,
// la mettre en favori, utiliser Précédent / Suivant ; sur le téléphone, le
// bouton Retour ferme la fenêtre de réservation.
//
// Ce que chacun voit et peut faire est décidé par le SERVEUR (champs
// patient, moi, reservable, idRv de chaque créneau) : l'écran se contente
// de l'afficher. Un patient ne reçoit même pas le nom des autres patients.
// =========================================================================

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

import '../api/erreur_api.dart';
import '../api/rdvmedecins_api.dart';
import '../api/types.dart';
import '../etat/cache.dart';
import '../etat/messages.dart';
import '../etat/session.dart';
import '../formatage.dart';
import '../i18n/traductions.dart';
import '../widgets/confirmation.dart';
import '../widgets/fenetre_reservation.dart';
import '../widgets/titre_page.dart';

// l'adresse de l'agenda (sans fenêtre de réservation, ou avec)
String adresseAgenda(int? idMedecin, String jour, [int? reserver]) => Uri(
  path: '/agenda',
  queryParameters: {'idMedecin': '${idMedecin ?? ''}', 'jour': jour, 'reserver': ?reserver?.toString()},
).toString();

class PageAgenda extends StatefulWidget {
  final int? idMedecin;
  final String? jour; // tel quel dans l'adresse : peut-être invalide
  final int? reserver;

  const PageAgenda({super.key, this.idMedecin, this.jour, this.reserver});

  @override
  State<PageAgenda> createState() => _PageAgendaState();
}

class _PageAgendaState extends State<PageAgenda> {
  late final _api = context.read<RdvMedecinsApi>();
  late final _cache = context.read<Cache>();
  late final _messages = context.read<Messages>();
  Future<List<Personne>>? _medecins;
  List<Personne> _clients = [];
  Future<Agenda>? _agenda;
  bool _envoiEnCours = false;

  // le jour : celui de l'adresse s'il est valide, sinon aujourd'hui
  bool get _jourInvalide => widget.jour != null && !estUneDateValide(widget.jour!);
  String get _jour => widget.jour != null && !_jourInvalide ? widget.jour! : aujourdhui();

  @override
  void initState() {
    super.initState();
    final utilisateur = context.read<Session>().utilisateur;
    _medecins = _cache.lire('medecins', () => _api.lister(TypePersonnes.medecins));
    // les patients : seulement pour ceux qui ont le droit de les lire (fenêtre de réservation)
    if (utilisateur.estGestionnaire) {
      _cache.lire('clients', () => _api.lister(TypePersonnes.clients)).then((c) {
        if (mounted) setState(() => _clients = c);
      }, onError: _messages.afficherErreurApi);
    }
    if (_jourInvalide) {
      // après la construction de l'écran : on ne modifie pas un autre état pendant un build
      WidgetsBinding.instance.addPostFrameCallback((_) => _messages.afficherErreur('ERRORS.INVALID_DATE'));
    }
    _chargerAgenda();
    // des données invalidées (réservation, annulation) : on relit l'agenda.
    // Utile surtout à l'écran caché SOUS la fenêtre de réservation (cf. _confirmerReservation)
    _cache.addListener(_chargerAgenda);
  }

  @override
  void dispose() {
    _cache.removeListener(_chargerAgenda);
    super.dispose();
  }

  // le routeur réutilise l'écran avec une autre adresse (autre médecin, autre jour)
  @override
  void didUpdateWidget(PageAgenda ancien) {
    super.didUpdateWidget(ancien);
    if (ancien.idMedecin != widget.idMedecin || ancien.jour != widget.jour) _chargerAgenda();
  }

  void _chargerAgenda() {
    final utilisateur = context.read<Session>().utilisateur;
    // sans médecin dans l'adresse, un DOCTOR voit SON agenda (le serveur le
    // retrouve à partir du jeton) ; les autres ne voient rien
    final avecAgenda = !_jourInvalide && (widget.idMedecin != null || utilisateur?.role == Role.doctor);
    setState(() {
      _agenda = avecAgenda
          ? _cache.lire('agenda/${widget.idMedecin}/$_jour', () => _api.agenda(widget.idMedecin, _jour))
          : null;
    });
    // une lecture en échec : son message, en bas de l'écran
    _agenda?.then((_) {}, onError: (Object e) => _messages.afficherErreurApi(e));
  }

  // c'est le serveur qui fait foi : après chaque action, on relit l'agenda
  // (et l'espace du patient, qui montre son rendez-vous). L'invalidation
  // prévient tous les écrans d'agenda vivants, dont celui-ci (_chargerAgenda)
  void _recharger() {
    _cache.invalider('mon-espace');
    _cache.invalider('agenda');
  }

  void _fermerReservation(int? idMedecin) {
    if (context.canPop()) {
      context.pop();
    } else {
      context.go(adresseAgenda(idMedecin, _jour));
    }
  }

  Future<void> _confirmerReservation(CreneauAgenda creneau, int? idMedecin, int? idClient) async {
    final gestionnaire = context.read<Session>().utilisateur.estGestionnaire;
    setState(() => _envoiEnCours = true);
    try {
      await _api.reserver(_jour, creneau.id, gestionnaire ? idClient : null);
      _messages.flash(cle: 'AGENDA.BOOKED');
    } on ErreurApi catch (e) {
      if (e.cle == 'ERRORS.CHOOSE_PATIENT') {
        // aucun patient choisi : la fenêtre reste ouverte
        _messages.afficherErreur(e.cle);
        return; // (finally s'exécute quand même)
      }
      // créneau pris entre-temps, patient qui a déjà un rendez-vous... : le
      // message s'affichera sur l'agenda, fenêtre fermée
      _messages.flash(succes: false, cle: e.cle, params: e.params);
    } finally {
      if (mounted) setState(() => _envoiEnCours = false);
    }
    if (!mounted) return;
    _fermerReservation(idMedecin);
    _recharger();
  }

  Future<void> _annuler(CreneauAgenda c) async {
    if (c.idRv == null || !await confirmer(context, 'AGENDA.CANCEL_CONFIRM')) return;
    try {
      await _api.annulerRv(c.idRv!);
      _messages.afficherSucces('AGENDA.CANCELLED');
    } on ErreurApi catch (e) {
      _messages.afficherErreurApi(e);
    }
    if (mounted) _recharger();
  }

  @override
  Widget build(BuildContext context) {
    final traductions = context.watch<Traductions>();
    final t = traductions.t;
    final utilisateur = context.watch<Session>().utilisateur;

    return TitrePage(
      cle: 'AGENDA.PAGE_TITLE',
      // FutureBuilder : l'agenda (null tant qu'il n'est pas arrivé, ou s'il n'y en a pas)
      child: FutureBuilder<Agenda>(
        future: _agenda,
        builder: (context, etat) {
          final a = etat.data;
          // le médecin affiché : celui de l'adresse, ou celui de l'agenda reçu (DOCTOR)
          final idMedecin = widget.idMedecin ?? a?.medecin.id;
          // le créneau dont la fenêtre de réservation est ouverte (&reserver=7),
          // seulement s'il est réservable par cet utilisateur
          final aReserver = a?.creneaux.where((c) => c.id == widget.reserver && c.reservable).firstOrNull;
          // le message du serveur au patient qui a déjà un rendez-vous à venir
          // ("annulez-le pour en prendre un autre") n'explique rien si CE
          // rendez-vous est dans l'agenda affiché : on le voit, sous son nom,
          // avec son bouton "Annuler". On ne l'affiche que dans les AUTRES agendas
          final info = a != null && !a.creneaux.any((c) => c.moi) ? a.info : null;

          final Widget contenu;
          if (etat.connectionState == ConnectionState.waiting) {
            contenu = const Padding(
              padding: EdgeInsets.all(24),
              child: Center(child: CircularProgressIndicator()),
            );
          } else if (a != null) {
            contenu = _Agenda(
              agenda: a,
              langue: traductions.langue,
              onReserver: (c) => context.push(adresseAgenda(idMedecin, _jour, c.id)),
              onAnnuler: _annuler,
            );
          } else {
            contenu = Padding(padding: const EdgeInsets.all(16), child: Text(t('AGENDA.EMPTY_STATE')));
          }

          return Stack(
            children: [
              ListView(
                padding: const EdgeInsets.all(12),
                children: [
                  // 1. le choix du médecin et du jour. Sa clé change avec l'adresse :
                  // Flutter le recrée alors, et il repart des nouvelles valeurs
                  FutureBuilder<List<Personne>>(
                    future: _medecins,
                    builder: (context, medecins) => _ChoixMedecinJour(
                      key: ValueKey('$idMedecin|$_jour'),
                      medecins: medecins.data ?? const [],
                      idMedecin: idMedecin,
                      jour: _jour,
                      onVoir: (id, jour) => context.go(adresseAgenda(id, jour)),
                    ),
                  ),
                  // patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver"
                  if (info != null)
                    Card(
                      color: Colors.lightBlue.shade50,
                      child: ListTile(
                        title: Text(t(info)),
                        trailing: TextButton(
                          onPressed: () => context.go('/mon-espace'),
                          child: Text(t('APP.MENU_MY_SPACE')),
                        ),
                      ),
                    ),
                  contenu,
                ],
              ),
              // 3. la fenêtre de réservation, seulement si l'adresse contient &reserver=...
              if (aReserver != null)
                FenetreReservation(
                  horaire: horaire(aReserver),
                  clients: _clients,
                  patient: utilisateur.estPatient ? utilisateur!.nom : null,
                  envoiEnCours: _envoiEnCours,
                  onConfirmer: (idClient) => _confirmerReservation(aReserver, idMedecin, idClient),
                  onFermer: () => _fermerReservation(idMedecin),
                ),
            ],
          );
        },
      ),
    );
  }
}

// -------------------------------------------------------------------------
// 1. le choix du médecin et du jour : un petit formulaire, dont l'état ne
// sert qu'avant l'envoi. Il DÉMARRE avec les valeurs de l'adresse.
// -------------------------------------------------------------------------
class _ChoixMedecinJour extends StatefulWidget {
  final List<Personne> medecins;
  final int? idMedecin;
  final String jour;
  final void Function(int? idMedecin, String jour) onVoir;

  const _ChoixMedecinJour({
    super.key,
    required this.medecins,
    required this.idMedecin,
    required this.jour,
    required this.onVoir,
  });

  @override
  State<_ChoixMedecinJour> createState() => _ChoixMedecinJourState();
}

class _ChoixMedecinJourState extends State<_ChoixMedecinJour> {
  late int? _idMedecin = widget.idMedecin;
  late String _jour = widget.jour;

  Future<void> _choisirJour() async {
    final choix = await showDatePicker(
      context: context,
      initialDate: DateTime.parse(_jour),
      firstDate: DateTime(2020),
      lastDate: DateTime(2035, 12, 31),
    );
    if (choix != null) setState(() => _jour = versJour(choix));
  }

  @override
  Widget build(BuildContext context) {
    final traductions = context.watch<Traductions>();
    final t = traductions.t;
    // le médecin choisi doit être dans la liste (sinon : aucun)
    final idConnu = widget.medecins.any((m) => m.id == _idMedecin) ? _idMedecin : null;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            DropdownButtonFormField<int?>(
              key: ValueKey('${widget.medecins.length}/$idConnu'),
              initialValue: idConnu,
              isExpanded: true,
              decoration: InputDecoration(labelText: t('DOCTOR_DAY_PICKER.DOCTOR_LABEL')),
              items: [
                DropdownMenuItem(value: null, child: Text(t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR'))),
                for (final m in widget.medecins) DropdownMenuItem(value: m.id, child: Text(m.libelle)),
              ],
              onChanged: (id) => setState(() => _idMedecin = id),
            ),
            const SizedBox(height: 8),
            // le jour : un bouton qui ouvre le calendrier
            InputDecorator(
              decoration: InputDecoration(labelText: t('DOCTOR_DAY_PICKER.DAY_LABEL')),
              child: InkWell(
                onTap: _choisirJour,
                child: Row(
                  children: [
                    Expanded(child: Text(jourLong(_jour, traductions.langue))),
                    const Icon(Icons.calendar_month),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: idConnu == null ? null : () => widget.onVoir(idConnu, _jour),
              child: Text(t('DOCTOR_DAY_PICKER.VIEW_AGENDA')),
            ),
          ],
        ),
      ),
    );
  }
}

// -------------------------------------------------------------------------
// 2. l'agenda
// -------------------------------------------------------------------------
class _Agenda extends StatelessWidget {
  final Agenda agenda;
  final String langue;
  final ValueChanged<CreneauAgenda> onReserver;
  final ValueChanged<CreneauAgenda> onAnnuler;

  const _Agenda({required this.agenda, required this.langue, required this.onReserver, required this.onAnnuler});

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    final a = agenda;
    return Card(
      child: Padding(
        padding: const EdgeInsets.symmetric(vertical: 8),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            ListTile(
              title: Text(
                '${t('AGENDA.TITLE_PREFIX')} ${jourLong(a.jour, langue)}',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              subtitle: Text(a.medecin.libelle),
            ),
            if (a.creneaux.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(t('AGENDA.NO_SLOTS'))),
            for (final c in a.creneaux) ...[
              const Divider(height: 1),
              ListTile(
                // les créneaux réservables sont légèrement colorés
                tileColor: c.reservable ? Colors.green.shade50 : null,
                leading: SizedBox(
                  width: 96,
                  child: Text(horaire(c), style: const TextStyle(fontWeight: FontWeight.w500)),
                ),
                title: Align(
                  alignment: Alignment.centerLeft,
                  child: c.libre
                      ? _Etiquette(t('AGENDA.FREE'), Colors.green.shade700)
                      : c.patient != null
                      ? _Etiquette(c.patient!.libelle, c.moi ? Colors.indigo : Colors.grey.shade600)
                      : _Etiquette(t('AGENDA.BOOKED_BY_OTHER'), Colors.grey.shade600),
                ),
                // "Réserver" change l'ADRESSE (&reserver=...), ce qui ouvre la fenêtre de réservation
                trailing: c.reservable
                    ? OutlinedButton(onPressed: () => onReserver(c), child: Text(t('AGENDA.BOOK')))
                    : c.idRv != null
                    ? OutlinedButton(
                        style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
                        onPressed: () => onAnnuler(c),
                        child: Text(t('AGENDA.CANCEL_APPOINTMENT')),
                      )
                    : null,
              ),
            ],
          ],
        ),
      ),
    );
  }
}

// une étiquette colorée (le "badge" des autres clients)
class _Etiquette extends StatelessWidget {
  final String texte;
  final Color couleur;

  const _Etiquette(this.texte, this.couleur);

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
      decoration: BoxDecoration(color: couleur, borderRadius: BorderRadius.circular(6)),
      child: Text(
        texte,
        style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
      ),
    );
  }
}

L'écran le plus riche : le choix du médecin et du jour, l'agenda, la fenêtre de réservation.

  • lignes 40-43 : l'adresse de l'agenda. TOUT l'état de l'écran est dans son adresse ; les actions de l'utilisateur la changent, et l'écran en déduit ce qu'il affiche ;
  • lignes 66-67 : le jour de l'adresse, s'il est valide, sinon aujourd'hui ;
  • lignes 70-87 : à l'ouverture de l'écran : les médecins (depuis le cache : ils sont lus une fois pour toute la session), les patients si l'utilisateur a le droit de les lire (pour la fenêtre de réservation), et l'agenda. Un jour invalide dans l'adresse : un message, affiché APRÈS la construction de l'écran ([addPostFrameCallback] : on ne modifie pas un autre état pendant une construction). Enfin, l'écran écoute le cache ;
  • lignes 98-100 : « Voir l'agenda » change l'adresse ; le routeur RÉUTILISE l'écran (même route) avec de nouveaux paramètres : [didUpdateWidget] relit alors l'agenda ;
  • lignes 103-114 : la lecture de l'agenda, par le cache. Sans médecin dans l'adresse, un médecin connecté voit SON agenda (le serveur le retrouve grâce au jeton) ;
  • lignes 120-122 : après une réservation ou une annulation, c'est le serveur qui fait foi : on invalide l'agenda (et l'espace du patient) ; le cache prévient les écrans d'agenda, qui se relisent ;
  • lignes 125-129 : fermer la fenêtre : revenir à l'adresse précédente. « Réserver » a EMPILÉ l'adresse avec [&reserver=] ([context.push]) : on la dépile ([context.pop]), exactement comme le fait le bouton Retour du téléphone. Si l'écran a été ouvert directement sur cette adresse (sur le web), il n'y a rien à dépiler : on va à l'adresse de l'agenda ;
  • lignes 133-153 : la réservation. Sans patient choisi, la fenêtre reste ouverte, avec le message. Toute autre issue ferme la fenêtre, et le message (succès ou erreur : un créneau pris entre-temps...) est un message flash, pour l'agenda. L'écran d'agenda qui porte la fenêtre disparaît ; celui qui était dessous se relit, grâce au cache ;
  • lignes 156-164 : l'annulation, après confirmation ;
  • lignes 176-184 : l'écran suit la lecture de l'agenda ([FutureBuilder]). Le créneau de la fenêtre de réservation est celui de l'adresse ([&reserver=7]), seulement s'il est réservable PAR CET UTILISATEUR (un créneau pris entre-temps, une adresse fabriquée à la main : pas de fenêtre) ;
  • ligne 189 : le serveur envoie ce message ([info]) avec TOUS les agendas ; mais dans l'agenda qui contient le rendez-vous du patient (un créneau [moi]), il est inutile, et même trompeur juste après une réservation : le patient voit son rendez-vous, sous son nom, avec son bouton « Annuler ». On ne l'affiche donc que dans les autres agendas. [a.creneaux.any((c) => c.moi)] : vrai si au moins un créneau vérifie la condition ;
  • lignes 201-202 : « Réserver » empile l'adresse de la fenêtre ;
  • lignes 215-222 : le choix du médecin et du jour. Sa clé contient le médecin et le jour de l'adresse : quand l'adresse change (bouton Retour, Précédent du navigateur), [Flutter] recrée le widget, qui repart des nouvelles valeurs ;
  • lignes 226-233 : un patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton « Réserver » (cf. [info], plus haut) ;
  • lignes 241-248 : la fenêtre de réservation, au-dessus de l'agenda (dans le [Stack]) ;
  • lignes 280-291 : le choix du médecin et du jour : un petit formulaire, dont l'état DÉMARRE avec les valeurs de l'adresse. [late int? _idMedecin = widget.idMedecin;] : un champ initialisé à partir de [widget], possible grâce à [late]. [showDatePicker] ouvre le calendrier de [Material], dans la langue de l'application ;
  • lignes 319-326 : le jour s'affiche comme un champ de saisie ([InputDecorator] : le libellé et le cadre d'un champ, autour de n'importe quel widget), qu'on touche pour ouvrir le calendrier ;
  • lignes 346-406 : l'agenda. Une ligne par créneau : l'horaire, une étiquette (libre, le nom du patient, ou « Réservé » pour un autre patient), et le bouton permis par le SERVEUR ([reservable], [idRv]).

11.13.3. lib/pages/personnes_config.dart

// =========================================================================
// lib/pages/personnes_config.dart - médecins ou clients ?
// -------------------------------------------------------------------------
// Les écrans de gestion des médecins et des clients sont IDENTIQUES : même
// liste, même formulaire. On les écrit UNE fois (personnes.dart,
// personne_formulaire.dart) ; seules changent l'adresse de l'API et les
// clés des libellés, décrites ici. Le routeur passe le type à chaque écran.
// =========================================================================

import '../api/rdvmedecins_api.dart';
import '../api/types.dart';

typedef ClesPersonnes = ({
  String titre, // "Médecins"
  String ajouter, // "Ajouter un médecin"
  String modifier, // "Modifier un médecin"
  String vide, // "Aucun médecin."
  String confirmerSuppression,
  String ajoute, // "Le médecin {{nom}} a été ajouté (login : {{login}})."
  String modifie,
  String supprime,
  String introuvable, // la fiche a été supprimée entre-temps
});

ClesPersonnes cles(TypePersonnes type) => switch (type) {
  TypePersonnes.medecins => (
    titre: 'MEDECINS.TITLE',
    ajouter: 'MEDECINS.ADD',
    modifier: 'MEDECINS.EDIT',
    vide: 'MEDECINS.EMPTY',
    confirmerSuppression: 'MEDECINS.DELETE_CONFIRM',
    ajoute: 'MEDECINS.ADDED',
    modifie: 'MEDECINS.UPDATED',
    supprime: 'MEDECINS.DELETED',
    introuvable: 'ERRORS.DOCTOR_NOT_FOUND',
  ),
  TypePersonnes.clients => (
    titre: 'CLIENTS.TITLE',
    ajouter: 'CLIENTS.ADD',
    modifier: 'CLIENTS.EDIT',
    vide: 'CLIENTS.EMPTY',
    confirmerSuppression: 'CLIENTS.DELETE_CONFIRM',
    ajoute: 'CLIENTS.ADDED',
    modifie: 'CLIENTS.UPDATED',
    supprime: 'CLIENTS.DELETED',
    introuvable: 'ERRORS.PATIENT_NOT_FOUND',
  ),
};

// {{nom}} et {{login}} des messages "ajouté / modifié / supprimé"
Map<String, String> parametres(Personne p) => {'nom': '${p.prenom} ${p.nom}', 'login': p.login ?? ''};

Les écrans des médecins et des clients sont identiques ; seules changent les clés des libellés, décrites ici.

  • lignes 13-22 : un record à champs nommés, qui rassemble les clés d'un type de personnes ;
  • lignes 25-37 : un [switch] expression sur une énumération : [Dart] vérifie que tous les cas sont traités. Ajouter une valeur à [TypePersonnes] produirait une erreur de compilation ici ;
  • ligne 51 : les paramètres des messages « ajouté », « modifié », « supprimé ».

11.13.4. lib/pages/personnes.dart

// =========================================================================
// lib/pages/personnes.dart - la liste des médecins OU des clients (ADMIN)
// -------------------------------------------------------------------------
// Un seul écran pour les deux : le type (médecins ou clients) arrive en
// paramètre (cf. routeur.dart) ; les libellés propres à chacun sont dans
// personnes_config.dart.
// Sur un téléphone, un tableau à cinq colonnes ne tient pas : une ligne de
// liste par personne (nom, login), avec ses deux boutons ; le bouton rond
// "+" (FloatingActionButton) ajoute une personne.
// =========================================================================

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

import '../api/erreur_api.dart';
import '../api/rdvmedecins_api.dart';
import '../api/types.dart';
import '../etat/cache.dart';
import '../etat/messages.dart';
import '../i18n/traductions.dart';
import '../widgets/confirmation.dart';
import '../widgets/titre_page.dart';
import 'personnes_config.dart';

class PagePersonnes extends StatefulWidget {
  final TypePersonnes type;

  const PagePersonnes({super.key, required this.type});

  @override
  State<PagePersonnes> createState() => _PagePersonnesState();
}

class _PagePersonnesState extends State<PagePersonnes> {
  late final _api = context.read<RdvMedecinsApi>();
  late final _cache = context.read<Cache>();
  late final _messages = context.read<Messages>();
  Future<List<Personne>>? _liste;

  @override
  void initState() {
    super.initState();
    _charger();
  }

  // le routeur réutilise l'écran pour l'autre type (/medecins -> /clients)
  @override
  void didUpdateWidget(PagePersonnes ancien) {
    super.didUpdateWidget(ancien);
    if (ancien.type != widget.type) _charger();
  }

  void _charger() {
    final type = widget.type;
    setState(() {
      _liste = _cache.lire(type.name, () => _api.lister(type));
    });
    _liste!.then((_) {}, onError: (Object e) => _messages.afficherErreurApi(e));
  }

  Future<void> _supprimer(Personne p) async {
    final c = cles(widget.type);
    if (!await confirmer(context, c.confirmerSuppression)) return;
    try {
      await _api.supprimer(widget.type, p.id);
      _messages.afficherSucces(c.supprime, parametres(p));
    } on ErreurApi catch (e) {
      // un médecin qui a encore des rendez-vous : 409 ERRORS.DOCTOR_HAS_APPOINTMENTS
      _messages.afficherErreurApi(e);
    }
    _cache.invalider(widget.type.name);
    if (mounted) _charger();
  }

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    final c = cles(widget.type);
    final type = widget.type.name;
    return TitrePage(
      cle: c.titre,
      // un Scaffold DANS le gabarit : pour son bouton flottant
      child: Scaffold(
        floatingActionButton: FloatingActionButton.extended(
          onPressed: () => context.go('/$type/ajouter'),
          icon: const Icon(Icons.add),
          label: Text(t(c.ajouter)),
        ),
        body: FutureBuilder<List<Personne>>(
          future: _liste,
          builder: (context, etat) {
            final liste = etat.data;
            if (liste == null) {
              // pas encore chargée (ou chargement en échec : le message est en bas de l'écran)
              return etat.hasError ? const SizedBox.shrink() : const Center(child: CircularProgressIndicator());
            }
            return ListView(
              // de la place en bas, pour ne pas cacher la dernière ligne sous le bouton flottant
              padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
              children: [
                Padding(
                  padding: const EdgeInsets.all(8),
                  child: Text(t(c.titre), style: Theme.of(context).textTheme.headlineSmall),
                ),
                if (liste.isEmpty) ListTile(title: Text(t(c.vide))),
                for (final p in liste)
                  Card(
                    child: ListTile(
                      leading: CircleAvatar(child: Text('${p.prenom[0]}${p.nom[0]}')),
                      title: Text(p.libelle),
                      subtitle: Text(p.login ?? '', style: const TextStyle(fontFamily: 'RobotoMono')),
                      trailing: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          IconButton(
                            tooltip: t('PERSONNES.EDIT'),
                            icon: const Icon(Icons.edit),
                            onPressed: () => context.go('/$type/${p.id}/modifier'),
                          ),
                          IconButton(
                            tooltip: t('PERSONNES.DELETE'),
                            icon: Icon(Icons.delete_outline, color: Colors.red.shade700),
                            onPressed: () => _supprimer(p),
                          ),
                        ],
                      ),
                    ),
                  ),
              ],
            );
          },
        ),
      ),
    );
  }
}

La liste des médecins ou des clients.

  • lignes 54-59 : la liste, depuis le cache. Attention aux accolades de [setState(() { ... })] : écrite avec une flèche, [setState(() => _liste = ...)], la fonction RENVERRAIT la valeur affectée, ici un [Future], et [setState] refuse une fonction qui renvoie un [Future] (la mise à jour de l'état doit être synchrone) : [Flutter] le signale par une erreur ;
  • lignes 62-73 : la suppression, après confirmation. Un médecin qui a des rendez-vous est refusé par le serveur (409 [DOCTOR_HAS_APPOINTMENTS]) ;
  • lignes 84-88 : un [Scaffold] dans le [Scaffold] du gabarit, pour son bouton flottant ([FloatingActionButton]) « + Ajouter », en bas à droite ;
  • lignes 107-124 : une ligne de liste par personne ([ListTile]) : un avatar avec ses initiales, son nom, son login, et ses deux boutons. Un tableau à cinq colonnes, celui des clients web, ne tiendrait pas sur un téléphone.

11.13.5. lib/pages/personne_formulaire.dart

// =========================================================================
// lib/pages/personne_formulaire.dart - création OU modification d'un
// médecin ou d'un client (ADMIN)
// -------------------------------------------------------------------------
//     /medecins/ajouter          -> création (pas d'id)
//     /medecins/3/modifier       -> modification (id = 3)
// (et les mêmes adresses sous /clients).
//
// Deux widgets :
//   - PagePersonneFormulaire lit la fiche à modifier (s'il y en a une) ;
//   - _Formulaire n'est affiché qu'une fois la fiche lue : il DÉMARRE avec
//     ses valeurs (état initial), sans avoir à les recopier plus tard.
// Aucune règle n'est vérifiée ici : le serveur valide tout, et ses erreurs
// s'affichent sous les champs (cf. widgets/envoi_formulaire.dart).
// La VERSION lue avec la fiche repart avec la modification : si un autre
// ADMIN l'a modifiée entre-temps, le serveur répond 409 CONCURRENT_UPDATE.
// =========================================================================

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

import '../api/erreur_api.dart';
import '../api/rdvmedecins_api.dart';
import '../api/types.dart';
import '../etat/cache.dart';
import '../etat/messages.dart';
import '../i18n/traductions.dart';
import '../widgets/champ_saisie.dart';
import '../widgets/champs_personne.dart';
import '../widgets/envoi_formulaire.dart';
import '../widgets/titre_page.dart';
import 'personnes_config.dart';

const _vide = <String, Object?>{'titre': 'Mr', 'prenom': '', 'nom': '', 'login': '', 'password': ''};

class PagePersonneFormulaire extends StatefulWidget {
  final TypePersonnes type;
  final int? id; // null : création

  const PagePersonneFormulaire({super.key, required this.type, this.id});

  @override
  State<PagePersonneFormulaire> createState() => _PagePersonneFormulaireState();
}

class _PagePersonneFormulaireState extends State<PagePersonneFormulaire> {
  Future<Personne>? _fiche;

  @override
  void initState() {
    super.initState();
    final id = widget.id;
    if (id != null) {
      // modification : on lit la fiche (dont sa version), SANS cache : elle
      // doit être à jour (sinon le formulaire démarrerait avec une version
      // périmée : 409 CONCURRENT_UPDATE)
      _fiche = context.read<RdvMedecinsApi>().lire(widget.type, id);
      // fiche inexistante (supprimée entre-temps...) : retour à la liste
      _fiche!.then(
        (_) {},
        onError: (Object e) {
          if (e is! ErreurApi || !mounted) return;
          context.read<Messages>().flash(succes: false, cle: e.cle, params: e.params);
          context.go('/${widget.type.name}');
        },
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final c = cles(widget.type);
    return TitrePage(
      cle: widget.id == null ? c.ajouter : c.modifier,
      child: widget.id == null
          ? _Formulaire(type: widget.type, id: null, initial: _vide)
          : FutureBuilder<Personne>(
              future: _fiche,
              builder: (context, etat) {
                final p = etat.data;
                if (p == null) return const Center(child: CircularProgressIndicator());
                return _Formulaire(
                  type: widget.type,
                  id: p.id,
                  initial: {
                    'titre': p.titre,
                    'prenom': p.prenom,
                    'nom': p.nom,
                    'login': p.login ?? '',
                    'password': '',
                    'version': p.version,
                  },
                );
              },
            ),
    );
  }
}

// le formulaire lui-même
class _Formulaire extends StatefulWidget {
  final TypePersonnes type;
  final int? id;
  final Map<String, Object?> initial;

  const _Formulaire({required this.type, required this.id, required this.initial});

  @override
  State<_Formulaire> createState() => _FormulaireState();
}

class _FormulaireState extends State<_Formulaire> {
  late final _messages = context.read<Messages>();
  late final _envoi = EnvoiFormulaire(_messages);
  // une COPIE modifiable des valeurs de départ
  late final Map<String, Object?> _saisie = {...widget.initial};

  bool get _creation => widget.id == null;

  @override
  void dispose() {
    _envoi.dispose();
    super.dispose();
  }

  void _modifier(String champ, String valeur) {
    setState(() => _saisie[champ] = valeur);
    _envoi.effacerErreur(champ);
  }

  Future<void> _enregistrer() async {
    final api = context.read<RdvMedecinsApi>();
    final cache = context.read<Cache>();
    final c = cles(widget.type);
    final reussi = await _envoi.envoyer(() async {
      final id = widget.id;
      final p = id == null ? await api.ajouter(widget.type, _saisie) : await api.modifier(widget.type, id, _saisie);
      _messages.flash(cle: _creation ? c.ajoute : c.modifie, params: parametres(p));
    });
    if (!mounted) return;
    final cle = _messages.erreur?.cle;
    if (reussi) {
      cache.invalider(widget.type.name); // la liste a changé
      context.go('/${widget.type.name}');
    } else if (cle == c.introuvable) {
      // supprimé entre-temps par un autre ADMIN : retour à la liste
      _messages.flash(succes: false, cle: cle!);
      context.go('/${widget.type.name}');
    }
  }

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    final c = cles(widget.type);
    return ListenableBuilder(
      listenable: _envoi,
      builder: (context, _) => ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text(t(_creation ? c.ajouter : c.modifier), style: Theme.of(context).textTheme.headlineSmall),
          const SizedBox(height: 8),
          ChampsPersonne(valeurs: _saisie, erreurs: _envoi.erreurs, onChanged: _modifier),
          const SizedBox(height: 16),
          Text(t('PERSONNES.ACCOUNT_SECTION'), style: Theme.of(context).textTheme.titleSmall),
          ChampSaisie(
            libelle: 'PERSONNES.LOGIN_LABEL',
            longueurMax: 30,
            valeur: _saisie['login'] as String,
            onChanged: (v) => _modifier('login', v),
            erreur: _envoi.erreurs['login'],
            aide: _creation ? 'PERSONNES.LOGIN_HELP_CREATE' : 'PERSONNES.LOGIN_HELP_EDIT',
          ),
          // jamais pré-rempli : le mot de passe ne revient jamais du serveur
          ChampSaisie(
            libelle: 'PERSONNES.PASSWORD_LABEL',
            secret: true,
            longueurMax: 72,
            valeur: _saisie['password'] as String,
            onChanged: (v) => _modifier('password', v),
            erreur: _envoi.erreurs['password'],
            params: _envoi.params,
            aide: _creation ? 'PERSONNES.PASSWORD_HELP_CREATE' : 'PERSONNES.PASSWORD_HELP_EDIT',
          ),
          const SizedBox(height: 16),
          Wrap(
            spacing: 8,
            children: [
              FilledButton(onPressed: _envoi.enCours ? null : _enregistrer, child: Text(t('PERSONNES.SAVE'))),
              OutlinedButton(onPressed: () => context.go('/${widget.type.name}'), child: Text(t('PERSONNES.BACK'))),
            ],
          ),
        ],
      ),
    );
  }
}

L'ajout ou la modification d'un médecin ou d'un client.

  • lignes 54-65 : pour une modification, on lit la fiche, SANS cache : sa version doit être à jour. Une fiche introuvable (supprimée entre-temps) : retour à la liste, avec un message ;
  • lignes 78-92 : le formulaire n'est affiché qu'une fois la fiche lue, et DÉMARRE avec ses valeurs : il n'a jamais à les recopier plus tard ;
  • ligne 117 : une COPIE modifiable des valeurs de départ ([...] : l'opérateur de décomposition, dans une [Map]) ;
  • lignes 132-149 : l'enregistrement : [POST] ou [PUT], selon qu'il y a un identifiant. Le message de succès est un message flash : il s'affichera sur la liste. Une fiche supprimée entre-temps par un autre ADMIN : retour à la liste ;
  • lignes 167-184 : le login et le mot de passe, avec une aide différente en création et en modification (le mot de passe laissé vide ne change pas). Le mot de passe n'est jamais pré-rempli : le serveur ne le renvoie jamais.

11.13.6. lib/pages/inscription.dart

// =========================================================================
// lib/pages/inscription.dart - "Créer un compte client" (/inscription)
// -------------------------------------------------------------------------
// On n'arrive ici qu'après avoir recopié le captcha de l'écran de connexion
// (bouton "Créer un compte client") : le serveur a alors délivré un
// laissez-passer (un cookie), que le garde de la route a vérifié (cf.
// routeur.dart). Mêmes champs que le formulaire de l'ADMIN, mais le login
// et le mot de passe sont obligatoires, avec confirmation.
// =========================================================================

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

import '../api/rdvmedecins_api.dart';
import '../etat/messages.dart';
import '../i18n/traductions.dart';
import '../widgets/champ_saisie.dart';
import '../widgets/champs_personne.dart';
import '../widgets/envoi_formulaire.dart';
import '../widgets/titre_page.dart';

// longueur minimale d'un mot de passe choisi par un patient : même valeur
// que le serveur (qui renvoie aussi {min} avec l'erreur PASSWORD_TOO_SHORT)
const motDePasseMin = '6';

class PageInscription extends StatefulWidget {
  const PageInscription({super.key});

  @override
  State<PageInscription> createState() => _PageInscriptionState();
}

class _PageInscriptionState extends State<PageInscription> {
  late final _messages = context.read<Messages>();
  late final _envoi = EnvoiFormulaire(_messages);
  final Map<String, Object?> _saisie = {
    'titre': 'Mr',
    'prenom': '',
    'nom': '',
    'login': '',
    'password': '',
    'confirmation': '',
  };

  @override
  void dispose() {
    _envoi.dispose();
    super.dispose();
  }

  void _modifier(String champ, String valeur) {
    setState(() => _saisie[champ] = valeur);
    _envoi.effacerErreur(champ);
  }

  Future<void> _creer() async {
    final api = context.read<RdvMedecinsApi>();
    var login = '';
    final reussi = await _envoi.envoyer(() async => login = await api.inscrire(_saisie));
    if (!mounted) return;
    if (reussi) {
      // retour à l'écran de connexion, login pré-rempli
      _messages.flash(cle: 'INSCRIPTION.DONE', params: {'login': login});
      context.go(Uri(path: '/login', queryParameters: {'login': login}).toString());
    } else if (_messages.erreur?.cle == 'ERRORS.CAPTCHA_BEFORE_ACCOUNT') {
      // laissez-passer expiré (15 minutes) : retour au captcha
      _messages.flash(succes: false, cle: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
      context.go('/login');
    }
  }

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    // les paramètres du message d'aide ET des erreurs du mot de passe
    final paramsPassword = {'min': motDePasseMin, ..._envoi.params};
    return TitrePage(
      cle: 'INSCRIPTION.TITLE',
      child: ListenableBuilder(
        listenable: _envoi,
        builder: (context, _) => ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Text(t('INSCRIPTION.TITLE'), style: Theme.of(context).textTheme.headlineSmall),
            Text(t('INSCRIPTION.INTRO'), style: const TextStyle(color: Colors.grey)),
            const SizedBox(height: 8),
            ChampsPersonne(valeurs: _saisie, erreurs: _envoi.erreurs, onChanged: _modifier),
            const SizedBox(height: 16),
            Text(t('PERSONNES.ACCOUNT_SECTION'), style: Theme.of(context).textTheme.titleSmall),
            ChampSaisie(
              libelle: 'PERSONNES.LOGIN_LABEL',
              longueurMax: 30,
              valeur: _saisie['login'] as String,
              onChanged: (v) => _modifier('login', v),
              erreur: _envoi.erreurs['login'],
              aide: 'INSCRIPTION.LOGIN_HELP',
              autofill: const [AutofillHints.newUsername],
            ),
            ChampSaisie(
              libelle: 'PERSONNES.PASSWORD_LABEL',
              secret: true,
              longueurMax: 72,
              valeur: _saisie['password'] as String,
              onChanged: (v) => _modifier('password', v),
              erreur: _envoi.erreurs['password'],
              params: paramsPassword,
              aide: 'INSCRIPTION.PASSWORD_HELP',
              autofill: const [AutofillHints.newPassword],
            ),
            ChampSaisie(
              libelle: 'COMPTE.CONFIRMATION_LABEL',
              secret: true,
              longueurMax: 72,
              valeur: _saisie['confirmation'] as String,
              onChanged: (v) => _modifier('confirmation', v),
              erreur: _envoi.erreurs['confirmation'],
            ),
            const SizedBox(height: 16),
            Wrap(
              spacing: 8,
              children: [
                FilledButton(onPressed: _envoi.enCours ? null : _creer, child: Text(t('INSCRIPTION.SUBMIT'))),
                OutlinedButton(onPressed: () => context.go('/login'), child: Text(t('INSCRIPTION.BACK_TO_LOGIN'))),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

La création d'un compte de patient, accessible avec le laissez-passer délivré par l'écran de connexion (cf. le garde de la route, dans [routeur.dart]).

  • ligne 25 : la longueur minimale d'un mot de passe, pour le message d'aide : la même valeur que le serveur (qui la renvoie aussi avec l'erreur [PASSWORD_TOO_SHORT]) ;
  • lignes 57-69 : la création. Réussie : retour à l'écran de connexion, login pré-rempli ([/login?login=...]), avec un message flash. Refusée parce que le laissez-passer a expiré (15 minutes) : retour au captcha. Toute autre erreur reste affichée sous les champs ;
  • ligne 77 : les paramètres du message d'aide et de l'erreur du mot de passe ([{{min}}]) ;
  • lignes 98-109 : un NOUVEAU compte : le gestionnaire de mots de passe peut proposer un mot de passe robuste, et l'enregistrer.

11.13.7. lib/pages/espace_patient.dart

// =========================================================================
// lib/pages/espace_patient.dart - "Mon espace" (/mon-espace, rôle USER)
// -------------------------------------------------------------------------
// L'écran d'accueil du patient, juste après sa connexion :
//   - deux options : [Gérer mon compte] et [Voir un agenda] ;
//   - son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
// =========================================================================

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

import '../api/erreur_api.dart';
import '../api/rdvmedecins_api.dart';
import '../api/types.dart';
import '../etat/cache.dart';
import '../etat/messages.dart';
import '../formatage.dart';
import '../i18n/traductions.dart';
import '../widgets/confirmation.dart';
import '../widgets/titre_page.dart';
import 'agenda.dart';

class PageEspacePatient extends StatefulWidget {
  const PageEspacePatient({super.key});

  @override
  State<PageEspacePatient> createState() => _PageEspacePatientState();
}

class _PageEspacePatientState extends State<PageEspacePatient> {
  late final _api = context.read<RdvMedecinsApi>();
  late final _cache = context.read<Cache>();
  late final _messages = context.read<Messages>();
  Future<MonEspace>? _espace;

  @override
  void initState() {
    super.initState();
    _charger();
  }

  void _charger() {
    // des accolades : une fonction fléchée RENVERRAIT le Future affecté, et
    // setState refuse une fonction qui renvoie un Future
    setState(() {
      _espace = _cache.lire('mon-espace', _api.monEspace);
    });
    _espace!.then((_) {}, onError: (Object e) => _messages.afficherErreurApi(e));
  }

  Future<void> _annuler(int idRv) async {
    if (!await confirmer(context, 'AGENDA.CANCEL_CONFIRM')) return;
    try {
      await _api.annulerRv(idRv);
      _messages.afficherSucces('AGENDA.CANCELLED');
    } on ErreurApi catch (e) {
      _messages.afficherErreurApi(e);
    }
    // l'espace ET les agendas ont changé
    _cache.invalider('mon-espace');
    _cache.invalider('agenda');
    if (mounted) _charger();
  }

  @override
  Widget build(BuildContext context) {
    final traductions = context.watch<Traductions>();
    final t = traductions.t;
    return TitrePage(
      cle: 'ESPACE.TITLE',
      child: FutureBuilder<MonEspace>(
        future: _espace,
        builder: (context, etat) {
          final e = etat.data;
          if (e == null) return const Center(child: CircularProgressIndicator());
          return ListView(
            padding: const EdgeInsets.all(12),
            children: [
              Padding(
                padding: const EdgeInsets.all(4),
                child: Text(
                  t('ESPACE.HELLO', {'nom': e.patient.libelle}),
                  style: Theme.of(context).textTheme.headlineSmall,
                ),
              ),
              _Option(
                icone: Icons.manage_accounts,
                titre: t('ESPACE.MANAGE_ACCOUNT'),
                aide: t('ESPACE.MANAGE_ACCOUNT_HELP'),
                onTap: () => context.go('/mon-compte'),
              ),
              _Option(
                icone: Icons.calendar_month,
                titre: t('ESPACE.VIEW_AGENDA'),
                aide: t('ESPACE.VIEW_AGENDA_HELP'),
                onTap: () => context.go('/agenda'),
              ),
              const SizedBox(height: 8),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(t('ESPACE.MY_APPOINTMENT'), style: Theme.of(context).textTheme.titleMedium),
                      // normalement un seul (règle "un rendez-vous à venir à la
                      // fois"), mais l'écran sait en afficher plusieurs
                      for (final rv in e.rendezVous) ...[
                        const SizedBox(height: 8),
                        Text(
                          '${jourLong(rv.jour, traductions.langue)}, ${horaire(rv.creneau)}',
                          style: const TextStyle(fontWeight: FontWeight.w600),
                        ),
                        Text(rv.medecin.libelle),
                        Wrap(
                          spacing: 8,
                          children: [
                            // l'agenda du médecin ce jour-là : le patient y voit son nom
                            TextButton(
                              onPressed: () => context.go(adresseAgenda(rv.medecin.id, rv.jour)),
                              child: Text(t('ESPACE.SEE_IN_AGENDA')),
                            ),
                            OutlinedButton(
                              style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
                              onPressed: () => _annuler(rv.id),
                              child: Text(t('ESPACE.CANCEL_APPOINTMENT')),
                            ),
                          ],
                        ),
                      ],
                      if (e.rendezVous.isEmpty)
                        Padding(
                          padding: const EdgeInsets.only(top: 8),
                          child: Text(t('ESPACE.NO_APPOINTMENT'), style: const TextStyle(color: Colors.grey)),
                        ),
                    ],
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

// une "carte" d'option, qu'on touche pour ouvrir un écran
class _Option extends StatelessWidget {
  final IconData icone;
  final String titre;
  final String aide;
  final VoidCallback onTap;

  const _Option({required this.icone, required this.titre, required this.aide, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        leading: Icon(icone, size: 32, color: Theme.of(context).colorScheme.primary),
        title: Text(titre, style: Theme.of(context).textTheme.titleMedium),
        subtitle: Text(aide),
        trailing: const Icon(Icons.chevron_right),
        onTap: onTap,
      ),
    );
  }
}

L'accueil du patient : deux options, et son rendez-vous à venir.

  • lignes 52-63 : l'annulation de son rendez-vous : l'espace ET les agendas ont changé ;
  • lignes 109-127 : le rendez-vous, et deux boutons : voir l'agenda du médecin ce jour-là (le patient y voit son nom), annuler ;
  • lignes 150-166 : une option : une carte qu'on touche pour ouvrir un écran.

11.13.8. lib/pages/mon_compte.dart

// =========================================================================
// lib/pages/mon_compte.dart - "Gérer mon compte" (/mon-compte, rôle USER)
// -------------------------------------------------------------------------
// Deux formulaires indépendants :
//   1. la modification (identité, login, nouveau mot de passe facultatif) ;
//   2. la suppression du compte - qui annule aussi ses rendez-vous.
// L'un comme l'autre exigent le mot de passe ACTUEL (vérifié par le serveur).
// Deux formulaires, donc deux EnvoiFormulaire (deux jeux d'erreurs). Comme
// pour le formulaire des personnes, la modification n'est affichée qu'une
// fois le compte lu, et démarre avec ses valeurs.
// =========================================================================

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

import '../api/rdvmedecins_api.dart';
import '../api/types.dart';
import '../etat/cache.dart';
import '../etat/messages.dart';
import '../etat/session.dart';
import '../i18n/traductions.dart';
import '../widgets/champ_saisie.dart';
import '../widgets/champs_personne.dart';
import '../widgets/confirmation.dart';
import '../widgets/envoi_formulaire.dart';
import '../widgets/titre_page.dart';

class PageMonCompte extends StatefulWidget {
  const PageMonCompte({super.key});

  @override
  State<PageMonCompte> createState() => _PageMonCompteState();
}

class _PageMonCompteState extends State<PageMonCompte> {
  // relu à chaque visite, sans cache : sa version doit être à jour
  late final Future<MonCompte> _compte = context.read<RdvMedecinsApi>().monCompte();

  @override
  Widget build(BuildContext context) {
    return TitrePage(
      cle: 'MON_COMPTE.TITLE',
      child: ListView(
        padding: const EdgeInsets.all(12),
        children: [
          FutureBuilder<MonCompte>(
            future: _compte,
            builder: (context, etat) => etat.hasData
                ? _Modification(compte: etat.data!)
                : const Padding(
                    padding: EdgeInsets.all(24),
                    child: Center(child: CircularProgressIndicator()),
                  ),
          ),
          const _Suppression(),
        ],
      ),
    );
  }
}

// -------------------------------------------------------------------------
// 1. la modification
// -------------------------------------------------------------------------
class _Modification extends StatefulWidget {
  final MonCompte compte;

  const _Modification({required this.compte});

  @override
  State<_Modification> createState() => _ModificationState();
}

class _ModificationState extends State<_Modification> {
  late final _messages = context.read<Messages>();
  late final _envoi = EnvoiFormulaire(_messages);
  late final Map<String, Object?> _saisie = {
    'titre': widget.compte.titre,
    'prenom': widget.compte.prenom,
    'nom': widget.compte.nom,
    'login': widget.compte.login,
    'password': '',
    'confirmation': '',
    'motDePasseActuel': '',
    'version': widget.compte.version,
  };

  @override
  void dispose() {
    _envoi.dispose();
    super.dispose();
  }

  void _modifier(String champ, String valeur) {
    setState(() => _saisie[champ] = valeur);
    _envoi.effacerErreur(champ);
  }

  Future<void> _enregistrer() async {
    final api = context.read<RdvMedecinsApi>();
    final session = context.read<Session>();
    final cache = context.read<Cache>();
    final reussi = await _envoi.envoyer(() async {
      // le serveur a délivré un NOUVEAU jeton (nom, login) : la barre du haut doit suivre
      session.mettreAJour(await api.modifierMonCompte(_saisie));
      _messages.flash(cle: 'MON_COMPTE.UPDATED');
    });
    if (!mounted) return;
    if (reussi) {
      cache.invalider('mon-espace');
      context.go('/mon-espace');
    } else {
      // jamais de mot de passe laissé dans le champ après un envoi refusé ;
      // on change la valeur SANS passer par _modifier(), pour ne pas effacer
      // l'erreur que le serveur vient d'y attacher
      setState(() => _saisie['motDePasseActuel'] = '');
    }
  }

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    return ListenableBuilder(
      listenable: _envoi,
      builder: (context, _) => Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(t('MON_COMPTE.TITLE'), style: Theme.of(context).textTheme.titleLarge),
              ChampsPersonne(valeurs: _saisie, erreurs: _envoi.erreurs, onChanged: _modifier),
              const SizedBox(height: 12),
              Text(t('PERSONNES.ACCOUNT_SECTION'), style: Theme.of(context).textTheme.titleSmall),
              ChampSaisie(
                libelle: 'PERSONNES.LOGIN_LABEL',
                longueurMax: 30,
                valeur: _saisie['login'] as String,
                onChanged: (v) => _modifier('login', v),
                erreur: _envoi.erreurs['login'],
              ),
              ChampSaisie(
                libelle: 'MON_COMPTE.NEW_PASSWORD_LABEL',
                secret: true,
                longueurMax: 72,
                valeur: _saisie['password'] as String,
                onChanged: (v) => _modifier('password', v),
                erreur: _envoi.erreurs['password'],
                params: _envoi.params,
                aide: 'PERSONNES.PASSWORD_HELP_EDIT',
              ),
              ChampSaisie(
                libelle: 'COMPTE.CONFIRMATION_LABEL',
                secret: true,
                longueurMax: 72,
                valeur: _saisie['confirmation'] as String,
                onChanged: (v) => _modifier('confirmation', v),
                erreur: _envoi.erreurs['confirmation'],
              ),
              ChampSaisie(
                libelle: 'MON_COMPTE.CURRENT_PASSWORD_LABEL',
                secret: true,
                longueurMax: 72,
                valeur: _saisie['motDePasseActuel'] as String,
                onChanged: (v) => _modifier('motDePasseActuel', v),
                erreur: _envoi.erreurs['motDePasseActuel'],
                aide: 'MON_COMPTE.CURRENT_PASSWORD_HELP',
              ),
              const SizedBox(height: 8),
              Wrap(
                spacing: 8,
                children: [
                  FilledButton(onPressed: _envoi.enCours ? null : _enregistrer, child: Text(t('PERSONNES.SAVE'))),
                  OutlinedButton(onPressed: () => context.go('/mon-espace'), child: Text(t('MON_COMPTE.BACK'))),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// -------------------------------------------------------------------------
// 2. la suppression : un formulaire d'un seul champ
// -------------------------------------------------------------------------
class _Suppression extends StatefulWidget {
  const _Suppression();

  @override
  State<_Suppression> createState() => _SuppressionState();
}

class _SuppressionState extends State<_Suppression> {
  late final _messages = context.read<Messages>();
  late final _envoi = EnvoiFormulaire(_messages);
  String _motDePasseActuel = '';

  @override
  void dispose() {
    _envoi.dispose();
    super.dispose();
  }

  Future<void> _supprimer() async {
    final api = context.read<RdvMedecinsApi>();
    final session = context.read<Session>();
    if (!await confirmer(context, 'MON_COMPTE.DELETE_CONFIRM')) return;
    final reussi = await _envoi.envoyer(() => api.supprimerMonCompte(_motDePasseActuel));
    if (reussi) {
      // le message d'abord : il s'affichera sur l'écran de connexion, où le
      // routeur nous emmène dès que la session est vide
      _messages.flash(cle: 'MON_COMPTE.DELETED');
      session.oublier(volontaire: true); // le serveur a effacé le cookie du jeton
    }
  }

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    return ListenableBuilder(
      listenable: _envoi,
      builder: (context, _) => Card(
        shape: RoundedRectangleBorder(
          side: BorderSide(color: Colors.red.shade300),
          borderRadius: BorderRadius.circular(12),
        ),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(
                t('MON_COMPTE.DELETE_TITLE'),
                style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.red.shade700),
              ),
              Text(t('MON_COMPTE.DELETE_HELP'), style: const TextStyle(color: Colors.grey)),
              ChampSaisie(
                libelle: 'MON_COMPTE.CURRENT_PASSWORD_LABEL',
                secret: true,
                longueurMax: 72,
                valeur: _motDePasseActuel,
                onChanged: (v) {
                  setState(() => _motDePasseActuel = v);
                  _envoi.effacerErreur('motDePasseActuel');
                },
                erreur: _envoi.erreurs['motDePasseActuel'],
              ),
              Align(
                alignment: Alignment.centerLeft,
                child: FilledButton(
                  style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
                  onPressed: _envoi.enCours ? null : _supprimer,
                  child: Text(t('MON_COMPTE.DELETE_SUBMIT')),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

« Gérer mon compte » : deux formulaires indépendants, chacun avec son [EnvoiFormulaire].

  • ligne 38 : le compte, relu à chaque visite, sans cache (sa version doit être à jour) ;
  • lignes 100-117 : la modification. Le serveur renvoie l'utilisateur modifié (et un nouveau jeton, dans son cookie) : la session est mise à jour, et le nom affiché dans le menu avec elle. Après un refus, le mot de passe actuel est effacé du champ ;
  • lignes 207-216 : la suppression du compte, après confirmation. Le message d'abord (un message flash) ; puis la session se vide, et le routeur affiche l'écran de connexion, avec le message.

11.13.9. lib/pages/page_erreur.dart

// =========================================================================
// lib/pages/page_erreur.dart - l'écran d'erreur : 404 (adresse inconnue) ou
// 403 (rôle insuffisant, cf. routeur.dart)
// =========================================================================

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

import '../i18n/traductions.dart';
import '../widgets/titre_page.dart';

class PageErreur extends StatelessWidget {
  final int code; // 403 ou 404

  const PageErreur({super.key, required this.code});

  @override
  Widget build(BuildContext context) {
    final t = context.watch<Traductions>().t;
    return TitrePage(
      cle: 'ERROR_PAGE.TITLE',
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '${t('ERROR_PAGE.TITLE')} $code',
              style: Theme.of(context).textTheme.headlineMedium!.copyWith(color: Colors.red.shade700),
            ),
            const SizedBox(height: 8),
            Text(t(code == 403 ? 'ERRORS.FORBIDDEN' : 'ERRORS.NOT_FOUND')),
            const SizedBox(height: 16),
            FilledButton(onPressed: () => context.go('/'), child: Text(t('ERROR_PAGE.BACK'))),
          ],
        ),
      ),
    );
  }
}

La page d'erreur : 404 (adresse inconnue) ou 403 (rôle insuffisant). « Retour à l'accueil » mène à [/], que le routeur redirige vers l'accueil du rôle.