9. Dialoguer avec le serveur
Le serveur JSON [RdvMedecins] doit maintenant être lancé (port 8080), comme expliqué au chapitre précédent.
9.1. Exemple 22 : une première requête HTTP
9.1.1. Où est le serveur ?
Pour un client web servi par le serveur lui-même, la question ne se pose pas : les requêtes partent vers la même origine que la page, avec une adresse relative ([/api/captcha]). Une application mobile n'a pas d'origine : il faut lui donner l'adresse complète du serveur. Et cette adresse dépend de l'endroit où tourne l'application :
| // =========================================================================
// lib/commun/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';
}
|
- ligne 24 : une adresse donnée au lancement de l'application, par l'option [--dart-define] : [flutter run --dart-define=SERVEUR=http://192.168.1.20:8080 ...]. [String.fromEnvironment] est évaluée à la compilation : c'est une constante, '' si l'option n'est pas donnée ;
- lignes 29-35 : sinon, une adresse par défaut, selon la plateforme. [kIsWeb] est vrai si l'application tourne dans un navigateur ; [defaultTargetPlatform] dit sur quel système elle tourne ([TargetPlatform.android], [TargetPlatform.windows]...) :
- sur le web : une adresse relative (''), la même origine que l'application ;
- sur l'émulateur [Android] : [http://10.0.2.2:8080]. L'émulateur est une machine à part entière, avec son propre réseau : pour lui, [localhost] c'est l'émulateur lui-même. L'adresse spéciale [10.0.2.2] désigne l'ordinateur qui le fait tourner ;
- sur un ordinateur ([Windows]...) : [http://localhost:8080].
Un vrai téléphone, relié au même réseau Wi-Fi que l'ordinateur du serveur, doit utiliser l'adresse de l'ordinateur sur ce réseau (la commande [ipconfig] de [Windows] la donne) : on la passe par [--dart-define]. Il faut aussi que le pare-feu de l'ordinateur laisse passer les connexions entrantes sur le port 8080.
Deux réglages du projet [Android] sont nécessaires, dans le fichier [android/app/src/main/AndroidManifest.xml] :
| <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- l'accès au réseau (le serveur JSON) -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="exemples_flutter"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
|
- ligne 3 : la permission d'utiliser le réseau. En développement, [Flutter] l'ajoute d'office (pour le rechargement à chaud) ; il faut la déclarer pour l'application livrée ;
- ligne 8 : depuis la version 9 d'[Android], une application n'a le droit de communiquer qu'en HTTPS. Notre serveur de développement parle HTTP (« en clair ») : on l'autorise. En production, le serveur serait en HTTPS et cette ligne disparaîtrait.
Et pour le web : le serveur de développement de [Flutter] ([flutter run -d chrome]) sert l'application sur son propre port ; le serveur JSON, lui, est sur le port 8080. Ce sont deux origines différentes : le navigateur refuserait de joindre le cookie aux requêtes, et même d'en lire les réponses (le serveur n'autorise pas les requêtes d'une autre origine, dites « CORS »). Le fichier [web_dev_config.yaml] règle le serveur de développement web de [Flutter] (depuis la version 3.38) :
| # ==========================================================================
# 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). Exemples 22 à 25.
# ==========================================================================
server:
host: localhost
port: 5173
proxy:
- target: "http://localhost:8080/"
prefix: "/api/"
|
- ligne 12 : l'application web est servie sur le port 5173 ;
- lignes 13-15 : un proxy : toute requête vers une adresse commençant par [/api/] est transmise au serveur JSON (port 8080), et sa réponse renvoyée au navigateur. Pour le navigateur, l'application et l'API ont la même origine. C'est exactement le rôle du proxy de [Vite] dans le cours [React].
9.1.2. La requête
| // =========================================================================
// lib/ex22_http/main.dart - une première requête HTTP vers le serveur
// -------------------------------------------------------------------------
// Le serveur JSON RdvMedecins doit être lancé (port 8080). On lui demande
// un captcha : GET /api/captcha -> { "id": "...", "svg": "<svg ...>" }.
//
// - http.get(uri) : la fonction du paquet http qui envoie une requête
// GET. Elle renvoie un Future de la réponse (http.Response) : son code
// de statut (statusCode), ses entêtes (headers), son corps (body) ;
// - jsonDecode(texte) : le corps JSON -> des objets Dart (Map, List...) ;
// - l'adresse du serveur dépend de l'endroit où tourne l'application
// (émulateur Android, téléphone, navigateur...) : cf. commun/config.dart ;
// - trois états à afficher : en cours, réussi, en erreur ;
// - l'image du captcha est du SVG : le paquet flutter_svg sait la dessiner.
// =========================================================================
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
import '../commun/config.dart';
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: const PageCaptcha(),
),
);
}
class PageCaptcha extends StatefulWidget {
const PageCaptcha({super.key});
@override
State<PageCaptcha> createState() => _PageCaptchaState();
}
class _PageCaptchaState extends State<PageCaptcha> {
// ce que renvoie GET /api/captcha : un record
({String id, String svg})? _captcha;
bool _enCours = true;
String? _erreur;
int _nbRequetes = 0;
int _numeroDemande = 0; // le numéro de la dernière demande
@override
void initState() {
super.initState();
_charger(); // la première requête, dès l'apparition de l'écran
}
Future<void> _charger() async {
final numero = ++_numeroDemande;
setState(() {
_enCours = true;
_erreur = null;
});
try {
final reponse = await http.get(Uri.parse('${Config.urlServeur}/api/captcha'));
// http.get ne lance PAS d'exception pour une réponse d'erreur (4xx,
// 5xx) : on teste le code de statut
if (reponse.statusCode != 200) {
throw Exception('le serveur a répondu ${reponse.statusCode} ${reponse.reasonPhrase}');
}
final json = jsonDecode(reponse.body) as Map<String, dynamic>;
// la réponse d'une demande DÉPASSÉE (on a redemandé entre-temps), ou
// arrivée après la fermeture de l'écran : on l'ignore
if (!mounted || numero != _numeroDemande) return;
setState(() {
_captcha = (id: json['id'] as String, svg: json['svg'] as String);
_nbRequetes++;
_enCours = false;
});
} catch (e) {
// pas de réponse du tout (serveur arrêté, mauvaise adresse...), ou erreur
if (!mounted || numero != _numeroDemande) return;
setState(() {
_erreur = '$e';
_captcha = null;
_enCours = false;
});
}
}
@override
Widget build(BuildContext context) {
final captcha = _captcha;
return Scaffold(
appBar: AppBar(title: const Text('Exemple 22 - une première requête HTTP')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Serveur : ${Config.urlServeur.isEmpty ? '(même origine)' : Config.urlServeur}',
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 12),
if (_enCours) const LinearProgressIndicator(),
if (_erreur != null)
Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
"Impossible d'obtenir un captcha : $_erreur.\n"
'Le serveur JSON RdvMedecins est-il lancé (port 8080) ?',
),
),
),
if (captcha != null && !_enCours) ...[
// SvgPicture.string : dessiner une image SVG donnée sous forme de texte
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: SvgPicture.string(captcha.svg, height: 60),
),
const SizedBox(height: 8),
Text('id : ${captcha.id}', style: const TextStyle(fontFamily: 'RobotoMono', fontSize: 11)),
],
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(onPressed: _enCours ? null : _charger, child: const Text('Autre image')),
),
const SizedBox(height: 8),
Text('$_nbRequetes requête(s) réussie(s)', style: const TextStyle(color: Colors.grey)),
],
),
);
}
}
|
- ligne 21 : le paquet [http], le client HTTP de l'équipe de [Dart]. [as http] : ses fonctions s'utilisent préfixées ([http.get], [http.Response]), ce qui évite de confondre ses noms avec d'autres ;
- lignes 44)? _captcha;}-48 : l'état : le captcha reçu (un record, ou [null]), les trois états d'une requête (en cours, en erreur, réussie), le nombre de requêtes réussies ;
- lignes 51-53 : la première requête part dès l'apparition de l'écran ;
- lignes 56-84 : la requête :
- ligne 63 : [http.get] envoie une requête GET et renvoie un [Future<http.Response>] : la réponse, plus tard. [Uri.parse] fabrique l'adresse à partir d'une chaîne ;
- lignes 66-67 : [http.get] ne lance PAS d'exception pour une réponse d'erreur (4xx, 5xx) : c'est une réponse comme une autre. On teste son code de statut ;
- ligne 69 : [reponse.body] est le corps de la réponse, du texte ; [jsonDecode] le convertit en objets [Dart] : ici une [Map] ;
- ligne 72 : la réponse d'une demande DÉPASSÉE (on a redemandé une image entre-temps), ou arrivée après la fermeture de l'écran, est ignorée (cf. exemple 19) ;
- ligne 74 : le JSON est typé [dynamic] : on convertit chaque valeur dans son type, avec [as] ;
- lignes {L:} catch (e) {}-82 : aucune réponse du tout (serveur arrêté, mauvaise adresse, réseau coupé) : [http.get] lance une exception, qu'on attrape, et qu'on affiche ;
- ligne 102 : une barre de progression pendant la requête ;
- ligne 122 : l'image du captcha est du SVG, sous forme de texte : [SvgPicture.string] (paquet [flutter_svg]) la dessine. Elle est fabriquée par NOTRE serveur, et ne contient rien qui vienne d'un utilisateur ;
- ligne 130 : « Autre image », désactivé pendant une requête.
Sur l'émulateur [Android] ; puis le serveur JSON étant arrêté :
L'outil [Flutter DevTools] (lancé par [VS Code], ou par l'adresse que donne [flutter run]) a un onglet Réseau (« Network ») qui montre chaque requête HTTP de l'application, ses entêtes et son corps, comme les outils de développement d'un navigateur.
9.2. Exemple 23 : connexion, cookie et session
9.2.1. Un client HTTP qui gère les cookies
Le serveur dépose le jeton JWT dans un cookie. Dans un navigateur, c'est le navigateur qui range les cookies et les renvoie : 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.
| // =========================================================================
// lib/commun/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();
}
|
- ligne 25 : le paquet [http] permet d'envelopper son client : on hérite de [http.BaseClient], et il suffit d'écrire la méthode [send], par laquelle passent toutes les requêtes ([get], [post]...) ;
- lignes 28-31 : le client enveloppé, le stockage de l'appareil (facultatif : [?]), et les cookies (nom -> valeur) ;
- lignes 33-37 : à la création, les cookies mémorisés lors d'une utilisation précédente de l'application. On reste ainsi connecté quand on arrête puis relance l'application : c'est l'équivalent, sur un téléphone, du navigateur qui garde ses cookies d'une visite à l'autre ;
- lignes 42-52 : chaque requête reçoit l'entête [Cookie] (« rdvmedecins_jwt=eyJhbGci...; autre=valeur »), puis on lit l'entête [Set-Cookie] de la réponse. [kIsWeb] : sur le web, on ne fait rien de plus que le client ordinaire, car le navigateur interdit à l'application de lire ou d'écrire ces entêtes (il s'en charge lui-même) ;
- lignes 58-69 : l'entête [Set-Cookie]. Plusieurs cookies y sont séparés par des virgules, mais la date [Expires] contient elle-même une virgule (« Tue, 29 Sep 2026 ») : l'expression régulière ne retient qu'un nom suivi de [=], en début d'entête ou après une virgule. On ne garde que le nom et la valeur de chaque cookie ; une valeur vide (déconnexion : le serveur envoie [rdvmedecins_jwt=; Expires=Thu, 01 Jan 1970...]) supprime le cookie. [_stockage?.setString(...)] : l'appel n'a lieu que si [_stockage] n'est pas [null].
Une gestion de cookies plus complète (domaines, chemins, dates d'expiration) existe dans des paquets ([cookie_jar], avec le client [dio]) ; la nôtre, de quelques lignes, suffit pour UN serveur. Remarque de sécurité : le jeton est ici rangé en clair sur l'appareil. Une application de production utiliserait le paquet [flutter_secure_storage] (cf. exemple 20).
9.2.2. Une commande
| // =========================================================================
// lib/ex23_connexion/commande.dart - une ACTION, son état, son résultat
// -------------------------------------------------------------------------
// Une action asynchrone (se connecter, enregistrer...) a un état que
// l'écran doit montrer : est-elle EN COURS (bouton désactivé, indicateur) ?
// s'est-elle terminée par une ERREUR (message) ? C'est le modèle "Command"
// du guide d'architecture de Flutter, réduit à l'essentiel :
//
// final connexion = Commande();
// connexion.executer(() => seConnecter(...)); // lance l'action
// connexion.enCours, connexion.erreur // son état
//
// Une Commande est un ChangeNotifier : l'écran l'écoute (ListenableBuilder)
// et se redessine quand elle démarre et quand elle se termine.
// =========================================================================
import 'package:flutter/foundation.dart';
class Commande extends ChangeNotifier {
bool _enCours = false;
Object? _erreur;
bool get enCours => _enCours;
Object? get erreur => _erreur;
// exécute l'action, si une autre n'est pas déjà en cours
Future<void> executer(Future<void> Function() action) async {
if (_enCours) return; // pas de double envoi (double appui sur le bouton)
_enCours = true;
_erreur = null;
notifyListeners();
try {
await action();
} catch (e) {
_erreur = e; // l'erreur est GARDÉE, pour être affichée
} finally {
// finally : exécuté dans TOUS les cas, succès ou erreur
_enCours = false;
notifyListeners();
}
}
void effacerErreur() {
_erreur = null;
notifyListeners();
}
}
|
Une action asynchrone (se connecter, enregistrer...) a un état que l'écran doit montrer : est-elle en cours (bouton désactivé) ? s'est-elle terminée par une erreur (message) ? C'est le modèle « Command » du guide d'architecture de [Flutter], réduit à l'essentiel. Le cours [React] utilisait pour cela les « actions » de [React] 19 ([useActionState]).
- lignes 27-39 : exécuter une action : si une autre est en cours, on ne fait rien (pas de double envoi, si l'on appuie deux fois sur le bouton) ; sinon, la commande passe « en cours » et prévient ses écouteurs ; en cas d'exception, l'erreur est gardée, pour être affichée ; dans tous les cas ([finally]), la commande n'est plus en cours.
9.2.3. L'écran
| // =========================================================================
// lib/ex23_connexion/main.dart - se connecter au serveur RdvMedecins
// -------------------------------------------------------------------------
// 1. au démarrage : GET /api/auth/moi - sommes-nous déjà connectés ?
// (l'application a pu être arrêtée ; le serveur, lui, lit le cookie
// qu'on lui présente) ;
// 2. connexion : POST /api/auth/login, corps JSON { login, password,
// captchaId, captcha }. En cas de succès, le serveur envoie le cookie
// du jeton JWT (entête Set-Cookie) ;
// 3. requête protégée : GET /api/medecins. Le cookie l'accompagne ;
// 4. déconnexion : POST /api/auth/logout - le serveur efface le cookie.
// Sur le téléphone, c'est ClientAvecCookies (commun/client_http.dart) qui
// range le cookie et le renvoie ; sur le web, c'est le navigateur.
// =========================================================================
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../commun/client_http.dart';
import '../commun/config.dart';
import 'commande.dart';
// les quelques messages dont cet exemple a besoin (l'exemple 25 utilisera des dictionnaires)
const messages = {
'LOGIN.ERROR': 'Login ou mot de passe incorrect.',
'ERRORS.WRONG_CAPTCHA': "Les caractères recopiés ne correspondent pas à l'image.",
'ERRORS.TOO_MANY_ATTEMPTS': 'Trop de tentatives infructueuses. Réessayez plus tard.',
};
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final client = ClientAvecCookies(await SharedPreferences.getInstance());
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: PageConnexion(client: client),
),
);
}
// une réponse qui n'est pas un 2xx devient une exception, qui garde la réponse
class ReponseEnErreur implements Exception {
final http.Response reponse;
const ReponseEnErreur(this.reponse);
}
class PageConnexion extends StatefulWidget {
final http.Client client;
const PageConnexion({super.key, required this.client});
@override
State<PageConnexion> createState() => _PageConnexionState();
}
class _PageConnexionState extends State<PageConnexion> {
Map<String, dynamic>? _utilisateur; // null : personne n'est connecté
List<dynamic> _medecins = [];
Map<String, dynamic>? _captcha;
bool _pret = false; // la vérification de départ est-elle terminée ?
String? _erreurServeur;
final _connexion = Commande();
final _login = TextEditingController(text: 'admin');
final _password = TextEditingController();
final _reponseCaptcha = TextEditingController();
@override
void initState() {
super.initState();
_verifierSession();
}
@override
void dispose() {
_connexion.dispose();
_login.dispose();
_password.dispose();
_reponseCaptcha.dispose();
super.dispose();
}
Uri _url(String chemin) => Uri.parse('${Config.urlServeur}/api$chemin');
// une requête GET dont on veut le corps JSON ; une exception si ce n'est pas un 2xx
Future<dynamic> _lire(String chemin) async {
final reponse = await widget.client.get(_url(chemin));
if (reponse.statusCode >= 300) throw ReponseEnErreur(reponse);
return jsonDecode(utf8.decode(reponse.bodyBytes));
}
// un POST avec un corps JSON
Future<http.Response> _poster(String chemin, Object corps) {
return widget.client.post(
_url(chemin),
headers: {'content-type': 'application/json'}, // le corps est du JSON
body: jsonEncode(corps),
);
}
Future<void> _chargerCaptcha() async {
final captcha = await _lire('/captcha') as Map<String, dynamic>;
_reponseCaptcha.clear();
if (mounted) setState(() => _captcha = captcha);
}
Future<void> _chargerMedecins() async {
final medecins = await _lire('/medecins') as List<dynamic>;
if (mounted) setState(() => _medecins = medecins);
}
// 1. qui est connecté ? 200 -> l'utilisateur ; 401 -> personne
Future<void> _verifierSession() async {
try {
final utilisateur = await _lire('/auth/moi') as Map<String, dynamic>;
setState(() => _utilisateur = utilisateur);
await _chargerMedecins();
} on ReponseEnErreur catch (e) {
// "on Type catch" : n'attrape QUE ce type d'exception
if (e.reponse.statusCode == 401) {
await _chargerCaptcha();
} else {
_erreurServeur = 'Le serveur a répondu ${e.reponse.statusCode}.';
}
} catch (_) {
_erreurServeur = 'Le serveur JSON RdvMedecins est-il lancé (${Config.urlServeur}) ?';
}
if (mounted) setState(() => _pret = true);
}
// 2. la connexion : une action exécutée par la Commande, qui mémorise son erreur
Future<void> _seConnecter() async {
final reponse = await _poster('/auth/login', {
'login': _login.text.trim(),
'password': _password.text,
'captchaId': _captcha?['id'],
'captcha': _reponseCaptcha.text,
});
if (reponse.statusCode != 200) {
// le corps JSON de la réponse d'erreur : { statusCode, cle, ... }
final erreur = jsonDecode(reponse.body) as Map<String, dynamic>;
final cle = erreur['cle'] as String;
await _chargerCaptcha(); // chaque image ne sert qu'une fois
throw '${reponse.statusCode} - ${messages[cle] ?? cle}'; // on peut lancer... une chaîne
}
_password.clear();
setState(() => _utilisateur = jsonDecode(reponse.body) as Map<String, dynamic>);
await _chargerMedecins();
}
// 4. la déconnexion
Future<void> _seDeconnecter() async {
await _poster('/auth/logout', {});
setState(() {
_utilisateur = null;
_medecins = [];
});
await _chargerCaptcha();
}
@override
Widget build(BuildContext context) {
final Widget contenu;
final utilisateur = _utilisateur;
if (!_pret) {
contenu = const Text('Vérification de la session...', style: TextStyle(color: Colors.grey));
} else if (utilisateur != null) {
// connecté
contenu = Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
const TextSpan(text: 'Connecté : '),
TextSpan(
text: '${utilisateur['nom']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: ' (${utilisateur['login']}), rôle ${utilisateur['role']}'),
],
),
),
const SizedBox(height: 8),
Text('GET /api/medecins', style: Theme.of(context).textTheme.titleSmall),
for (final m in _medecins) Text('• ${m['titre']} ${m['prenom']} ${m['nom']}'),
const SizedBox(height: 8),
const Text(
"Arrêtez puis relancez l'application : vous restez connecté.",
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 8),
OutlinedButton(onPressed: _seDeconnecter, child: const Text('Se déconnecter')),
],
),
),
);
} else if (_erreurServeur == null) {
// pas connecté : le formulaire
contenu = Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _login,
decoration: const InputDecoration(labelText: 'Login'),
),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Mot de passe'),
),
const SizedBox(height: 12),
Row(
children: [
if (_captcha != null) SvgPicture.string('${_captcha!['svg']}', height: 50),
TextButton(onPressed: _chargerCaptcha, child: const Text('Autre image')),
],
),
TextField(
controller: _reponseCaptcha,
autocorrect: false,
decoration: const InputDecoration(labelText: "Recopiez les caractères de l'image"),
),
const SizedBox(height: 16),
// ListenableBuilder : ce morceau suit l'état de la commande
ListenableBuilder(
listenable: _connexion,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FilledButton(
onPressed: _connexion.enCours ? null : () => _connexion.executer(_seConnecter),
child: Text(_connexion.enCours ? 'Connexion...' : 'Se connecter'),
),
if (_connexion.erreur != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text('${_connexion.erreur}', style: const TextStyle(color: Colors.red)),
),
],
),
),
const SizedBox(height: 8),
const Text(
'Comptes : admin, pelissier, martin... (mot de passe = login).',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
);
} else {
contenu = const SizedBox.shrink(); // un widget "vide"
}
return Scaffold(
appBar: AppBar(title: const Text('Exemple 23 - connexion au serveur')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
contenu,
if (_erreurServeur != null)
Card(
color: Colors.red.shade50,
child: Padding(padding: const EdgeInsets.all(12), child: Text(_erreurServeur!)),
),
],
),
);
}
}
|
- lignes 36-41 : le client HTTP, qui mémorise ses cookies sur l'appareil, est créé au démarrage et passé à l'écran ;
- lignes 47-49 : une exception qui garde la réponse en erreur (on y lira son code de statut). [implements Exception] : un objet qu'on peut lancer ([throw]) ;
- lignes 90-93 : une requête GET dont on veut le corps JSON, et une exception si ce n'est pas un 2xx. [widget.client.get(...)] : le client aux cookies. [utf8.decode(reponse.bodyBytes)] : le corps décodé en UTF-8 (les accents), quoi que dise l'entête de la réponse ;
- lignes 97-101 : un POST : l'entête [content-type: application/json] et le corps en JSON (le serveur refuse un POST qui n'est pas en JSON) ;
- lignes 117-132 : 1. au démarrage : qui est connecté ? [GET /api/auth/moi] : 200, l'utilisateur ; 401, personne. [on ReponseEnErreur catch (e)] n'attrape QUE ce type d'exception ; le [catch] suivant, toutes les autres (pas de réponse du tout) ;
- lignes 136-152 : 2. la connexion : [POST /api/auth/login]. En cas de refus, le corps de la réponse d'erreur donne la clé du message ; on charge une nouvelle image (chacune ne sert qu'une fois), puis on lance le message : en [Dart], on peut lancer n'importe quel objet, ici une chaîne. C'est la commande qui l'attrape et le garde ;
- lignes 156-162 : 4. la déconnexion : [POST /api/auth/logout] ; le serveur efface le cookie (et le client aux cookies aussi) ;
- lignes 193-197 : 3. connecté : la liste des médecins ([GET /api/medecins], une requête protégée : le cookie l'accompagne) ;
- lignes 225-226 : le captcha et son bouton ;
- lignes 236-248 : le bouton suit l'état de la commande : désactivé et « Connexion... » pendant l'envoi ; l'erreur éventuelle en dessous.
L'écran de connexion ; un mauvais mot de passe ; la connexion réussie :
Arrêtez puis relancez l'application : elle démarre connectée. Le cookie, mémorisé sur l'appareil, est présenté à [GET /api/auth/moi], et le serveur répond par l'utilisateur. Le jeton expire au bout de deux heures : [GET /api/auth/moi] répondra alors 401, et l'écran de connexion reviendra.
9.3. Exemple 24 : une couche d'accès à l'API
Dans l'exemple 23, chaque requête répétait les mêmes gestes. Cet exemple les range dans trois couches, que l'étude de cas reprendra.
9.3.1. Les types
| // =========================================================================
// lib/commun/types.dart - les objets JSON échangés avec l'API RdvMedecins
// (cf. le chapitre sur la boîte noire, qui décrit chaque route)
// -------------------------------------------------------------------------
// Le JSON décodé n'est pas typé : des Map<String, dynamic>, des List...
// Chaque classe a un constructeur "factory" depuisJson qui VÉRIFIE la forme
// du JSON reçu, par le "filtrage" de Dart 3 (pattern matching) :
//
// switch (json) {
// {'login': String login, 'nom': String nom} => ... // la forme attendue
// _ => throw FormatException(...) // tout le reste
// }
//
// Le motif {'login': String login, ...} vérifie que json est une Map, que
// ces clés existent, que leurs valeurs ont le bon type, et range ces
// valeurs dans des variables. Une réponse inattendue est détectée
// immédiatement, et non plus tard, au milieu de l'affichage.
// =========================================================================
enum Role { admin, doctor, user }
// "ADMIN" -> Role.admin
Role _role(String nom) => Role.values.byName(nom.toLowerCase());
// POST /api/auth/login, GET /api/auth/moi
class Utilisateur {
final String login;
final String nom;
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),
),
_ => throw FormatException('Utilisateur inattendu : $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),
_ => throw FormatException('Captcha inattendu : $json'),
};
}
// un médecin ou un client (GET /api/medecins, GET /api/clients...)
class Personne {
final int id;
final int version;
final String titre;
final String prenom;
final String nom;
final String? login; // seulement pour l'ADMIN
const Personne(this.id, this.version, this.titre, this.prenom, this.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,
} =>
// la clé "login" est facultative : on la lit à part
Personne(id, version, titre, prenom, nom, (json as Map<String, Object?>)['login'] as String?),
_ => throw FormatException('Personne inattendue : $json'),
};
// une liste JSON -> une liste de personnes
static List<Personne> liste(Object? json) => [for (final p in json as List<Object?>) Personne.depuisJson(p)];
String get nomComplet => '$titre $prenom $nom';
}
// le nom d'une personne, sans rien d'autre
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,
),
_ => throw FormatException('Identité inattendue : $json'),
};
@override
String toString() => '$titre $prenom $nom';
}
// une ligne de GET /api/agenda
class CreneauAgenda {
final int id;
final int hdebut;
final int mdebut;
final int hfin;
final int mfin;
final bool libre;
final Identite? patient;
final bool moi;
final bool reservable;
final int? idRv;
const CreneauAgenda(
this.id,
this.hdebut,
this.mdebut,
this.hfin,
this.mfin,
this.libre,
this.patient,
this.moi,
this.reservable,
this.idRv,
);
factory CreneauAgenda.depuisJson(Object? json) => switch (json) {
{
'id': final int id,
'hdebut': final int hdebut,
'mdebut': final int mdebut,
'hfin': final int hfin,
'mfin': final int mfin,
'libre': final bool libre,
'patient': final Object? patient,
'moi': final bool moi,
'reservable': final bool reservable,
'idRv': final int? idRv,
} =>
CreneauAgenda(
id,
hdebut,
mdebut,
hfin,
mfin,
libre,
patient == null ? null : Identite.depuisJson(patient),
moi,
reservable,
idRv,
),
_ => throw FormatException('Créneau inattendu : $json'),
};
// "8:00 - 8:20"
String get horaire {
String hm(int h, int m) => '$h:${m.toString().padLeft(2, '0')}';
return '${hm(hdebut, mdebut)} - ${hm(hfin, mfin)}';
}
}
// GET /api/agenda?idMedecin=...&jour=...
class Agenda {
final Personne medecin;
final String jour;
final List<CreneauAgenda> creneaux;
final String? info;
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),
_ => throw FormatException('Agenda inattendu : $json'),
};
}
|
Le JSON décodé n'est pas typé : des [Map], des [List], des [dynamic]. Chaque classe a un constructeur factory [depuisJson] qui vérifie la forme du JSON reçu. Un constructeur [factory] ne crée pas forcément un objet neuf : c'est une fonction qui renvoie un objet de la classe.
- lignes 33-39 : le filtrage de [Dart] 3 (« pattern matching ») appliqué au JSON. Le motif [{'login': final String login, 'nom': final String nom, 'role': final String role}] vérifie que [json] est une [Map], que ces trois clés existent, que leurs valeurs sont des chaînes, et range ces valeurs dans trois variables. Si le JSON a une autre forme, c'est le cas [_], une exception. Une réponse inattendue est ainsi détectée dès sa réception, et non plus tard, au milieu de l'affichage ;
- ligne 23 : « ADMIN » -> [Role.admin] : [byName] retrouve une valeur d'énumération par son nom ;
- lignes 67-76 : le motif vérifie les clés obligatoires ; la clé [login], facultative, est lue à part ;
- ligne 81 : une liste JSON -> une liste de personnes ;
- lignes 133-144 : [final Object? patient] accepte n'importe quelle valeur, [null] compris ; [final int? idRv], un entier ou [null] ;
- lignes 162-164 : un getter de mise en forme.
9.3.2. Les erreurs
| // =========================================================================
// lib/commun/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';
}
|
Toutes les réponses d'erreur du serveur ont la même forme. Mais une requête peut aussi échouer AUTREMENT : pas de réponse du tout, ou une réponse qui ne vient pas de notre serveur. On convertit tout cela en UNE classe, [ErreurApi].
- lignes 19-23 : un statut (0 : pas de réponse), une clé de traduction, ses paramètres, l'erreur de chaque champ ;
- lignes 28-44 : une réponse d'erreur -> une [ErreurApi]. [if (corps case {'cle': final String cle})] : le filtrage dans un [if] (« if-case ») : si le corps a une clé [cle] de type chaîne, elle est rangée dans la variable [cle]. Sinon, ce n'est pas notre serveur qui a répondu ;
- ligne 53 : « 401 LOGIN.ERROR », pour un affichage rapide.
9.3.3. Les requêtes
| // =========================================================================
// lib/commun/api.dart - des requêtes vers l'API, en une ligne
// -------------------------------------------------------------------------
// Dans l'exemple 23, chaque requête répétait les mêmes gestes : adresse du
// serveur, préfixe /api, entête JSON, test du code de statut, lecture du
// corps d'erreur... On les regroupe ici :
//
// final json = await api.get('/medecins');
// await api.post('/rendez-vous', {'jour': jour, 'idCreneau': 3});
//
// - en cas de succès, le Future renvoie le corps JSON, DÉCODÉ (des Map,
// des List, des String, des nombres...) ;
// - en cas d'échec, il se termine par une ErreurApi.
// Toutes les requêtes passent par la méthode requete : c'est l'endroit où
// l'on traiterait un problème COMMUN à toutes (cf. l'étude de cas et la
// réponse 401). Utilisé par les exemples 24 et 25.
// =========================================================================
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'config.dart';
import 'erreur_api.dart';
class Api {
final http.Client _client;
Api(this._client);
Future<Object?> requete(String methode, String url, [Object? corps]) async {
final requete = http.Request(methode, Uri.parse('${Config.urlServeur}/api$url'));
if (corps != null) {
// un corps JSON (le serveur refuse un POST qui n'en est pas un)
requete.headers['content-type'] = 'application/json';
requete.body = jsonEncode(corps);
}
final http.Response reponse;
try {
// send envoie la requête ; fromStream attend le corps complet de la réponse
reponse = await http.Response.fromStream(await _client.send(requete));
} catch (_) {
// AUCUNE réponse (réseau, serveur arrêté, mauvaise adresse)
throw const ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
}
if (reponse.statusCode >= 400) {
throw ErreurApi.depuisReponse(reponse);
}
return reponse.bodyBytes.isEmpty ? null : jsonDecode(utf8.decode(reponse.bodyBytes));
}
Future<Object?> get(String url) => requete('GET', url);
// un POST a toujours un corps JSON : {} par défaut
Future<Object?> post(String url, [Object corps = const {}]) => requete('POST', url, corps);
Future<Object?> put(String url, Object corps) => requete('PUT', url, corps);
Future<Object?> delete(String url, [Object? corps]) => requete('DELETE', url, corps);
}
|
- lignes 31-49 : TOUTES les requêtes passent par cette méthode : l'adresse du serveur et le préfixe [/api], le corps en JSON, l'envoi ([_client.send], puis [http.Response.fromStream] qui attend le corps complet), les erreurs converties en [ErreurApi], le corps de la réponse décodé. C'est l'endroit où l'on traiterait un problème commun à toutes les requêtes (cf. l'étude de cas et la réponse 401) ;
- lignes 52-56 : quatre raccourcis. Un POST a toujours un corps JSON : [{}] par défaut.
9.3.4. Une barre de connexion
| // =========================================================================
// lib/commun/barre_connexion.dart - connexion / déconnexion, en une barre
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 23, réduit à une barre et réécrit avec api.dart.
// Le widget appelle onUtilisateurChange avec l'utilisateur connecté (ou
// null) chaque fois que celui-ci change - y compris au démarrage, après
// avoir interrogé GET /api/auth/moi. L'objet Api est lu dans le context
// (fourni par provider, cf. le main des exemples 24 et 25).
// =========================================================================
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import 'api.dart';
import 'erreur_api.dart';
import 'types.dart';
class BarreConnexion extends StatefulWidget {
final ValueChanged<Utilisateur?> onUtilisateurChange;
const BarreConnexion({super.key, required this.onUtilisateurChange});
@override
State<BarreConnexion> createState() => _BarreConnexionState();
}
class _BarreConnexionState extends State<BarreConnexion> {
// late : lu dans le context, dans initState
late final Api _api = context.read<Api>();
Utilisateur? _utilisateur;
Captcha? _captcha;
final _login = TextEditingController(text: 'admin');
final _password = TextEditingController();
final _reponseCaptcha = TextEditingController();
String? _erreur;
@override
void initState() {
super.initState();
_demarrer();
}
@override
void dispose() {
_login.dispose();
_password.dispose();
_reponseCaptcha.dispose();
super.dispose();
}
void _changer(Utilisateur? u) {
setState(() => _utilisateur = u);
widget.onUtilisateurChange(u);
}
Future<void> _nouveauCaptcha() async {
final captcha = Captcha.depuisJson(await _api.get('/captcha'));
_reponseCaptcha.clear();
if (mounted) setState(() => _captcha = captcha);
}
// au démarrage : sommes-nous déjà connectés ?
Future<void> _demarrer() async {
try {
_changer(Utilisateur.depuisJson(await _api.get('/auth/moi')));
} on ErreurApi {
_changer(null); // 401 : personne n'est connecté
try {
await _nouveauCaptcha();
} on ErreurApi {
setState(() => _erreur = 'Le serveur JSON est-il lancé (port 8080) ?');
}
}
}
Future<void> _seConnecter() async {
setState(() => _erreur = null);
try {
final u = Utilisateur.depuisJson(
await _api.post('/auth/login', {
'login': _login.text.trim(),
'password': _password.text,
'captchaId': _captcha?.id,
'captcha': _reponseCaptcha.text,
}),
);
_password.clear();
_changer(u);
} on ErreurApi catch (e) {
setState(() => _erreur = '$e'); // "401 LOGIN.ERROR"
await _nouveauCaptcha();
}
}
Future<void> _seDeconnecter() async {
await _api.post('/auth/logout');
_changer(null);
await _nouveauCaptcha();
}
@override
Widget build(BuildContext context) {
final u = _utilisateur;
return Card(
color: Colors.blueGrey.shade50,
child: Padding(
padding: const EdgeInsets.all(8),
child: u != null
? Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 8),
Expanded(child: Text('${u.nom} (${u.role.name.toUpperCase()})')),
TextButton(onPressed: _seDeconnecter, child: const Text('Se déconnecter')),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _login,
decoration: const InputDecoration(labelText: 'login', isDense: true),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'mot de passe', isDense: true),
),
),
],
),
Row(
children: [
// l'image SVG du captcha, fabriquée par NOTRE serveur (cf. exemple 22)
if (_captcha != null) SvgPicture.string(_captcha!.svg, height: 40),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _reponseCaptcha,
autocorrect: false,
decoration: const InputDecoration(labelText: 'captcha', isDense: true),
),
),
IconButton(tooltip: 'Se connecter', icon: const Icon(Icons.login), onPressed: _seConnecter),
],
),
if (_erreur != null) Text(_erreur!, style: const TextStyle(color: Colors.red)),
],
),
),
);
}
}
|
Le formulaire de l'exemple 23, réduit à une barre et réécrit avec [Api]. Il appelle [onUtilisateurChange] chaque fois que l'utilisateur connecté change, y compris au démarrage.
- ligne 30 : l'objet [Api] est fourni plus haut (par [provider]) ; [late] : il n'est lu qu'à sa première utilisation, dans [initState] ;
- lignes 64-72 : au démarrage, qui est connecté ? [Utilisateur.depuisJson(await _api.get('/auth/moi'))] : une ligne. Toute [ErreurApi] signifie « personne » ;
- lignes 77-92 : la connexion, en quelques lignes : les erreurs arrivent sous forme d'[ErreurApi].
9.3.5. La couche d'accès à l'API
| // =========================================================================
// lib/ex24_couche_api/rdvmedecins.dart - la couche d'accès à l'API
// -------------------------------------------------------------------------
// Une méthode par route de l'API, typée. Les écrans ne connaissent ni les
// URL, ni le paquet http, ni le format JSON : ils appellent des méthodes
// qui renvoient des objets Dart (Personne, Agenda...).
// C'est l'équivalent, côté client, de la couche DAO côté serveur : si l'API
// change (une URL, un nom de champ), on ne modifie que ce fichier.
// Dans le guide d'architecture de Flutter, c'est un "repository".
// =========================================================================
import '../commun/api.dart';
import '../commun/types.dart';
class RdvMedecins {
final Api _api;
RdvMedecins(this._api);
Future<List<Personne>> getMedecins() async => Personne.liste(await _api.get('/medecins'));
Future<List<Personne>> getClients() async => Personne.liste(await _api.get('/clients'));
// Uri fabrique "idMedecin=1&jour=2030-01-15", en encodant ce qu'il faut
Future<Agenda> getAgenda(int idMedecin, String jour) async {
final parametres = Uri(queryParameters: {'idMedecin': '$idMedecin', 'jour': jour}).query;
return Agenda.depuisJson(await _api.get('/agenda?$parametres'));
}
// [int? idClient] : un paramètre facultatif. '?idClient' dans la Map : la clé
// n'est placée dans la Map que si idClient n'est pas null (Dart 3.8)
Future<void> reserver(String jour, int idCreneau, [int? idClient]) =>
_api.post('/rendez-vous', {'jour': jour, 'idCreneau': idCreneau, 'idClient': ?idClient});
Future<void> annuler(int idRv) => _api.delete('/rendez-vous/$idRv');
}
|
Une méthode par route de l'API, typée. Les écrans ne connaissent ni les adresses, ni le paquet [http], ni le format JSON : ils appellent des méthodes qui renvoient des objets [Dart]. Si l'API change (une adresse, un nom de champ), on ne modifie que ce fichier. Le guide d'architecture de [Flutter] appelle cette couche un « repository ».
- ligne 26 : [Uri] fabrique la chaîne « idMedecin=1&jour=2030-01-15 », en encodant ce qu'il faut ;
- ligne 33 : ['idClient': ?idClient] dans une [Map] littérale : la clé n'est placée dans la [Map] que si [idClient] n'est pas [null] (les « éléments facultatifs » de [Dart] 3.8).
9.3.6. Le modèle de vue
| // =========================================================================
// lib/ex24_couche_api/agenda_modele.dart - le "modèle de vue" de l'écran
// -------------------------------------------------------------------------
// Le guide d'architecture de Flutter recommande de séparer :
// - la VUE : les widgets, qui affichent et transmettent les actions de
// l'utilisateur ;
// - le MODÈLE DE VUE (ViewModel) : un ChangeNotifier qui contient l'état
// de l'écran (le médecin choisi, le jour, l'agenda reçu...), les
// actions (choisir, réserver, annuler), et qui appelle la couche
// d'accès à l'API.
// La vue n'a plus de logique : elle est simple à lire ; le modèle de vue
// n'a pas de widgets : il est simple à tester.
//
// Il fait aussi ce qu'on n'a pas envie de réécrire à chaque écran :
// - un CACHE : l'agenda de chaque (médecin, jour) déjà lu est gardé ; y
// revenir l'affiche aussitôt, pendant qu'on le relit en arrière-plan ;
// - les réponses PÉRIMÉES sont ignorées (cf. exemple 19).
// =========================================================================
import 'package:flutter/foundation.dart';
import '../commun/erreur_api.dart';
import '../commun/types.dart';
import 'rdvmedecins.dart';
String _deuxChiffres(int n) => n.toString().padLeft(2, '0');
String formatJour(DateTime d) => '${d.year}-${_deuxChiffres(d.month)}-${_deuxChiffres(d.day)}';
class AgendaModele extends ChangeNotifier {
final RdvMedecins _rdvmedecins;
AgendaModele(this._rdvmedecins);
// ---- l'état ----
Utilisateur? utilisateur;
List<Personne> medecins = [];
List<Personne> clients = [];
int? idMedecin;
String jour = formatJour(DateTime.now().add(const Duration(days: 1))); // demain
int? idClient;
Agenda? agenda;
bool chargement = false;
({bool succes, String texte})? message;
final Map<String, Agenda> _cache = {};
int _numeroLecture = 0;
// ---- des valeurs calculées ----
bool get connecte => utilisateur != null;
bool get gestionnaire => connecte && utilisateur!.role != Role.user;
// ---- les actions ----
Future<void> changerUtilisateur(Utilisateur? u) async {
utilisateur = u;
medecins = [];
clients = [];
agenda = null;
idMedecin = null;
_cache.clear(); // les droits ont changé : on oublie tout
notifyListeners();
if (u == null) return;
medecins = await _rdvmedecins.getMedecins();
if (gestionnaire) clients = await _rdvmedecins.getClients(); // un USER recevrait 403
notifyListeners();
}
void choisirMedecin(int id) {
idMedecin = id;
message = null;
_lireAgenda();
}
void choisirJour(DateTime d) {
jour = formatJour(d);
message = null;
_lireAgenda();
}
void choisirClient(int? id) {
idClient = id;
notifyListeners();
}
Future<void> reserver(CreneauAgenda c) =>
_agir(() => _rdvmedecins.reserver(jour, c.id, gestionnaire ? idClient : null), 'Créneau ${c.horaire} réservé.');
Future<void> annuler(CreneauAgenda c) =>
_agir(() => _rdvmedecins.annuler(c.idRv!), 'Rendez-vous de ${c.horaire} annulé.');
// une action, puis TOUJOURS une relecture de l'agenda : c'est le SERVEUR qui fait foi
Future<void> _agir(Future<void> Function() action, String succes) async {
try {
await action();
message = (succes: true, texte: succes);
} on ErreurApi catch (e) {
message = (succes: false, texte: '$e');
}
await _lireAgenda();
}
Future<void> _lireAgenda() async {
final id = idMedecin;
if (id == null) return;
final cle = '$id/$jour';
final numero = ++_numeroLecture;
// le cache d'abord (ou rien), puis la lecture
agenda = _cache[cle];
chargement = true;
notifyListeners();
try {
final lu = await _rdvmedecins.getAgenda(id, jour);
if (numero != _numeroLecture) return; // une lecture plus récente a été lancée
agenda = _cache[cle] = lu;
} on ErreurApi catch (e) {
if (numero != _numeroLecture) return;
agenda = null;
message = (succes: false, texte: '$e');
}
chargement = false;
notifyListeners();
}
}
|
Le guide d'architecture de [Flutter] recommande de séparer la vue (les widgets, qui affichent et transmettent les actions de l'utilisateur) et le modèle de vue (un [ChangeNotifier] qui contient l'état de l'écran et ses actions, et qui appelle la couche d'accès à l'API). La vue n'a plus de logique ; le modèle de vue n'a pas de widgets : il se teste sans écran.
- lignes 35-43)? message;} : l'état de l'écran ;
- lignes 45-46 : un cache : l'agenda de chaque (médecin, jour) déjà lu est gardé ; y revenir l'affiche aussitôt, pendant qu'on le relit. Et le numéro de la dernière lecture, pour ignorer les réponses périmées (cf. exemple 19). C'est, en quelques lignes, ce que fait la bibliothèque [TanStack Query] du cours [React] ;
- lignes 49-50 : des valeurs calculées ;
- lignes 53-64 : un nouvel utilisateur : on oublie tout (les droits ont changé), puis on lit les médecins et, pour l'ADMIN et le DOCTOR, les clients (un USER recevrait 403) ;
- lignes 91-98 : une action (réserver, annuler), puis TOUJOURS une relecture de l'agenda : c'est le serveur qui fait foi ;
- lignes 101-120 : la lecture de l'agenda : le cache d'abord (ou rien), puis la réponse du serveur, sauf si une lecture plus récente a été lancée entre-temps.
9.3.7. L'écran
| // =========================================================================
// lib/ex24_couche_api/main.dart - un agenda, avec la couche d'accès à l'API
// -------------------------------------------------------------------------
// Connectez-vous (barre du haut), choisissez un médecin et un jour :
// l'agenda s'affiche. ADMIN et DOCTOR peuvent réserver pour un patient et
// annuler ; un patient (martin...) réserve pour lui-même. Chaque action
// se termine par une relecture de l'agenda.
// Trois couches, du bas vers le haut :
// Api (commun/api.dart) les requêtes HTTP, les erreurs ;
// RdvMedecins (rdvmedecins.dart) une méthode typée par route de l'API ;
// AgendaModele (agenda_modele.dart) l'état et les actions de l'écran ;
// et la vue, ci-dessous, qui ne fait qu'afficher le modèle.
// =========================================================================
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../commun/api.dart';
import '../commun/barre_connexion.dart';
import '../commun/client_http.dart';
import '../commun/types.dart';
import 'agenda_modele.dart';
import 'rdvmedecins.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final api = Api(ClientAvecCookies(await SharedPreferences.getInstance()));
runApp(
MultiProvider(
providers: [
// Provider : fournit un objet ordinaire (qui ne prévient de rien)
Provider.value(value: api),
ChangeNotifierProvider(create: (_) => AgendaModele(RdvMedecins(api))),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: const PageAgenda(),
),
),
);
}
class PageAgenda extends StatelessWidget {
const PageAgenda({super.key});
@override
Widget build(BuildContext context) {
final modele = context.watch<AgendaModele>();
final agenda = modele.agenda;
final message = modele.message;
return Scaffold(
appBar: AppBar(title: const Text("Exemple 24 - une couche d'accès à l'API")),
body: ListView(
padding: const EdgeInsets.all(12),
children: [
BarreConnexion(onUtilisateurChange: modele.changerUtilisateur),
if (modele.connecte) ...[
DropdownButton<int>(
isExpanded: true,
hint: const Text('-- choisir un médecin --'),
value: modele.idMedecin,
items: [for (final m in modele.medecins) DropdownMenuItem(value: m.id, child: Text(m.nomComplet))],
onChanged: (id) => modele.choisirMedecin(id!),
),
Row(
children: [
Text('Jour : ${modele.jour}'),
IconButton(
icon: const Icon(Icons.calendar_month),
onPressed: () async {
final d = await showDatePicker(
context: context,
initialDate: DateTime.parse(modele.jour),
firstDate: DateTime(2020),
lastDate: DateTime(2035),
);
if (d != null) modele.choisirJour(d);
},
),
],
),
if (modele.gestionnaire)
DropdownButton<int?>(
isExpanded: true,
value: modele.idClient,
items: [
const DropdownMenuItem(value: null, child: Text('-- patient à inscrire --')),
for (final c in modele.clients) DropdownMenuItem(value: c.id, child: Text(c.nomComplet)),
],
onChanged: modele.choisirClient,
),
if (message != null)
Card(
color: message.succes ? Colors.green.shade50 : Colors.red.shade50,
child: Padding(padding: const EdgeInsets.all(10), child: Text(message.texte)),
),
if (modele.chargement) const LinearProgressIndicator(),
if (agenda != null) ...[
// info : la clé envoyée par le serveur quand un patient a déjà un rendez-vous à venir
if (agenda.info != null) Text(agenda.info!, style: const TextStyle(color: Colors.blue)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Agenda du ${agenda.jour} - ${agenda.medecin.nomComplet}',
style: Theme.of(context).textTheme.titleSmall,
),
),
for (final c in agenda.creneaux) _LigneCreneau(creneau: c),
],
],
],
),
);
}
}
// une ligne de l'agenda : l'horaire, l'occupant, les boutons permis
class _LigneCreneau extends StatelessWidget {
final CreneauAgenda creneau;
const _LigneCreneau({required this.creneau});
@override
Widget build(BuildContext context) {
final c = creneau;
final modele = context.read<AgendaModele>();
final Widget statut = c.libre
? Chip(label: const Text('Libre'), backgroundColor: Colors.green.shade100)
: Chip(
label: Text(c.patient?.toString() ?? 'Réservé'),
backgroundColor: c.moi ? Colors.indigo.shade100 : Colors.grey.shade300,
);
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: SizedBox(width: 90, child: Text(c.horaire)),
title: Align(alignment: Alignment.centerLeft, child: statut),
// les boutons suivent les droits calculés par le serveur
trailing: c.reservable
? IconButton(
tooltip: 'Réserver',
icon: const Icon(Icons.add_circle_outline, color: Colors.green),
onPressed: () => modele.reserver(c),
)
: c.idRv != null
? IconButton(
tooltip: 'Annuler',
icon: const Icon(Icons.cancel_outlined, color: Colors.red),
onPressed: () => modele.annuler(c),
)
: null,
);
}
}
|
- lignes 28-34 : les trois couches, assemblées au démarrage : [Api] (les requêtes), [RdvMedecins] (les routes), [AgendaModele] (l'écran). [Provider.value] fournit un objet ordinaire, qui ne prévient de rien ;
- ligne 50 : la vue s'abonne au modèle de vue ;
- ligne 58 : la barre de connexion prévient le modèle ;
- la suite n'est qu'affichage : chaque widget lit l'état du modèle et, quand l'utilisateur agit, appelle une de ses méthodes ([modele.choisirMedecin(id!)], [modele.reserver(c)]...) ;
- lignes 120-153 : une ligne de l'agenda. Les boutons suivent les droits calculés par le serveur ([reservable], [idRv]).
Connecté comme [admin], l'agenda de Mme PELISSIER ; on réserve un créneau pour un patient ; connecté comme [martin] (rôle USER), le même agenda :
Le patient [martin] ne voit pas le nom de la patiente du créneau réservé : le serveur ne le lui a pas envoyé.
9.4. Exemple 25 : les erreurs renvoyées par le serveur
9.4.1. Les dictionnaires
| {
"FORM": {
"TITRE_AJOUT": "Ajouter un client",
"TITRE_MODIF": "Modifier un client",
"TITRE": "Titre",
"PRENOM": "Prénom",
"NOM": "Nom",
"LOGIN": "Login",
"PASSWORD": "Mot de passe",
"ENREGISTRER": "Enregistrer",
"ANNULER": "Annuler",
"MODIFIER": "Modifier",
"SUPPRIMER": "Supprimer",
"CONFIRMER_SUPPRESSION": "Supprimer le client {{nom}}, son compte et ses rendez-vous ?",
"AJOUTE": "Le client {{nom}} a été ajouté (login : {{login}}).",
"MODIFIE": "Le client {{nom}} a été modifié.",
"SUPPRIME": "Le client {{nom}} a été supprimé.",
"ADMIN_SEULEMENT": "Connectez-vous avec le compte admin : seul le rôle ADMIN gère les clients.",
"TITRE_PAGE": "Exemple 25 - les erreurs du serveur",
"NOUVEAU": "Nouveau client"
},
"ERRORS": {
"INVALID_FORM": "Le formulaire contient des erreurs.",
"INVALID_TITLE": "Choisissez un titre dans la liste.",
"FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
"FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
"LASTNAME_REQUIRED": "Le nom est obligatoire.",
"LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
"INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
"PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
"LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
"PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
"PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
"CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Rechargez-la avant de recommencer.",
"FORBIDDEN": "Votre rôle ne vous permet pas cette action.",
"AUTH_REQUIRED": "Connectez-vous d'abord.",
"SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
"SERVER_UNREACHABLE": "Le serveur ne répond pas.",
"SERVER_ERROR": "Une erreur inattendue s'est produite.",
"WRONG_CAPTCHA": "Les caractères recopiés ne correspondent pas à l'image."
}
}
|
Les messages d'erreur du serveur sont des clés (« ERRORS.LASTNAME_REQUIRED ») : le dictionnaire les traduit.
9.4.2. L'écran
| // =========================================================================
// lib/ex25_erreurs_serveur/main.dart - afficher les erreurs du SERVEUR
// -------------------------------------------------------------------------
// La gestion des clients (compte admin). Ce formulaire ne fait
// VOLONTAIREMENT aucune validation sur le téléphone : tout est envoyé au
// serveur, qui répond, en cas de refus, par une erreur JSON (convertie en
// ErreurApi par commun/api.dart) :
//
// 400 { cle: 'ERRORS.INVALID_FORM', champs: { nom: 'ERRORS.LASTNAME_REQUIRED' } }
// 409 { cle: 'ERRORS.PATIENT_ALREADY_EXISTS', champs: { prenom: ..., nom: ... } }
// 409 { cle: 'ERRORS.CONCURRENT_UPDATE' } (verrou optimiste)
//
// L'erreur de CHAQUE champ (ErreurApi.champs) est rangée dans l'état et
// affichée sous le champ (errorText) ; son message est la CLÉ envoyée par
// le serveur, que l'écran traduit (dictionnaires de l'exemple 21).
// Modifier un champ efface son erreur.
// Essayez : un nom vide, un titre "Dr", un homonyme (Alice DURAND), un
// login déjà pris (martin), la même fiche modifiée sur deux appareils...
// =========================================================================
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../commun/api.dart';
import '../commun/barre_connexion.dart';
import '../commun/client_http.dart';
import '../commun/erreur_api.dart';
import '../commun/types.dart';
import '../ex16_confirmation/confirmation.dart';
import '../ex21_i18n/traductions.dart';
import '../ex23_connexion/commande.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final stockage = await SharedPreferences.getInstance();
final traductions = Traductions('ex25', stockage);
await traductions.demarrer();
runApp(
MultiProvider(
providers: [
Provider.value(value: Api(ClientAvecCookies(stockage))),
ChangeNotifierProvider.value(value: traductions),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: const PageClients(),
),
),
);
}
// les champs du formulaire
enum Champ { titre, prenom, nom, login, password }
class PageClients extends StatefulWidget {
const PageClients({super.key});
@override
State<PageClients> createState() => _PageClientsState();
}
class _PageClientsState extends State<PageClients> {
late final Api _api = context.read<Api>();
Utilisateur? _utilisateur;
List<Personne> _clients = [];
// le formulaire : _idEdite == null -> création, sinon modification
int? _idEdite;
int _version = 0;
String _titre = 'Mr';
final _textes = {
for (final c in [Champ.prenom, Champ.nom, Champ.login, Champ.password]) c: TextEditingController(),
};
// l'erreur de chaque champ (une CLÉ de traduction), renvoyée par le serveur
Map<String, String> _erreurs = {};
// le message général de la dernière erreur, et celui d'un succès (clé + paramètres)
ErreurApi? _erreurPage;
({String cle, Map<String, String> params})? _succes;
// l'envoi au serveur : une Commande (exemple 23), qui dit s'il est en cours
final _envoi = Commande();
bool get _estAdmin => _utilisateur?.role == Role.admin;
@override
void dispose() {
for (final c in _textes.values) {
c.dispose();
}
_envoi.dispose();
super.dispose();
}
Future<void> _recharger() async {
final clients = Personne.liste(await _api.get('/clients'));
if (mounted) setState(() => _clients = clients);
}
Future<void> _surConnexion(Utilisateur? u) async {
setState(() {
_utilisateur = u;
_clients = [];
});
if (u?.role == Role.admin) await _recharger();
}
void _effacerMessages() {
_erreurs = {};
_erreurPage = null;
_succes = null;
}
void _nouveau() {
setState(() {
_idEdite = null;
_version = 0;
_titre = 'Mr';
for (final c in _textes.values) {
c.clear();
}
_effacerMessages();
});
}
void _editer(Personne c) {
setState(() {
_idEdite = c.id;
// la VERSION lue avec la fiche repart avec la modification : si quelqu'un
// a modifié la fiche entre-temps, le serveur répondra 409 CONCURRENT_UPDATE
_version = c.version;
_titre = c.titre;
_textes[Champ.prenom]!.text = c.prenom;
_textes[Champ.nom]!.text = c.nom;
_textes[Champ.login]!.text = c.login ?? '';
_textes[Champ.password]!.clear();
_effacerMessages();
});
}
Future<void> _enregistrer() async {
setState(_effacerMessages);
final saisie = {
'titre': _titre,
for (final MapEntry(key: champ, value: c) in _textes.entries) champ.name: c.text,
'version': _version,
};
try {
final client = Personne.depuisJson(
_idEdite == null ? await _api.post('/clients', saisie) : await _api.put('/clients/$_idEdite', saisie),
);
final cle = _idEdite == null ? 'FORM.AJOUTE' : 'FORM.MODIFIE';
_nouveau(); // formulaire vidé, messages effacés...
// ... sauf celui-ci
setState(
() => _succes = (cle: cle, params: {'nom': '${client.prenom} ${client.nom}', 'login': client.login ?? ''}),
);
await _recharger();
} on ErreurApi catch (e) {
setState(() {
_erreurPage = e; // le message général
_erreurs = e.champs; // l'erreur de chaque champ
});
}
}
Future<void> _supprimer(Personne c) async {
final t = context.read<Traductions>().t;
final nom = '${c.prenom} ${c.nom}';
final oui = await confirmer(context, t('FORM.CONFIRMER_SUPPRESSION', {'nom': nom}));
if (!oui || !mounted) return;
setState(_effacerMessages);
try {
await _api.delete('/clients/${c.id}');
setState(() => _succes = (cle: 'FORM.SUPPRIME', params: {'nom': nom}));
} on ErreurApi catch (e) {
setState(() => _erreurPage = e);
}
await _recharger();
}
@override
Widget build(BuildContext context) {
final traductions = context.watch<Traductions>();
final t = traductions.t;
// un champ de saisie, avec son erreur (une clé du serveur) traduite
Widget champ(Champ c, {bool secret = false}) => TextField(
controller: _textes[c],
obscureText: secret,
decoration: InputDecoration(
labelText: t('FORM.${c.name.toUpperCase()}'),
errorText: _erreurs[c.name] == null ? null : t(_erreurs[c.name]!),
errorMaxLines: 2,
),
// un champ modifié : son erreur disparaît
onChanged: (_) {
if (_erreurs.containsKey(c.name)) setState(() => _erreurs = {..._erreurs}..remove(c.name));
},
);
final succes = _succes;
final erreurPage = _erreurPage;
return Scaffold(
appBar: AppBar(
title: Text(t('FORM.TITRE_PAGE')),
actions: [
// la langue : un menu
PopupMenuButton<String>(
icon: const Icon(Icons.translate),
onSelected: traductions.changerLangue,
itemBuilder: (_) => [for (final l in langues) PopupMenuItem(value: l, child: Text(l.toUpperCase()))],
),
],
),
body: ListView(
padding: const EdgeInsets.all(12),
children: [
BarreConnexion(onUtilisateurChange: _surConnexion),
if (_utilisateur != null && !_estAdmin)
Card(
color: Colors.amber.shade50,
child: Padding(padding: const EdgeInsets.all(12), child: Text(t('FORM.ADMIN_SEULEMENT'))),
),
if (_estAdmin) ...[
for (final c in _clients)
ListTile(
dense: true,
title: Text(c.nomComplet),
subtitle: Text(c.login ?? ''),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(tooltip: t('FORM.MODIFIER'), icon: const Icon(Icons.edit), onPressed: () => _editer(c)),
IconButton(
tooltip: t('FORM.SUPPRIMER'),
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _supprimer(c),
),
],
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t(_idEdite == null ? 'FORM.TITRE_AJOUT' : 'FORM.TITRE_MODIF'),
style: Theme.of(context).textTheme.titleMedium,
),
// un titre "Dr" que le serveur refusera : pour voir ERRORS.INVALID_TITLE
DropdownButton<String>(
value: _titre,
items: [
for (final titre in ['Mr', 'Mme', 'Melle', 'Dr'])
DropdownMenuItem(value: titre, child: Text(titre)),
],
onChanged: (v) => setState(() {
_titre = v!;
_erreurs = {..._erreurs}..remove('titre');
}),
),
if (_erreurs['titre'] != null)
Text(t(_erreurs['titre']!), style: const TextStyle(color: Colors.red, fontSize: 12)),
champ(Champ.prenom),
champ(Champ.nom),
champ(Champ.login),
champ(Champ.password, secret: true),
const SizedBox(height: 12),
ListenableBuilder(
listenable: _envoi,
builder: (context, _) => Wrap(
spacing: 8,
children: [
FilledButton(
onPressed: _envoi.enCours ? null : () => _envoi.executer(_enregistrer),
child: Text(t('FORM.ENREGISTRER')),
),
OutlinedButton(onPressed: _nouveau, child: Text(t('FORM.ANNULER'))),
],
),
),
if (succes != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(t(succes.cle, succes.params), style: const TextStyle(color: Colors.green)),
),
// le message général, et le statut HTTP, pour comprendre
if (erreurPage != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: t(erreurPage.cle, erreurPage.params),
style: const TextStyle(color: Colors.red),
),
TextSpan(
text: ' ($erreurPage)',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
),
],
),
),
),
],
],
),
);
}
}
|
La gestion des clients (compte admin). Ce formulaire ne fait VOLONTAIREMENT aucune validation sur le téléphone : tout est envoyé au serveur, qui répond, en cas de refus, par une erreur JSON, convertie en [ErreurApi].
- lignes 37-43 : les traductions de l'exemple 21, avec les dictionnaires de cet exemple ;
- ligne 77 : l'erreur de chaque champ : une CLÉ de traduction, renvoyée par le serveur ;
- lignes 79-82 : le message général de la dernière erreur, celui d'un succès, et l'envoi au serveur, une [Commande] (exemple 23) ;
- lignes 126-131 : la version lue avec la fiche repart avec la modification : si quelqu'un a modifié la fiche entre-temps, le serveur répondra 409 [CONCURRENT_UPDATE] (le verrou optimiste) ;
- lignes 141-162 : l'enregistrement : [POST /api/clients] (création) ou [PUT /api/clients/:id] (modification). En cas de refus, l'[ErreurApi] donne le message général et l'erreur de chaque champ ;
- lignes 188-198 : un champ de saisie : son erreur, une clé du serveur, est traduite et affichée sous le champ ([errorText]) ; modifier le champ efface son erreur ;
- lignes 209-212 : un menu dans la barre du haut, pour la langue ;
- ligne 257 : un titre « Dr », que le serveur refusera : pour voir [ERRORS.INVALID_TITLE] ;
- lignes 291-302 : le message général, traduit, et, pour comprendre, le statut HTTP et la clé.
Un formulaire vide ; un homonyme (« Alice DURAND ») ; le même, en anglais :
Essayez aussi : un login déjà pris ([martin]), un login invalide (« Jean M »), la même fiche modifiée sur deux appareils (ou dans l'application et avec [curl]).
Le principe est celui de tous les formulaires de l'étude de cas : les règles vivent en UN seul endroit, le serveur ; l'application affiche ses réponses, dans la langue de l'utilisateur.