Skip to content

3. Premiers pas avec Flutter

3.1. Exemple 01 : la structure d'une application Flutter

3.1.1. Le projet

Un projet [Flutter] se crée avec la commande [flutter create] :

flutter create --org fr.stahe --platforms=android,web,windows mon_application
  • [--org fr.stahe] : le préfixe de l'identifiant de l'application, à l'envers comme un nom de domaine. L'application [Android] s'appellera [fr.stahe.mon_application] ; deux applications installées sur un même téléphone doivent avoir des identifiants différents ;
  • [--platforms=...] : les plateformes visées. Pour chacune, [flutter create] fabrique un dossier ([android/], [web/], [windows/], et aussi [ios/], [macos/], [linux/] si on les demande) : un petit projet propre à la plateforme, qui se contente de démarrer le moteur de [Flutter] et d'y charger l'application. On n'y touche que rarement (le nom de l'application, son icône, ses permissions) ;
  • [mon_application] : le nom du projet, qui est aussi celui du paquet [Dart] : des minuscules, des chiffres et des soulignés.

La commande fabrique une application complète, prête à être lancée (un compteur). Le projet des exemples a été créé de la même façon, puis son dossier [lib/] a été rempli à la main, pour réunir vingt-cinq applications dans un même projet : elles partagent ainsi les mêmes paquets, téléchargés une seule fois.

3.1.2. Le fichier pubspec.yaml

Le fichier [pubspec.yaml] décrit le projet : son nom, la version de [Dart] exigée, les paquets utilisés, les fichiers livrés avec l'application. Celui des exemples :

# ==========================================================================
# pubspec.yaml - la description du projet des exemples
# --------------------------------------------------------------------------
# Un seul projet Flutter pour les 25 exemples : chacun a son dossier dans
# lib/ et son propre point d'entrée (main.dart), qu'on donne à la commande
# flutter run avec l'option -t (target) :
#
#     flutter run -t lib/ex05_champs_controles/main.dart
#
# Les bibliothèques (les "paquets") sont téléchargées depuis pub.dev par
# la commande flutter pub get (lancée automatiquement par flutter run).
# ==========================================================================

name: exemples_flutter
description: Les 25 petits exemples du cours Introduction étape par étape au framework Flutter.
# pas de publication sur pub.dev : c'est une application, pas une bibliothèque
publish_to: none
version: 1.0.0

environment:
  # la version du langage Dart exigée
  sdk: ^3.12.0

dependencies:
  flutter:
    sdk: flutter
  # des adresses "propres" sur le web (exemple 17)
  flutter_web_plugins:
    sdk: flutter
  # les textes de Flutter lui-même (boutons des fenêtres, calendrier...)
  # en français et en anglais (exemples 05, 21)
  flutter_localizations:
    sdk: flutter
  # dates et nombres mis en forme selon la langue (exemples 09, 21)
  intl: any
  # le routeur (exemples 17, 18)
  go_router: ^16.2.0
  # un état partagé par toute l'application (exemples 20, 24, 25)
  provider: ^6.1.5
  # un petit dictionnaire clé -> valeur, conservé sur l'appareil (exemples 20, 21, 23)
  shared_preferences: ^2.5.3
  # les requêtes HTTP (exemples 22 à 25)
  http: ^1.5.0
  # l'affichage d'images SVG (exemple 03, captcha des exemples 22 à 25)
  flutter_svg: ^2.2.1

dev_dependencies:
  # les tests (et les copies d'écran du cours, cf. test/captures)
  flutter_test:
    sdk: flutter
  # les règles de style recommandées par l'équipe de Flutter (cf. analysis_options.yaml)
  flutter_lints: ^6.0.0

flutter:
  # les icônes de Material Design (Icons.add, Icons.delete...)
  uses-material-design: true
  # les fichiers livrés AVEC l'application : on les lit par leur chemin
  assets:
    - assets/
    - assets/i18n/
  # une police à chasse fixe (pour afficher du JSON), livrée avec l'application
  fonts:
    - family: RobotoMono
      fonts:
        - asset: assets/polices/RobotoMono-Regular.ttf
        - asset: assets/polices/RobotoMono-Bold.ttf
          weight: 700
  • lignes 14-18 : le nom du projet (c'est aussi le nom du paquet : les fichiers du projet s'importent par [package:exemples_flutter/...]), sa description, sa version. [publish_to: none] : ce projet n'est pas une bibliothèque, il ne sera jamais publié sur [pub.dev] ;
  • lignes 20-22 : la version du langage [Dart] exigée. Le préfixe [^] accepte toute version plus récente de même version majeure (3.12, 3.13... mais pas 4.0) ;
  • lignes 24-45 : les paquets dont l'application a besoin. Certains viennent du SDK de [Flutter] lui-même ([sdk: flutter]) : [flutter] (le framework), [flutter_localizations] (les textes de [Flutter] traduits dans chaque langue), [flutter_web_plugins] (un réglage propre au web). Les autres sont téléchargés depuis pub.dev, le dépôt des paquets [Dart] : leur rôle est rappelé en commentaire, et chacun sera présenté dans l'exemple qui s'en sert. [intl: any] : n'importe quelle version (c'est [flutter_localizations] qui impose la sienne) ;
  • lignes 47-52 : les paquets qui ne servent qu'au développement, et ne font pas partie de l'application livrée : [flutter_test] (les tests) et [flutter_lints] (les règles de style de l'analyseur) ;
  • lignes 54-67 : la configuration propre à [Flutter] :
    • [uses-material-design: true] : l'application utilise la police d'icônes de [Material Design] ([Icons.add], [Icons.delete]...) ;
    • [assets] : les fichiers livrés avec l'application (images, dictionnaires...), qu'on lira par leur chemin. Déclarer un dossier ([assets/]) livre tous les fichiers qu'il contient directement ;
    • [fonts] : une police de caractères livrée avec l'application, [Roboto Mono], une police à chasse fixe, pour afficher du JSON. La police normale de [Material Design], [Roboto], est celle du système [Android] : inutile de la livrer.

Les versions des paquets suivent la même convention que [npm] : [^16.2.0] accepte toute version 16.x.y à partir de 16.2.0. La commande [flutter pub get] télécharge les paquets et écrit les versions exactes retenues dans le fichier [pubspec.lock]. Elle est lancée automatiquement par [flutter run] quand [pubspec.yaml] a changé ; [VS Code] la lance aussi dès qu'on enregistre ce fichier. Pour ajouter un paquet, plutôt que de modifier le fichier à la main, on peut taper [flutter pub add go_router] : la commande ajoute la ligne, avec la dernière version du paquet.

3.1.3. Le fichier analysis_options.yaml

L'analyseur de [Dart] vérifie le code pendant qu'on le tape : [VS Code] souligne en rouge les erreurs, en jaune ou en bleu les avertissements. On le lance aussi pour tout le projet avec [flutter analyze]. Ses règles :

# ==========================================================================
# 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
  • ligne 9 : les règles recommandées par l'équipe de [Flutter] (paquet [flutter_lints]) : un widget doit avoir un constructeur [const] s'il le peut, un contrôleur doit être libéré, etc. ;
  • lignes 14-15 : un typage plus strict. [Dart] est un langage à typage statique, mais il a un type [dynamic] (« n'importe quoi ») : le JSON décodé, par exemple, est fait de [dynamic]. [strict-casts] interdit de le convertir implicitement dans un autre type : il faut l'écrire ([json['nom'] as String]), et l'erreur éventuelle est à cet endroit, visible ;
  • lignes 20-24 : quelques règles de style : des guillemets simples pour les chaînes (doubles si la chaîne contient une apostrophe : ["l'état"]), [final] pour les variables jamais réaffectées.

3.1.4. Le point d'entrée : main.dart

Un exemple, c'est un fichier [main.dart], qui contient la fonction [main], le point d'entrée de tout programme [Dart]. Celui de l'exemple 01 :

// =========================================================================
// lib/ex01_premiere_application/main.dart - une première application
// -------------------------------------------------------------------------
// Tout, dans une application Flutter, est un WIDGET : un objet qui décrit
// un morceau de l'écran (un texte, un bouton, une marge, une colonne...).
// Les widgets s'emboîtent les uns dans les autres et forment un ARBRE.
// Un widget a une méthode build(), qui RENVOIE les widgets qui le
// composent. Flutter appelle build() pour fabriquer l'écran ; il la
// RAPPELLE chaque fois que l'état du widget change (setState), et met à
// jour l'écran avec le nouveau résultat.
// =========================================================================

import 'package:flutter/material.dart';

// des données "ordinaires", hors des widgets : elles ne changent jamais
const titre = 'Bienvenue dans Flutter';
const langages = ['Dart', 'Flutter', 'Material Design'];

// "12:05:09" : l'heure courante, sur deux chiffres
String heureCourante() {
  final d = DateTime.now();
  String deuxChiffres(int n) => n.toString().padLeft(2, '0');
  return '${deuxChiffres(d.hour)}:${deuxChiffres(d.minute)}:${deuxChiffres(d.second)}';
}

// le point d'entrée de tout programme Dart
void main() {
  // runApp : Flutter prend possession de l'écran et y affiche le widget RACINE
  runApp(const Application());
}

// le widget racine : il configure l'application (titre, thème, premier écran).
// StatelessWidget : un widget SANS état, entièrement décrit par ses paramètres
class Application extends StatelessWidget {
  const Application({super.key});

  @override
  Widget build(BuildContext context) {
    // MaterialApp : une application aux couleurs et au comportement de
    // Material Design, le langage visuel de Google
    return MaterialApp(
      title: 'Exemple 01',
      // pas de bandeau "DEBUG" dans le coin de l'écran
      debugShowCheckedModeBanner: false,
      // les couleurs de toute l'application, déduites d'une couleur de base
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageAccueil(),
    );
  }
}

// un widget AVEC état : il est en deux parties, le widget lui-même (qui ne
// change jamais) et un objet State (qui contient les données qui changent)
class PageAccueil extends StatefulWidget {
  const PageAccueil({super.key});

  @override
  State<PageAccueil> createState() => _PageAccueilState();
}

// le "_" : cette classe est PRIVÉE, invisible hors de ce fichier
class _PageAccueilState extends State<PageAccueil> {
  // l'ÉTAT : de simples champs de l'objet State
  int _compteur = 0;
  String _heure = heureCourante();

  // la fonction appelée par le bouton
  void _incrementer() {
    // setState : on modifie l'état DANS la fonction passée à setState ;
    // Flutter rappelle alors build() pour mettre l'écran à jour. On ne
    // modifie jamais l'écran soi-même
    setState(() {
      _compteur++;
      _heure = heureCourante();
    });
  }

  @override
  Widget build(BuildContext context) {
    // Scaffold : la structure d'un écran Material (barre du haut, corps...)
    return Scaffold(
      appBar: AppBar(title: const Text('Exemple 01 - une première application')),
      // Padding : une marge autour de son enfant (child)
      body: Padding(
        padding: const EdgeInsets.all(16),
        // Column : des enfants (children) placés les uns sous les autres
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(titre, style: Theme.of(context).textTheme.headlineSmall),
            const SizedBox(height: 12),
            const Text(
              'Cet écran est fabriqué par Flutter, à partir de la fonction main '
              'et des widgets Application et PageAccueil.',
            ),
            const SizedBox(height: 8),
            // ${expression} insère la valeur d'une expression dans une chaîne
            Text('On y écrit du ${langages.join(', ')} (${langages.length} langages).'),
            const SizedBox(height: 16),
            // onPressed : la fonction à appeler quand on appuie (cf. exemple 04)
            FilledButton(onPressed: _incrementer, child: const Text('Cliquez-moi')),
            const SizedBox(height: 16),
            Text(
              'Vous avez cliqué $_compteur fois (dernier clic : $_heure).',
              style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF087EA4)),
            ),
          ],
        ),
      ),
    );
  }
}

Commençons par la fin : le programme.

  • ligne 13 : la bibliothèque [Material] de [Flutter] : tous les widgets de [Material Design], le langage visuel de [Google] (boutons, champs de saisie, barres, fenêtres...). Elle importe elle-même le reste de [Flutter] ;
  • lignes 16-17 : deux données ordinaires, déclarées hors des widgets : elles ne changent jamais. [const] : une constante connue dès la compilation ;
  • lignes 20-23 : une fonction ordinaire, qui renvoie l'heure courante sous la forme « 12:05:09 ». Elle contient elle-même une petite fonction, [deuxChiffres], écrite en une ligne avec [=>] ;
  • lignes 27-29 : la fonction [main], appelée au lancement. [runApp] démarre [Flutter] : il prend possession de l'écran et y affiche le widget qu'on lui donne, la racine de l'application.

Le widget racine, [Application] :

  • ligne 34 : un widget est une classe. Celle-ci hérite de [StatelessWidget], un widget sans état : ce qu'il affiche ne dépend que de ses paramètres, et ne change jamais de lui-même ;
  • ligne 29 : son constructeur. [{super.key}] : un paramètre nommé, facultatif, transmis à la classe mère. Tout widget en a un : la « clé » qui l'identifie (cf. exemple 04). Le constructeur est [const] : [Application()] peut être une constante, que [Flutter] n'a jamais besoin de reconstruire ;
  • lignes 38-47 : la méthode [build], le cœur d'un widget. Elle renvoie les widgets qui le composent. [@override] : elle redéfinit la méthode [build] de la classe mère. Le paramètre [context] est la position du widget dans l'arbre des widgets : il sert à retrouver ce que ses ancêtres fournissent (le thème, par exemple, ligne 90) ;
  • lignes 41-47 : [MaterialApp] est le widget qui fait d'un arbre de widgets une application [Material] : un titre ([title], le nom de l'application dans la liste des applications récentes du téléphone, ou de l'onglet du navigateur), un thème ([theme]), un premier écran ([home]). [debugShowCheckedModeBanner: false] retire le bandeau « DEBUG » qu'une application en développement affiche dans un coin. [ThemeData(colorSchemeSeed: Colors.indigo)] : toutes les couleurs de l'application (boutons, barres, champs...) sont déduites d'une seule couleur de base, selon les règles de [Material Design] 3.

Le premier écran, [PageAccueil], a des données qui changent : c'est un widget avec état.

  • lignes 54-58 : un [StatefulWidget] est en deux parties. Le widget lui-même, comme tout widget, ne change jamais (ses champs seraient [final]) ; sa seule tâche est de fabriquer, par [createState], un objet [State] qui, lui, contient les données qui changent. [Flutter] crée cet objet une seule fois et le garde tant que le widget reste à la même place dans l'arbre ;
  • ligne 62 : l'objet [State]. Son nom commence par un souligné : en [Dart], tout ce qui commence par [_] est privé, invisible hors du fichier ;
  • lignes 64-65 : l'état : de simples champs de l'objet [State] ;
  • lignes 68-75 : la méthode appelée par le bouton. Elle ne touche pas à l'écran : elle change l'état, dans la fonction passée à [setState]. [setState] exécute cette fonction, puis prévient [Flutter] que l'état a changé : [Flutter] rappellera [build], comparera le nouvel arbre de widgets à l'ancien, et redessinera ce qui a changé ;
  • lignes 79-105 : le [build] de l'écran :
    • [Scaffold] (« échafaudage ») est la structure d'un écran [Material] : une barre du haut ([appBar]), un corps ([body]), et, si on les demande, une barre du bas, un bouton flottant, un menu latéral ;
    • [AppBar] : la barre du haut ; son titre est un widget ([Text]), comme presque tout en [Flutter] ;
    • [Padding] : une marge autour de son unique enfant, [child]. [EdgeInsets.all(16)] : 16 points de chaque côté (un « point » logique, indépendant de la densité de l'écran : environ 2,6 pixels sur le téléphone des copies d'écran) ;
    • [Column] : ses enfants, [children], placés les uns sous les autres. [crossAxisAlignment: CrossAxisAlignment.start] : alignés à gauche (l'axe « transversal » d'une colonne est l'horizontale) ;
    • [Text] : un texte. Son style peut venir du thème : [Theme.of(context).textTheme.headlineSmall] est le style des « petits grands titres » ;
    • [SizedBox] : une boîte vide, d'une hauteur donnée : un espace entre deux widgets ;
    • ligne 98 : [${expression}] insère la valeur d'une expression dans une chaîne ; [$nom] suffit pour une simple variable (ligne 104) ;
    • ligne 101 : un bouton. [onPressed] reçoit la fonction à appeler quand on appuie. On donne la fonction elle-même, sans parenthèses : [onPressed: _incrementer()] l'appellerait tout de suite, pendant la construction de l'écran ;
    • lignes 104-105 : un texte qui dépend de l'état. Quand l'état change, [build] est rappelée, et ce texte change.

Deux remarques de [Dart] :

  • les arguments des widgets sont presque tous des paramètres nommés ([title: ...], [child: ...]) : l'arbre des widgets se lit comme une description, et l'ordre des arguments n'importe pas. Un paramètre nommé est facultatif, sauf s'il est déclaré [required] ;
  • le mot-clé [const] devant un widget ([const SizedBox(height: 12)]) le fabrique une fois pour toutes, à la compilation. [Flutter] sait alors qu'il ne change jamais, et ne perd pas de temps à le comparer. L'analyseur signale les [const] qu'on a oubliés ; [VS Code] sait les ajouter tout seul.

L'arbre des widgets de l'écran, tel que le montre l'outil [Flutter DevTools] (inspecteur de widgets) :

Application
└── MaterialApp
    └── PageAccueil
        └── Scaffold
            ├── AppBar
            │   └── Text
            └── Padding
                └── Column
                    ├── Text          (le titre)
                    ├── SizedBox
                    ├── Text
                    ├── SizedBox
                    ├── Text
                    ├── SizedBox
                    ├── FilledButton
                    │   └── Text
                    ├── SizedBox
                    └── Text          (le compteur)

Dans une application [Flutter], on écrit peu de code « qui fait » et beaucoup de code « qui décrit » : l'écran est un arbre de widgets, chacun configuré par ses paramètres. C'est la même idée que le JSX de [React], sans langage de gabarits : l'arbre est écrit en [Dart].

3.1.5. Exécution

Dans la console ouverte sur le dossier [exemples_flutter], l'émulateur [Android] étant démarré :

flutter run -d emulator-5554 -t lib/ex01_premiere_application/main.dart

La console affiche :

Launching lib/ex01_premiere_application/main.dart on sdk gphone16k x86 64 in debug mode...
Running Gradle task 'assembleDebug'...                             24,1s
√ Built build\app\outputs\flutter-apk\app-debug.apk
Installing build\app\outputs\flutter-apk\app-debug.apk...          1 523ms
Syncing files to device sdk gphone16k x86 64...                       88ms

Flutter run key commands.
r Hot reload.
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

A Dart VM Service on sdk gphone16k x86 64 is available at: http://127.0.0.1:54321/...
The Flutter DevTools debugger and profiler on sdk gphone16k x86 64 is available at: http://127.0.0.1:9101?uri=...

La première fois, la construction de l'application [Android] (l'outil [Gradle]) prend une ou deux minutes ; les suivantes, quelques secondes. L'application s'installe sur l'émulateur et démarre. À gauche, l'écran de départ ; à droite, après deux appuis sur le bouton :

Deux expériences à faire :

  • modifier le texte du titre dans [main.dart], enregistrer le fichier, et taper r dans la console (ou simplement enregistrer, dans [VS Code]) : l'écran change en une seconde, et le compteur garde sa valeur. C'est le rechargement à chaud (« hot reload ») : le nouveau code est injecté dans l'application en cours d'exécution, [Flutter] rappelle les [build], mais les objets [State] sont conservés. R (« hot restart ») relance l'application depuis [main] : le compteur repart de 0 ;
  • lancer la même application dans le navigateur : [flutter run -d chrome -t lib/ex01_premiere_application/main.dart]. C'est le même écran, dessiné par le même moteur ; seule la taille de la fenêtre change. Avec [-d windows], c'est une application [Windows].

3.2. Exemple 02 : l'état et les valeurs calculées

L'état est le cœur de [Flutter], comme de [React]. Cet exemple calcule le prix TTC d'un article :

// =========================================================================
// lib/ex02_etat/main.dart - l'état et les valeurs calculées
// -------------------------------------------------------------------------
// Un petit calculateur de prix TTC :
//   - l'ÉTAT : les champs de l'objet State ; les changer DANS setState()
//     fait rappeler build(), qui fabrique l'écran avec les nouvelles valeurs ;
//   - une valeur CALCULÉE à partir de l'état n'est PAS un état : c'est un
//     "getter" (ou une variable locale de build), recalculé à chaque appel
//     de build(). Il est toujours à jour, sans rien à synchroniser ;
//   - une donnée qui dépend des valeurs SUCCESSIVES de l'état (l'historique
//     des totaux) se met à jour là où l'état change : dans setState ;
//   - le titre de l'application (onglet du navigateur, liste des
//     applications récentes du téléphone) est lui aussi un widget, Title :
//     il suit l'état comme le reste de l'écran.
// =========================================================================

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

// un formateur de montants en euros, à la française (1 234,50 €), cf. exemple 09
final euros = NumberFormat.currency(locale: 'fr_FR', symbol: '€');

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageCalculateur(),
    ),
  );
}

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

  @override
  State<PageCalculateur> createState() => _PageCalculateurState();
}

class _PageCalculateurState extends State<PageCalculateur> {
  // l'état
  String _libelle = 'Stylo';
  double _prixHT = 2.5;
  int _quantite = 1;
  double _tauxTVA = 20; // en %
  // l'historique des 5 derniers changements du total TTC
  final List<String> _historique = [];

  // un contrôleur donne accès au TEXTE d'un champ de saisie : on en a besoin
  // ici pour le remettre à sa valeur de départ (Réinitialiser). L'exemple 05
  // les étudie en détail
  final _champLibelle = TextEditingController(text: 'Stylo');
  final _champPrix = TextEditingController(text: '2.5');

  // des valeurs CALCULÉES : des getters, recalculés à chaque lecture
  double get _totalHT => _quantite * _prixHT;
  double get _montantTVA => _totalHT * _tauxTVA / 100;
  double get _totalTTC => _totalHT + _montantTVA;

  // TOUS les changements de l'état passent par cette méthode : elle note le
  // total TTC avant le changement, applique le changement, et complète
  // l'historique si le total a changé
  void _changer(void Function() changement) {
    final ancienTTC = _totalTTC;
    setState(() {
      changement();
      if (_totalTTC != ancienTTC) {
        _historique.insert(0, '${euros.format(ancienTTC)} -> ${euros.format(_totalTTC)}');
        if (_historique.length > 5) {
          _historique.removeLast();
        }
      }
    });
  }

  // changer d'article remet la quantité à 1 : on le fait là où l'on sait
  // POURQUOI l'état change
  void _changerLibelle(String nouveau) {
    _changer(() {
      _libelle = nouveau;
      _quantite = 1;
    });
  }

  void _reinitialiser() {
    _changer(() {
      _libelle = 'Stylo';
      _prixHT = 2.5;
      _quantite = 1;
      _tauxTVA = 20;
    });
    _champLibelle.text = 'Stylo';
    _champPrix.text = '2.5';
  }

  // l'objet State disparaît (on quitte l'écran) : on libère les contrôleurs
  @override
  void dispose() {
    _champLibelle.dispose();
    _champPrix.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final styleTotal = Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold);

    // Title : le titre de l'application SUIT l'état, comme tout le reste
    return Title(
      title: 'TTC : ${euros.format(_totalTTC)}',
      color: Colors.indigo,
      child: Scaffold(
        appBar: AppBar(title: const Text("Exemple 02 - l'état")),
        // ListView : une colonne qui défile si elle dépasse la hauteur de l'écran
        body: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  children: [
                    // onChanged : appelée à chaque frappe, avec le nouveau texte
                    TextField(
                      controller: _champLibelle,
                      decoration: const InputDecoration(labelText: 'Article'),
                      onChanged: _changerLibelle,
                    ),
                    TextField(
                      controller: _champPrix,
                      decoration: const InputDecoration(labelText: 'Prix unitaire HT'),
                      // le clavier numérique du téléphone, avec la virgule
                      keyboardType: const TextInputType.numberWithOptions(decimal: true),
                      // tryParse : null si le texte n'est pas un nombre ; on accepte la virgule
                      onChanged: (texte) => _changer(() => _prixHT = double.tryParse(texte.replaceAll(',', '.')) ?? 0),
                    ),
                    const SizedBox(height: 12),
                    Row(
                      children: [
                        const Expanded(child: Text("Quantité (revient à 1 si l'article change)")),
                        IconButton(
                          icon: const Icon(Icons.remove),
                          // null : le bouton est désactivé
                          onPressed: _quantite > 0 ? () => _changer(() => _quantite--) : null,
                        ),
                        Text('$_quantite', style: styleTotal),
                        IconButton(icon: const Icon(Icons.add), onPressed: () => _changer(() => _quantite++)),
                      ],
                    ),
                    const SizedBox(height: 8),
                    // des boutons dont UN seul est sélectionné : le taux de TVA
                    SegmentedButton<double>(
                      segments: const [
                        ButtonSegment(value: 5.5, label: Text('5,5 %')),
                        ButtonSegment(value: 10, label: Text('10 %')),
                        ButtonSegment(value: 20, label: Text('20 %')),
                      ],
                      selected: {_tauxTVA},
                      onSelectionChanged: (choix) => _changer(() => _tauxTVA = choix.first),
                    ),
                    const SizedBox(height: 12),
                    OutlinedButton(onPressed: _reinitialiser, child: const Text('Réinitialiser')),
                  ],
                ),
              ),
            ),
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('$_quantite x $_libelle', style: Theme.of(context).textTheme.titleLarge),
                    const Divider(),
                    _ligne('Total HT', euros.format(_totalHT)),
                    _ligne('TVA (${_tauxTVA.toString().replaceAll('.', ',')} %)', euros.format(_montantTVA)),
                    _ligne('Total TTC', euros.format(_totalTTC), style: styleTotal),
                  ],
                ),
              ),
            ),
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('Historique du total TTC', style: Theme.of(context).textTheme.titleSmall),
                    // une ligne de texte par élément de l'historique (cf. exemple 03)
                    for (final ligne in _historique) Text('• $ligne'),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  // une ligne "libellé ....... montant" : une méthode qui fabrique des widgets
  Widget _ligne(String libelle, String montant, {TextStyle? style}) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        children: [
          Expanded(child: Text(libelle, style: style)),
          Text(montant, style: style),
        ],
      ),
    );
  }
}
  • ligne 21 : un formateur de montants, à la française (1 234,50 €), créé une fois pour toutes hors des widgets, avec le paquet [intl] (cf. exemple 09). [final] : une variable qu'on n'affecte qu'une fois, mais dont la valeur n'est connue qu'à l'exécution (contrairement à [const]) ;
  • lignes 23-28 : le programme ; cette fois, le [MaterialApp] est écrit directement dans [main]. C'est ainsi que commenceront la plupart des exemples ;
  • lignes 42-45 : quatre données d'état, de simples champs, typés : [String], [double], [int]. En [Dart], [int] et [double] sont deux types de nombres distincts ;
  • ligne 47 : l'historique des cinq derniers changements du total, une liste. Le champ est [final] : on ne remplacera jamais la liste, on la modifiera (ajout, retrait) ;
  • lignes 52-53 : deux contrôleurs de champs de saisie. Un champ de saisie [Flutter] garde son texte dans un contrôleur, qu'on crée et qu'on lui donne : on peut alors lire ou changer le texte depuis le code (bouton Réinitialiser). L'exemple 05 les étudie en détail ;
  • lignes 56-58 : trois valeurs calculées à partir de l'état. Ce ne sont PAS des états : ce sont des getters, des méthodes sans parenthèses qui s'utilisent comme des champs ([_totalTTC]). Elles sont recalculées à chaque lecture : elles sont donc toujours à jour. C'est la règle déjà rencontrée avec [React] : on ne range dans l'état que ce qu'on ne peut pas calculer ; tout le reste se calcule ;
  • lignes 63-73 : l'historique des totaux TTC dépend des valeurs successives du total, ce qu'un getter ne sait pas exprimer. On fait donc passer TOUS les changements d'état par la méthode [_changer], qui reçoit en paramètre le changement à faire (une fonction sans paramètre ni résultat, de type [void Function()]) : elle note le total avant, applique le changement dans [setState], compare avec le total après, et complète l'historique s'il a changé. C'est l'endroit naturel pour ce calcul : là où l'état change, et non dans [build] (qui doit seulement décrire l'écran) ;
  • lignes 78-81 : changer d'article remet la quantité à 1. On le fait dans la méthode qui traite le changement du champ, là où l'on sait pourquoi l'état change ;
  • lignes 85-93 : plusieurs changements d'état dans un seul [setState] : [build] ne sera appelée qu'une fois. Puis les contrôleurs reçoivent leurs textes de départ : les champs les affichent aussitôt ;
  • lignes 98-101 : [dispose] est appelée quand l'objet [State] disparaît pour toujours (on quitte l'écran). On y libère ce qu'on a créé : les contrôleurs, ici. L'exemple 13 présentera la vie d'un objet [State].

La méthode [build] :

  • ligne 106 : un style de texte, calculé à partir du thème : celui des titres moyens ([titleMedium]), en gras. [copyWith] fabrique une copie d'un objet dont on change quelques propriétés : c'est une convention de [Flutter], qu'on retrouvera souvent. Le [!] affirme à [Dart] que la valeur n'est pas [null] (le type de [titleMedium] est [TextStyle?], un style ou [null]) ;
  • lignes 109-111 : le widget [Title] donne son titre à l'application : sur le web, c'est le titre de l'onglet du navigateur ; sur [Android], le nom affiché dans la liste des applications récentes. Là où [React] utilisait un effet ([useEffect]) pour modifier [document.title] après l'affichage, [Flutter] n'a besoin de rien de particulier : le titre est un widget, décrit par [build] comme le reste de l'écran, et il suit l'état tout seul ;
  • ligne 115 : [ListView] est une colonne qui défile quand elle dépasse la hauteur de l'écran. Sur un téléphone, c'est presque toujours ce qu'il faut : une [Column] trop haute serait coupée (et [Flutter] afficherait une bande jaune et noire, le signe d'un « débordement ») ;
  • lignes 124-127 : un champ de saisie ([TextField]) : son contrôleur, son libellé ([decoration: InputDecoration(labelText: ...)]), et [onChanged], la fonction appelée à chaque frappe, avec le nouveau texte ;
  • lignes 130-135 : le prix. [keyboardType] choisit le clavier du téléphone : ici, le clavier numérique, avec la virgule. Le texte tapé est converti en nombre par [double.tryParse], qui renvoie [null] si le texte n'est pas un nombre ([?? 0] : dans ce cas, 0). On accepte la virgule française en la remplaçant par un point ;
  • lignes 138-147 : la quantité, avec deux boutons « - » et « + ». [Row] place ses enfants côte à côte ; [Expanded] donne à son enfant toute la largeur restante. [IconButton] : un bouton qui n'est qu'une icône. [onPressed: null] désactive un bouton : on ne peut pas descendre sous 0 ;
  • lignes 152-159 : le taux de TVA, trois boutons « segmentés » dont un seul est choisi. [selected] reçoit un ensemble de valeurs ([{_tauxTVA}] : un [Set] d'un élément), [onSelectionChanged] le nouvel ensemble choisi ;
  • lignes 173-177 : les résultats : trois lignes fabriquées par la méthode [_ligne] ;
  • ligne 190 : une ligne de texte par élément de l'historique, fabriquée par une boucle [for] écrite dans la liste des enfants (cf. exemple 03) ;
  • lignes 202-208 : une méthode qui fabrique des widgets. [{TextStyle? style}] : un paramètre nommé, facultatif, qui vaut [null] s'il n'est pas fourni. Découper un [build] en méthodes le rend plus lisible ; l'exemple 10 montrera qu'il vaut souvent mieux en faire de vrais widgets.

On modifie le prix, la quantité et le taux, puis l'article :

À chaque changement du total TTC, une ligne s'ajoute à l'historique. Changer l'article remet la quantité à 1 (à droite).

La différence avec [React] tient dans un détail : [React] rappelle la fonction du composant, qui recrée ses variables à chaque fois ; l'état doit donc être rangé à part, par [useState]. En [Flutter], l'état vit dans un objet ([State]), créé une fois et conservé : ce sont ses champs, et [build] n'est qu'une de ses méthodes. On n'a donc besoin ni de hooks, ni de leurs règles (ne pas les appeler dans une condition, gérer leurs dépendances). La règle d'or est la même : on ne modifie l'état que dans [setState], et [build] ne fait que décrire l'écran à partir de l'état.

3.3. Exemple 03 : écrire un arbre de widgets

Il n'y a pas de langage de gabarits en [Flutter] : l'écran est écrit en [Dart]. Pour les conditions et les boucles, [Dart] a des outils faits pour fabriquer des listes d'enfants. Cet exemple affiche une liste de médecins, qu'on peut filtrer :

// =========================================================================
// lib/ex03_widgets/main.dart - écrire un arbre de widgets
// -------------------------------------------------------------------------
// Flutter n'a pas de langage de gabarits (pas de HTML, pas de JSX) : les
// écrans sont écrits en Dart, avec des constructeurs de widgets imbriqués.
// Pour les conditions et les boucles, Dart offre des outils faits pour
// fabriquer des LISTES d'enfants (children: [...]) :
//   - if (condition) widget          : un enfant qui n'EXISTE que si la
//                                      condition est vraie ("collection if") ;
//   - if (c) widgetA else widgetB    : l'un OU l'autre ;
//   - condition ? widgetA : widgetB  : pareil, dans une expression ;
//   - for (final x in liste) widget  : un enfant par élément de la liste
//                                      ("collection for") ;
//   - ...autreListe                  : les éléments d'une autre liste,
//                                      insérés à cet endroit ("spread") ;
//   - switch (valeur) { motif => ... } : une valeur parmi plusieurs.
// Et tout ce qui est avant le return de build() est du code ordinaire.
// =========================================================================

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

// une "énumération" : les seules valeurs possibles d'une spécialité
enum Specialite { generaliste, pediatre, cardiologue }

// une classe de données : ses champs sont final, un objet Medecin ne change
// jamais. Le constructeur "const" permet d'en faire des constantes
class Medecin {
  final int id;
  final String titre;
  final String prenom;
  final String nom;
  final Specialite specialite;
  final bool disponible;

  const Medecin(this.id, this.titre, this.prenom, this.nom, this.specialite, this.disponible);
}

const medecins = [
  Medecin(1, 'Mme', 'Marie', 'PELISSIER', Specialite.generaliste, true),
  Medecin(2, 'Mr', 'Paul', 'TATOU', Specialite.pediatre, false),
  Medecin(3, 'Melle', 'Sophie', 'DUPONT', Specialite.cardiologue, true),
];

// "une valeur parmi plusieurs, selon une valeur" : une EXPRESSION switch.
// Le compilateur vérifie que tous les cas de l'énumération sont traités
String libelle(Specialite s) => switch (s) {
  Specialite.generaliste => 'généraliste',
  Specialite.pediatre => 'pédiatre',
  Specialite.cardiologue => 'cardiologue',
};

Icon icone(Specialite s) => switch (s) {
  Specialite.pediatre => const Icon(Icons.child_care, color: Colors.orange),
  Specialite.cardiologue => const Icon(Icons.favorite, color: Colors.red),
  _ => const Icon(Icons.medical_services, color: Colors.teal), // "_" : tous les autres cas
};

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageMedecins(),
    ),
  );
}

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

  @override
  State<PageMedecins> createState() => _PageMedecinsState();
}

class _PageMedecinsState extends State<PageMedecins> {
  String _filtre = ''; // le texte recherché
  bool _seulementDisponibles = false;
  bool _afficherAide = true;

  @override
  Widget build(BuildContext context) {
    // la liste affichée : une variable ordinaire, recalculée à chaque build()
    final texte = _filtre.trim().toLowerCase();
    final affiches = medecins
        .where(
          (m) =>
              (!_seulementDisponibles || m.disponible) &&
              '${m.prenom} ${m.nom} ${libelle(m.specialite)}'.toLowerCase().contains(texte),
        )
        .toList();

    // "l'un des trois blocs" : on le calcule AVANT le return, avec des if
    final Widget contenu;
    if (medecins.isEmpty) {
      contenu = const Text('Aucun médecin.');
    } else if (affiches.isEmpty) {
      contenu = Text('Aucun médecin ne correspond à « $_filtre ».', style: const TextStyle(color: Colors.deepOrange));
    } else {
      contenu = Column(
        children: [
          // un enfant par médecin : "collection for", avec son rang (indexed)
          for (final (i, m) in affiches.indexed)
            // Opacity : les médecins indisponibles sont grisés
            Opacity(
              opacity: m.disponible ? 1 : 0.5,
              // ListTile : une ligne de liste Material (icône, titre, sous-titre, fin)
              child: ListTile(
                leading: CircleAvatar(child: Text('${i + 1}')),
                title: Text('${m.titre} ${m.prenom} ${m.nom}'),
                // Wrap : comme une Row, mais qui passe à la ligne si la place manque
                subtitle: Wrap(
                  spacing: 6,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  children: [icone(m.specialite), Text(libelle(m.specialite))],
                ),
                trailing: Chip(
                  label: Text(m.disponible ? 'disponible' : 'indisponible'),
                  // une couleur choisie par une expression conditionnelle
                  backgroundColor: m.disponible ? Colors.green.shade100 : Colors.grey.shade300,
                ),
              ),
            ),
        ],
      );
    }

    return Scaffold(
      appBar: AppBar(
        // une image SVG livrée avec l'application (dossier assets/, cf. pubspec.yaml)
        leading: Padding(padding: const EdgeInsets.all(12), child: SvgPicture.asset('assets/medecin.svg')),
        title: const Text('Exemple 03 - les widgets'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          TextField(
            decoration: const InputDecoration(hintText: 'rechercher...', prefixIcon: Icon(Icons.search)),
            onChanged: (valeur) => setState(() => _filtre = valeur),
          ),
          // SwitchListTile : un interrupteur, avec son libellé
          SwitchListTile(
            title: const Text('disponibles seulement'),
            value: _seulementDisponibles,
            onChanged: (valeur) => setState(() => _seulementDisponibles = valeur),
          ),
          Align(
            alignment: Alignment.centerLeft,
            child: TextButton(
              onPressed: () => setState(() => _afficherAide = !_afficherAide),
              child: Text(_afficherAide ? "Cacher l'aide" : "Montrer l'aide"),
            ),
          ),
          // "collection if" : ce widget n'EXISTE que si _afficherAide est vrai
          if (_afficherAide)
            Card(
              color: Colors.lightBlue.shade50,
              child: const Padding(
                padding: EdgeInsets.all(12),
                child: Text('Tapez un nom ou une spécialité. Les médecins indisponibles sont grisés.'),
              ),
            ),
          contenu,
          const SizedBox(height: 12),
          // un style CALCULÉ : la taille du texte dépend du nombre de médecins affichés
          Text(
            '${affiches.length} médecin(s) affiché(s) - la taille de ce texte en dépend.',
            style: TextStyle(fontSize: 12 + affiches.length * 2.0),
          ),
        ],
      ),
    );
  }
}
  • ligne 24 : une énumération : les trois seules valeurs possibles d'une spécialité ([Specialite.pediatre]...) ;
  • lignes 28-36 : une classe de données. Ses champs sont [final] : un [Medecin] ne change jamais. Le constructeur [Medecin(this.id, this.titre, ...)] range directement ses arguments dans les champs de même nom. Il est [const] : on peut fabriquer des médecins constants (ligne 39) ;
  • lignes 47-50 : « une valeur parmi plusieurs, selon une valeur » : une expression [switch] (et non une instruction), qui a une valeur. Chaque cas s'écrit [motif => valeur]. Sur une énumération, le compilateur vérifie que tous les cas sont traités : ajouter une spécialité sans compléter ce [switch] serait une erreur de compilation ;
  • lignes 53-56 : le même principe pour choisir une icône ; le motif [_] signifie « tous les autres cas ». Les icônes de [Material Design] sont des constantes de la classe [Icons] ;
  • lignes 77-79 : l'état de l'écran.

La méthode [build] :

  • lignes 84-91 : la liste affichée : une variable, recalculée à chaque appel de [build]. [where] garde les éléments qui vérifient une condition ; [toList()] fait du résultat une liste ;
  • lignes 94-100 : un contenu parmi trois, choisi avec des [if] avant le [return]. Un widget est une valeur comme une autre : on peut le ranger dans une variable. [final Widget contenu;] déclare la variable sans l'initialiser : le compilateur vérifie que chaque branche lui donne une valeur, une seule fois ;
  • lignes 103-117 : la liste. Un [for] écrit dans une liste littérale ([children: [ ... ]]) y ajoute un élément par tour de boucle : c'est un « collection for ». [affiches.indexed] fournit des paires (rang, médecin), que [(i, m)] « déstructure » en deux variables ;
  • ligne 106 : [Opacity] rend son enfant à moitié transparent : les médecins indisponibles sont grisés. La valeur est calculée par l'opérateur [? :] ;
  • lignes 108-112 : [ListTile] est la ligne de liste de [Material Design] : un élément de tête ([leading], ici un disque avec le rang), un titre, un sous-titre, un élément de fin ([trailing]). Tous sont des widgets : n'importe quels widgets. Le sous-titre est un [Wrap] : une [Row] qui passe à la ligne quand la place manque (sur un téléphone, l'icône et la spécialité ne tiendraient pas toujours à côté de l'étiquette « disponible ») ;
  • lignes 117-120 : une « puce » ([Chip]), dont le texte et la couleur dépendent du médecin. [Colors.green.shade100] : une nuance claire de vert.

Le [return] :

  • ligne 131 : une image SVG, livrée avec l'application (dossier [assets/], déclaré dans [pubspec.yaml]). [Flutter] ne sait pas dessiner le SVG tout seul : le paquet [flutter_svg] fournit le widget [SvgPicture]. Pour une image PNG ou JPEG, [Flutter] a le widget [Image.asset] ;
  • lignes 138-139 : le champ de recherche. [hintText] est le texte grisé affiché tant que le champ est vide ; [prefixIcon], une icône à gauche. Cette fois, pas de contrôleur : on n'a besoin que de la valeur tapée, reçue par [onChanged] ;
  • lignes 142-145 : un interrupteur, avec son libellé : une ligne de liste qu'on touche pour basculer ;
  • ligne 150 : une fonction anonyme, écrite directement dans le paramètre : elle inverse l'état ;
  • lignes 155-160 : un « collection if » : la carte d'aide n'existe que si [_afficherAide] est vrai. Il n'y a pas de widget « caché » : quand la condition est fausse, la liste des enfants a un élément de moins ;
  • ligne 163 : le contenu choisi plus haut ;
  • lignes 167-168 : un style calculé : la taille du texte dépend du nombre de médecins affichés.

Pour résumer les outils de [Dart] qui remplacent, dans une liste d'enfants, ce que d'autres frameworks écrivent dans leurs gabarits :

En Dart
Rôle
if (condition) widget
un enfant qui n'existe que si la condition est vraie
if (c) widgetA else widgetB
l'un ou l'autre
condition ? widgetA : widgetB
l'un ou l'autre, dans une expression
for (final x in liste) widget
un enfant par élément de la liste
...autreListe
les éléments d'une autre liste, insérés à cet endroit
...[widgetA, widgetB]
plusieurs enfants d'un coup (souvent après un if)
switch (valeur) { motif => widget }
un enfant parmi plusieurs, selon une valeur

Au démarrage, puis après avoir tapé « gén » et caché l'aide, puis avec « xyz » :

3.4. Exemple 04 : les événements

Un widget interactif reçoit en paramètres les fonctions à appeler quand il se passe quelque chose : on les appelle souvent des « callbacks ». Cet exemple gère une liste de tâches :

// =========================================================================
// lib/ex04_evenements/main.dart - réagir aux actions de l'utilisateur
// -------------------------------------------------------------------------
// Un widget interactif reçoit en paramètres les FONCTIONS à appeler quand
// il se passe quelque chose ("callbacks") :
//   - onPressed: f                  : f est appelée à l'appui (boutons) ;
//   - onPressed: () => supprimer(t) : une fonction anonyme, pour passer ses
//                                     propres arguments ;
//   - onChanged, onSubmitted...     : la fonction reçoit une donnée (le
//                                     texte, la case cochée...) ;
//   - GestureDetector               : ajoute des gestes (appui, appui long,
//                                     double appui, glissement...) à
//                                     N'IMPORTE QUEL widget ;
//   - Dismissible                   : faire glisser une ligne pour la retirer,
//                                     le geste typique d'un mobile.
// Pour agir sur un champ de saisie depuis le code (lire ou effacer son
// texte, lui donner le focus), on lui associe un CONTRÔLEUR et un FocusNode.
// =========================================================================

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

class Tache {
  final int id;
  final String libelle;
  bool faite;

  Tache(this.id, this.libelle, {this.faite = false});
}

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageTaches(),
    ),
  );
}

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

  @override
  State<PageTaches> createState() => _PageTachesState();
}

class _PageTachesState extends State<PageTaches> {
  final List<Tache> _taches = [
    Tache(1, 'Installer Flutter', faite: true),
    Tache(2, 'Lire le chapitre sur les événements'),
  ];
  final List<String> _journal = [];
  // le contrôleur du champ : son texte (lecture, écriture) ;
  // le FocusNode : son focus (le curseur et le clavier du téléphone)
  final _champ = TextEditingController();
  final _focus = FocusNode();
  // un champ de l'objet State qui n'est PAS affiché : le modifier ne
  // demande aucun setState
  int _prochainId = 3;

  @override
  void dispose() {
    _champ.dispose();
    _focus.dispose();
    super.dispose();
  }

  void _tracer(String message) {
    final t = TimeOfDay.now();
    _journal.insert(0, '${t.format(context)} $message');
  }

  // appelée par le bouton ET par la touche Entrée du clavier (onSubmitted)
  void _ajouter() {
    final libelle = _champ.text.trim();
    if (libelle.isEmpty) {
      return;
    }
    setState(() {
      _taches.add(Tache(_prochainId++, libelle));
      _tracer('ajout de « $libelle »');
    });
    _champ.clear();
    // le focus revient au champ : on peut taper la tâche suivante
    _focus.requestFocus();
  }

  void _basculer(Tache tache, bool faite) {
    setState(() {
      tache.faite = faite;
      _tracer('« ${tache.libelle} » : ${faite ? 'faite' : 'à faire'}');
    });
  }

  void _supprimer(Tache tache, String comment) {
    setState(() {
      _taches.remove(tache);
      _tracer('suppression de « ${tache.libelle} » ($comment)');
    });
  }

  // les détails d'un appui : sa position, relative au widget touché
  void _position(TapDownDetails details) {
    final p = details.localPosition;
    setState(() => _tracer('appui en (${p.dx.round()}, ${p.dy.round()})'));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Exemple 04 - les événements')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Row(
            children: [
              Expanded(
                // CallbackShortcuts : des raccourcis clavier (un clavier
                // physique : ordinateur, émulateur, tablette) ; Échap vide le champ
                child: CallbackShortcuts(
                  bindings: {const SingleActivator(LogicalKeyboardKey.escape): _champ.clear},
                  child: TextField(
                    controller: _champ,
                    focusNode: _focus,
                    decoration: const InputDecoration(hintText: 'nouvelle tâche (Échap pour vider)'),
                    // la touche "Entrée" du clavier du téléphone
                    textInputAction: TextInputAction.done,
                    onSubmitted: (_) => _ajouter(),
                  ),
                ),
              ),
              const SizedBox(width: 8),
              FilledButton(onPressed: _ajouter, child: const Text('Ajouter')),
            ],
          ),
          const SizedBox(height: 12),
          for (final t in _taches)
            // Dismissible : faire glisser la ligne vers la gauche la retire.
            // key : l'IDENTITÉ de la ligne, pour que Flutter sache laquelle
            // a disparu (cf. l'explication du cours)
            Dismissible(
              key: ValueKey(t.id),
              direction: DismissDirection.endToStart,
              background: Container(
                color: Colors.red.shade100,
                alignment: Alignment.centerRight,
                padding: const EdgeInsets.only(right: 16),
                child: const Icon(Icons.delete),
              ),
              onDismissed: (_) => _supprimer(t, 'glissement'),
              child: CheckboxListTile(
                // la case est À GAUCHE, comme sur une liste de tâches
                controlAffinity: ListTileControlAffinity.leading,
                value: t.faite,
                // la case transmet sa nouvelle valeur (bool?, d'où le "!")
                onChanged: (coche) => _basculer(t, coche!),
                title: Text(
                  t.libelle,
                  style: t.faite ? const TextStyle(decoration: TextDecoration.lineThrough, color: Colors.grey) : null,
                ),
                // une fonction anonyme : la tâche de CETTE ligne en argument
                secondary: IconButton(
                  icon: const Icon(Icons.delete_outline, color: Colors.red),
                  onPressed: () => _supprimer(t, 'bouton'),
                ),
              ),
            ),
          const SizedBox(height: 12),
          // GestureDetector : un widget quelconque devient sensible aux gestes
          GestureDetector(
            onTapDown: _position,
            onDoubleTap: () => setState(() => _tracer('double appui')),
            onLongPress: () => setState(() => _tracer('appui long')),
            child: Container(
              height: 80,
              alignment: Alignment.center,
              decoration: BoxDecoration(color: const Color(0xFFE7F5FF), borderRadius: BorderRadius.circular(8)),
              child: const Text('Touchez cette zone (appui, double appui, appui long)'),
            ),
          ),
          const SizedBox(height: 16),
          Text('Journal', style: Theme.of(context).textTheme.titleSmall),
          for (final ligne in _journal) Text(ligne, style: const TextStyle(fontSize: 12)),
        ],
      ),
    );
  }
}
  • lignes 23-28 : une tâche. [faite] n'est pas [final] : on la modifiera. [{this.faite = false}] : un paramètre nommé facultatif, avec sa valeur par défaut ;
  • lignes 49-53 : l'état : la liste des tâches et un journal des événements ;
  • lignes 56-57 : pour agir sur le champ de saisie depuis le code, on lui associe deux objets : un contrôleur ([TextEditingController]), qui donne accès à son texte, et un [FocusNode], qui donne accès à son focus (le curseur clignotant, et le clavier du téléphone, qui s'ouvre quand un champ a le focus) ;
  • ligne 60 : un champ de l'objet [State] qui n'est pas affiché : le modifier ne demande pas de [setState]. C'est ce que [React] appelle une « référence » ([useRef]) ; en [Flutter], c'est un champ ordinaire ;
  • lignes 63-66 : le contrôleur et le [FocusNode] sont libérés quand l'écran disparaît ;
  • lignes 69-71 : une ligne de plus en tête du journal. [TimeOfDay] est l'heure du jour de [Flutter] ; [format(context)] l'écrit selon les usages de la langue de l'application ;
  • lignes 75-86 : l'ajout d'une tâche, appelé par le bouton ET par la touche Entrée du clavier. On lit le texte dans le contrôleur ([_champ.text]), on ajoute la tâche (dans [setState]), on vide le champ ([_champ.clear()]) et on lui rend le focus ([_focus.requestFocus()]) : on peut taper la tâche suivante ;
  • lignes 89-92 : cocher ou décocher une tâche : on modifie l'objet [Tache] lui-même, dans [setState]. Contrairement à [React], rien n'oblige à fabriquer une nouvelle liste : [setState] prévient [Flutter] que l'état a changé, quelle que soit la façon dont il a changé ;
  • lignes 96-99 : la suppression, par le bouton ou par un glissement (cf. plus loin) ;
  • lignes 104-106 : la fonction appelée quand on touche la zone du bas. Elle reçoit les détails de l'événement : ici, [localPosition], la position du doigt, relative au widget touché.

La méthode [build] :

  • lignes 121-122 : [CallbackShortcuts] associe des raccourcis clavier à des fonctions, pour un clavier physique (ordinateur, émulateur, tablette avec clavier) : la touche Échap vide le champ. [{ touche: fonction }] est une [Map] littérale ;
  • lignes 124-129 : le champ de saisie, relié à son contrôleur et à son [FocusNode]. [textInputAction] choisit la touche « Entrée » du clavier du téléphone ; [onSubmitted] est appelée quand on l'appuie, avec le texte (qu'on n'utilise pas : le paramètre s'appelle [_], par convention) ;
  • ligne 134 : le bouton appelle la même méthode ;
  • lignes 138-151 : une ligne par tâche, dans un [Dismissible] : un widget qu'on fait glisser du doigt pour le retirer, le geste typique d'une liste sur un téléphone. [direction] : seulement vers la gauche ; [background] : ce qui apparaît sous la ligne pendant le glissement (une icône de corbeille sur fond rouge) ; [onDismissed] : la fonction appelée quand la ligne est sortie de l'écran ;
  • ligne 143 : la clé du [Dismissible]. Quand une liste de widgets change, [Flutter] compare l'ancienne liste à la nouvelle pour savoir quels widgets garder : sans clé, il les associe par leur rang (le 1er au 1er, le 2e au 2e...). Si la première tâche disparaît, la deuxième prendrait sa place... avec l'état de la première. Une clé donne à chaque widget une identité : [Flutter] associe alors les widgets par leur clé. Elle doit être unique parmi les frères, et stable : l'identifiant de la tâche, pas son rang. Un [Dismissible] exige une clé ;
  • lignes 152-157 : une ligne de liste avec une case à cocher. [controlAffinity] place la case à gauche. [onChanged] reçoit la nouvelle valeur de la case, de type [bool?] (une case peut avoir un troisième état, « indéterminé ») : le [!] affirme qu'elle n'est pas [null] ;
  • ligne 160 : le style du libellé dépend de la tâche : barré et gris si elle est faite, le style par défaut ([null]) sinon ;
  • lignes 163-165 : un bouton « corbeille ». La fonction anonyme [() => _supprimer(t, 'bouton')] permet de passer des arguments : la tâche de cette ligne ;
  • lignes 171-174 : [GestureDetector] rend n'importe quel widget sensible aux gestes : appui ([onTap], [onTapDown]...), double appui, appui long, glissement, pincement... Ici, une simple boîte colorée ([Container]) réagit à trois gestes ;
  • lignes 175-178 : [Container] est une boîte, à la fois marge, fond, bordure, taille et alignement. [decoration] décrit son fond : une couleur, des coins arrondis. [Color(0xFFE7F5FF)] : une couleur donnée par son code ARGB (opacité FF, puis rouge, vert, bleu) ;
  • ligne 184 : le journal.

On ajoute une tâche, on coche la deuxième, on touche la zone du bas ; puis on fait glisser la première tâche vers la gauche (au milieu, pendant le glissement ; à droite, une fois la ligne retirée) :

Chez [React], les événements du navigateur ([click], [keydown]...) sont les mêmes pour tous les éléments ; en [Flutter], chaque widget interactif déclare les siens, avec des paramètres typés ([onPressed], [onChanged], [onSubmitted]...), et [GestureDetector] ajoute les gestes à ceux qui n'en ont pas. L'éditeur, qui connaît les paramètres de chaque widget, les propose à la frappe.

3.5. Exemple 05 : tous les types de champs

Cet exemple reprend le formulaire de l'exemple 05 du cours [React], avec les champs de saisie de [Flutter] : zones de texte, boutons radio, cases à cocher, listes déroulantes, puces, curseur et calendrier.

// =========================================================================
// lib/ex05_champs_controles/main.dart - tous les types de champs
// -------------------------------------------------------------------------
// Un champ CONTRÔLÉ affiche une valeur de l'état et prévient le widget de
// chaque changement (onChanged), qui met l'état à jour : l'état est la
// seule source de vérité. Modifier l'état (par le code) modifie le champ.
// Ce que transmet onChanged dépend du champ :
//   - TextField (avec un contrôleur)   -> le texte (String) ;
//   - Checkbox, Switch                 -> bool? / bool ;
//   - Radio (dans un RadioGroup)       -> la valeur du bouton choisi ;
//   - DropdownButton                   -> la valeur de l'élément choisi ;
//   - FilterChip                       -> bool : la puce est-elle choisie ?
//   - Slider                           -> double ;
//   - showDatePicker (une fenêtre)     -> un Future<DateTime?>.
// Le TextField est un cas à part : il garde son texte dans un
// TextEditingController, qu'on crée, qu'on lit (controller.text), qu'on
// modifie... et qu'on libère (dispose).
// Même formulaire que l'exemple 05 du cours React ; tout se passe sur le
// téléphone, sans serveur.
// =========================================================================

import 'dart:convert';

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

const titres = ['Mr', 'Mme', 'Melle'];
const loisirs = ['sport', 'musique', 'lecture', 'cinéma'];
const pays = {'fr': 'France', 'be': 'Belgique', 'ch': 'Suisse'};
const langues = {'fr': 'français', 'en': 'anglais', 'es': 'espagnol'};

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      // les textes de Flutter lui-même (calendrier, boutons OK / ANNULER) en
      // français (cf. exemple 21)
      locale: const Locale('fr'),
      supportedLocales: const [Locale('fr'), Locale('en')],
      localizationsDelegates: GlobalMaterialLocalizations.delegates,
      home: const PageFormulaire(),
    ),
  );
}

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

  @override
  State<PageFormulaire> createState() => _PageFormulaireState();
}

class _PageFormulaireState extends State<PageFormulaire> {
  // un état par champ du formulaire
  String _titre = 'Mme';
  final _nom = TextEditingController();
  final _age = TextEditingController(text: '30');
  String _presentation = '';
  bool _newsletter = false;
  final Set<String> _loisirs = {'lecture'}; // un ENSEMBLE : pas de doublon
  String _pays = 'fr';
  final Set<String> _langues = {};
  DateTime? _naissance; // null : pas encore choisie
  double _satisfaction = 3;
  String? _envoye;

  @override
  void initState() {
    super.initState();
    // un contrôleur PRÉVIENT ses écouteurs à chaque changement du texte :
    // on redessine l'écran (le modèle affiché en direct)
    _nom.addListener(_redessiner);
    _age.addListener(_redessiner);
  }

  void _redessiner() => setState(() {});

  @override
  void dispose() {
    _nom.dispose();
    _age.dispose();
    super.dispose();
  }

  // "2026-10-05" : le format des dates de l'API RdvMedecins
  String? get _naissanceTexte => _naissance?.toIso8601String().substring(0, 10);

  // toutes les valeurs, réunies en un objet (ce qu'on enverrait à un serveur)
  Map<String, Object?> get _modele => {
    'titre': _titre,
    'nom': _nom.text.trim(),
    'age': int.tryParse(_age.text), // null si le champ est vide
    'presentation': _presentation,
    'newsletter': _newsletter,
    'loisirs': _loisirs.toList(),
    'pays': _pays,
    'langues': _langues.toList(),
    'naissance': _naissanceTexte,
    'satisfaction': _satisfaction.round(),
  };

  // la date de naissance : une fenêtre de calendrier. showDatePicker renvoie
  // un Future, la date choisie (ou null : fenêtre fermée sans choix)
  Future<void> _choisirNaissance() async {
    final choix = await showDatePicker(
      context: context,
      initialDate: _naissance ?? DateTime(2000, 1, 1),
      firstDate: DateTime(1900),
      lastDate: DateTime.now(),
    );
    if (choix != null) {
      setState(() => _naissance = choix);
    }
  }

  // l'état modifié PAR LE CODE : les champs suivent
  void _exemple() {
    setState(() {
      _titre = 'Mr';
      _nom.text = 'MARTIN';
      _age.text = '42';
      _loisirs
        ..clear()
        ..addAll(['sport', 'cinéma']);
      _pays = 'be';
    });
  }

  void _envoyer() {
    // JsonEncoder.withIndent : du JSON indenté ; jsonEncode : sur une ligne
    setState(() => _envoye = jsonEncode(_modele));
  }

  @override
  Widget build(BuildContext context) {
    final styleTitre = Theme.of(context).textTheme.titleSmall;
    return Scaffold(
      appBar: AppBar(title: const Text('Exemple 05 - les champs contrôlés')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text('Titre', style: styleTitre),
          // RadioGroup : la valeur choisie (groupValue) et la fonction appelée
          // quand un des boutons Radio qu'il contient est choisi
          RadioGroup<String>(
            groupValue: _titre,
            onChanged: (valeur) => setState(() => _titre = valeur!),
            child: Row(
              children: [
                for (final t in titres) ...[Radio<String>(value: t), Text(t), const SizedBox(width: 12)],
              ],
            ),
          ),
          Row(
            children: [
              Expanded(
                flex: 2,
                child: TextField(
                  controller: _nom,
                  decoration: const InputDecoration(labelText: 'Nom'),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: TextField(
                  controller: _age,
                  decoration: const InputDecoration(labelText: 'Âge'),
                  keyboardType: TextInputType.number,
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          // Focus : prévient quand son enfant GAGNE ou PERD le focus. Le champ
          // n'est pas relié à l'état à chaque frappe : son texte n'est lu qu'à
          // la sortie du champ (comme onBlur en HTML)
          Focus(
            onFocusChange: (aLeFocus) {
              if (!aLeFocus) setState(() {});
            },
            child: TextFormField(
              initialValue: _presentation,
              maxLines: 2,
              decoration: const InputDecoration(labelText: 'Présentation (mise à jour à la sortie du champ)'),
              onChanged: (texte) => _presentation = texte, // SANS setState : rien n'est redessiné
            ),
          ),
          // une case seule : true / false
          CheckboxListTile(
            contentPadding: EdgeInsets.zero,
            controlAffinity: ListTileControlAffinity.leading,
            title: const Text("Recevoir la lettre d'information"),
            value: _newsletter,
            onChanged: (coche) => setState(() => _newsletter = coche!),
          ),
          Text('Loisirs', style: styleTitre),
          // plusieurs puces pour UN ensemble : on ajoute ou on retire la valeur
          Wrap(
            spacing: 8,
            children: [
              for (final l in loisirs)
                FilterChip(
                  label: Text(l),
                  selected: _loisirs.contains(l),
                  onSelected: (choisi) => setState(() => choisi ? _loisirs.add(l) : _loisirs.remove(l)),
                ),
            ],
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Text('Pays', style: styleTitre),
              const SizedBox(width: 16),
              // une liste déroulante : value dit quel élément est choisi
              DropdownButton<String>(
                value: _pays,
                items: [
                  for (final MapEntry(key: code, value: nom) in pays.entries)
                    DropdownMenuItem(value: code, child: Text(nom)),
                ],
                onChanged: (code) => setState(() => _pays = code!),
              ),
            ],
          ),
          Text('Langues parlées', style: styleTitre),
          Wrap(
            spacing: 8,
            children: [
              for (final MapEntry(key: code, value: nom) in langues.entries)
                FilterChip(
                  label: Text(nom),
                  selected: _langues.contains(code),
                  onSelected: (choisi) => setState(() => choisi ? _langues.add(code) : _langues.remove(code)),
                ),
            ],
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Text('Satisfaction : ${_satisfaction.round()} / 5', style: styleTitre),
              Expanded(
                // un curseur : 5 crans, de 1 à 5
                child: Slider(
                  value: _satisfaction,
                  min: 1,
                  max: 5,
                  divisions: 4,
                  onChanged: (v) => setState(() => _satisfaction = v),
                ),
              ),
            ],
          ),
          ListTile(
            contentPadding: EdgeInsets.zero,
            leading: const Icon(Icons.cake),
            title: const Text('Date de naissance'),
            subtitle: Text(_naissanceTexte ?? 'non choisie'),
            trailing: OutlinedButton(onPressed: _choisirNaissance, child: const Text('Choisir')),
          ),
          const SizedBox(height: 8),
          // Wrap : les boutons passent à la ligne sur un écran étroit
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              FilledButton(onPressed: _envoyer, child: const Text('Envoyer')),
              OutlinedButton(onPressed: _exemple, child: const Text('Remplir par le code')),
            ],
          ),
          const SizedBox(height: 16),
          Text('Le modèle, en direct', style: styleTitre),
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.grey.shade100,
            // JsonEncoder.withIndent('  ') : le JSON, indenté de 2 espaces
            child: Text(
              const JsonEncoder.withIndent('  ').convert(_modele),
              style: const TextStyle(fontFamily: 'RobotoMono', fontSize: 12),
            ),
          ),
          // un bloc qui n'existe qu'après un envoi ; ...[ ] insère PLUSIEURS widgets
          if (_envoye != null) ...[
            const SizedBox(height: 8),
            Text('Dernier envoi', style: styleTitre),
            Container(
              padding: const EdgeInsets.all(8),
              color: Colors.green.shade50,
              child: Text(_envoye!, style: const TextStyle(fontFamily: 'RobotoMono', fontSize: 12)),
            ),
          ],
        ],
      ),
    );
  }
}

Un champ contrôlé affiche une valeur de l'état et prévient le widget de chaque changement ([onChanged]), qui met l'état à jour : l'état est la seule source de vérité. C'est le cas de tous les champs de [Flutter]... sauf des zones de texte, qui gardent leur texte dans un contrôleur.

  • lignes 39-41 : les textes de [Flutter] lui-même (les jours et les mois du calendrier, les boutons « OK » et « ANNULER » de ses fenêtres) sont en anglais par défaut. On déclare les langues de l'application ([supportedLocales]), la langue choisie ([locale]), et les « délégués » qui fournissent les traductions de [Flutter], dans le paquet [flutter_localizations]. L'exemple 21 y reviendra ;
  • lignes 56-66 : un état par champ. [final _nom = TextEditingController()] : pour une zone de texte, l'état est le contrôleur lui-même ; [Set<String>] : un ensemble (sans doublon) de loisirs, de langues ; [DateTime? _naissance] : une date, ou [null] tant qu'elle n'est pas choisie ;
  • lignes 69-74 : [initState] est appelée une fois, à la création de l'objet [State] (cf. exemple 13). Un contrôleur prévient de chaque changement de son texte ceux qui l'écoutent ([addListener]) : on redessine l'écran, pour que le modèle affiché en bas suive la saisie. [setState(() {})] : on n'a rien à changer, seulement à redessiner ;
  • ligne 87 : la date au format de l'API [RdvMedecins], « 2026-10-05 ». [_naissance?.toIso8601String()] : l'opérateur [?.] n'appelle la méthode que si [_naissance] n'est pas [null] (sinon, le résultat est [null]) ;
  • lignes 90-100 : toutes les valeurs, réunies dans une [Map] (ce qu'on enverrait à un serveur). [int.tryParse(_age.text)] : l'âge, ou [null] si le champ n'est pas un nombre ;
  • lignes 105-113 : la date de naissance se choisit dans une fenêtre de calendrier. [showDatePicker] ouvre la fenêtre et renvoie un [Future] : la date choisie, plus tard, quand l'utilisateur aura fermé la fenêtre (ou [null] s'il l'a fermée sans choisir). La méthode est [async] : [await] attend la fin du [Future] sans bloquer l'application. On retrouvera ce mécanisme partout où il faut attendre (une fenêtre, un serveur) ;
  • lignes 118-126 : l'état modifié par le code : les champs suivent. Pour une zone de texte, on écrit dans son contrôleur. [..clear()..addAll([...])] : la « cascade » de [Dart], plusieurs appels de méthode sur le même objet ;
  • ligne 132 : [jsonEncode] (de [dart:convert]) fabrique le JSON d'une [Map].

Les champs, dans [build] :

  • lignes 146-151 : des boutons radio. Le [RadioGroup] porte la valeur choisie ([groupValue]) et la fonction appelée quand on en choisit une autre ([onChanged]) ; il contient les boutons [Radio], chacun avec sa valeur. [...[a, b, c]] insère plusieurs widgets d'un coup : pour chaque titre, un bouton, son libellé et un espace ;
  • lignes 160-169 : deux zones de texte côte à côte, dans une [Row]. [Expanded(flex: 2, ...)] : le nom prend deux fois plus de place que l'âge. Le clavier du champ âge est numérique ;
  • lignes 178-186 : un champ non contrôlé : [TextFormField] peut recevoir une valeur de départ ([initialValue]) au lieu d'un contrôleur, et gérer lui-même son texte. On range le texte à chaque frappe, SANS [setState] : rien n'est redessiné. Le widget [Focus] prévient quand son enfant gagne ou perd le focus ([onFocusChange]) : on ne redessine qu'à la sortie du champ. C'est l'équivalent de l'événement [blur] du HTML ;
  • lignes 190-195 : une case seule, et son libellé ;
  • lignes 199-206 : plusieurs choix pour UN ensemble : des puces à choisir ([FilterChip]). Chaque puce est choisie si l'ensemble contient sa valeur ; la toucher ajoute ou retire la valeur. [Wrap] place ses enfants côte à côte, et passe à la ligne quand il n'y a plus de place ;
  • lignes 216-222 : une liste déroulante. [value] dit quel élément est choisi ; [items], les éléments, chacun avec sa valeur et son widget. [for (final MapEntry(key: code, value: nom) in pays.entries)] parcourt les entrées de la [Map] [pays] en « déstructurant » chacune en deux variables, [code] et [nom] ;
  • lignes 230-234 : les langues parlées, avec des puces aussi. (Il n'y a pas, en [Flutter], de liste à choix multiples : des puces ou des cases à cocher en tiennent lieu) ;
  • lignes 244-249 : un curseur, de 1 à 5, en quatre intervalles ([divisions]). Sa valeur est un [double] ;
  • lignes 254-259 : la date de naissance, et le bouton qui ouvre le calendrier ;
  • lignes 278-279 : le modèle, en direct : son JSON, indenté de deux espaces, dans la police à chasse fixe livrée avec l'application ;
  • lignes 283-289 : un bloc qui n'existe qu'après un envoi : [if] suivi de [...[ ]] insère plusieurs widgets, ou aucun.

On choisit le titre, un nom, des loisirs, une langue ; puis on ouvre le calendrier ; puis on envoie :

Le tableau suivant résume ce que chaque champ transmet à sa fonction [onChanged] :

Champ
Valeur transmise
TextField, TextFormField
String (le texte) ; ou bien on lit le contrôleur
Checkbox, CheckboxListTile
bool? (coché, décoché, ou indéterminé)
Switch, SwitchListTile
bool
Radio (dans un RadioGroup)
la valeur du bouton choisi (T?)
DropdownButton<T>
la valeur de l'élément choisi (T?)
FilterChip
bool : la puce est-elle choisie ?
SegmentedButton<T>
Set<T> : les valeurs choisies
Slider
double
showDatePicker
Future<DateTime?> : la date choisie, plus tard

3.6. Exemple 06 : un formulaire, un objet, un réducteur

L'exemple 05 avait un champ d'état par champ du formulaire. Pour un vrai formulaire, on préfère un objet qui contient toutes les valeurs, le modèle, et l'on décrit ses changements dans une seule fonction, le réducteur : [reducteur(etatActuel, action) -> nouvelEtat]. C'est ce que fait le hook [useReducer] de [React] ; en [Flutter], il n'y a rien à importer : [Dart] fournit tout ce qu'il faut.

// =========================================================================
// lib/ex06_reducteur/main.dart - un formulaire, un objet, un réducteur
// -------------------------------------------------------------------------
// L'exemple 05 avait un champ d'état par champ du formulaire. Pour un vrai
// formulaire, on préfère UN objet qui contient toutes les valeurs, le
// MODÈLE (ici un objet Profil, non modifiable), et l'on décrit ses
// changements dans une seule fonction, le RÉDUCTEUR :
//
//     reducteur(etatActuel, action) -> nouvelEtat
//
// Le widget ne modifie plus l'état : il DÉCRIT ce qui s'est passé, par un
// objet ActionProfil (Modifier(Champ.nom, 'Dupont'), Quitter(Champ.nom)...), et
// le réducteur en déduit le nouvel état. Toute la logique est en un
// endroit, dans une fonction PURE (même entrée, même sortie, aucun effet
// de bord), facile à tester.
// Dart s'y prête bien : une classe "sealed" énumère toutes les actions
// possibles, et un switch sur une action est vérifié par le compilateur
// (un cas oublié est une erreur).
//
// L'état garde aussi, pour chaque champ, s'il a été "touché" (quitté au
// moins une fois) ; on en déduit s'il est "modifié" (sa valeur diffère de
// la valeur de départ) et s'il est désactivé.
// =========================================================================

import 'dart:convert';

import 'package:flutter/material.dart';

// le nom de chaque champ du formulaire
enum Champ { titre, prenom, nom, age, newsletter, email, pays, naissance, presentation }

// les données du formulaire : un objet NON MODIFIABLE (tous ses champs sont
// final). Pour le "modifier", on en fabrique un nouveau (copyWith)
class Profil {
  final String titre;
  final String prenom;
  final String nom;
  final int age;
  final bool newsletter;
  final String email;
  final String pays;
  final String naissance;
  final String presentation;

  const Profil({
    required this.titre,
    required this.prenom,
    required this.nom,
    required this.age,
    required this.newsletter,
    required this.email,
    required this.pays,
    required this.naissance,
    required this.presentation,
  });

  // une copie, dont on change quelques champs (les autres sont conservés) ;
  // "??" : la nouvelle valeur si elle est fournie, sinon l'ancienne
  Profil copyWith({
    String? titre,
    String? prenom,
    String? nom,
    int? age,
    bool? newsletter,
    String? email,
    String? pays,
    String? naissance,
    String? presentation,
  }) {
    return Profil(
      titre: titre ?? this.titre,
      prenom: prenom ?? this.prenom,
      nom: nom ?? this.nom,
      age: age ?? this.age,
      newsletter: newsletter ?? this.newsletter,
      email: email ?? this.email,
      pays: pays ?? this.pays,
      naissance: naissance ?? this.naissance,
      presentation: presentation ?? this.presentation,
    );
  }

  // la valeur d'un champ, désigné par son nom
  Object valeur(Champ c) => switch (c) {
    Champ.titre => titre,
    Champ.prenom => prenom,
    Champ.nom => nom,
    Champ.age => age,
    Champ.newsletter => newsletter,
    Champ.email => email,
    Champ.pays => pays,
    Champ.naissance => naissance,
    Champ.presentation => presentation,
  };

  // une copie dont UN champ, désigné par son nom, a une nouvelle valeur
  Profil avec(Champ c, Object v) => switch (c) {
    Champ.titre => copyWith(titre: v as String),
    Champ.prenom => copyWith(prenom: v as String),
    Champ.nom => copyWith(nom: v as String),
    Champ.age => copyWith(age: v as int),
    Champ.newsletter => copyWith(newsletter: v as bool),
    Champ.email => copyWith(email: v as String),
    Champ.pays => copyWith(pays: v as String),
    Champ.naissance => copyWith(naissance: v as String),
    Champ.presentation => copyWith(presentation: v as String),
  };

  Map<String, Object> versJson() => {for (final c in Champ.values) c.name: valeur(c)};
}

const depart = Profil(
  titre: 'Mme',
  prenom: 'Marie',
  nom: 'PELISSIER',
  age: 30,
  newsletter: false,
  email: '',
  pays: 'fr',
  naissance: '',
  presentation: '',
);

// l'état complet : les valeurs, et les champs déjà quittés
class Etat {
  final Profil valeurs;
  final Set<Champ> touches;

  const Etat(this.valeurs, this.touches);
}

// les actions possibles. "sealed" : il n'y en a pas d'autres que celles de
// ce fichier, le compilateur le sait
sealed class ActionProfil {
  const ActionProfil();
}

class Modifier extends ActionProfil {
  final Champ champ;
  final Object valeur;
  const Modifier(this.champ, this.valeur);
}

class Quitter extends ActionProfil {
  final Champ champ;
  const Quitter(this.champ);
}

class Majuscules extends ActionProfil {
  const Majuscules();
}

class Reinitialiser extends ActionProfil {
  const Reinitialiser();
}

// le réducteur : il ne MODIFIE jamais l'état reçu, il en renvoie un nouveau.
// Le switch "déstructure" chaque action : Modifier(:final champ, ...) lit
// ses champs dans des variables du même nom
Etat reducteur(Etat etat, ActionProfil action) => switch (action) {
  Modifier(:final champ, :final valeur) => Etat(etat.valeurs.avec(champ, valeur), etat.touches),
  Quitter(:final champ) => Etat(etat.valeurs, {...etat.touches, champ}),
  Majuscules() => Etat(
    etat.valeurs.copyWith(prenom: etat.valeurs.prenom.toUpperCase(), nom: etat.valeurs.nom.toUpperCase()),
    etat.touches,
  ),
  Reinitialiser() => const Etat(depart, {}),
};

const titres = ['Mr', 'Mme', 'Melle'];
const champsSuivis = [Champ.prenom, Champ.nom, Champ.age, Champ.email];
// les champs de texte : chacun aura son contrôleur
const champsTexte = [Champ.prenom, Champ.nom, Champ.email, Champ.presentation];

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageProfil(),
    ),
  );
}

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

  @override
  State<PageProfil> createState() => _PageProfilState();
}

class _PageProfilState extends State<PageProfil> {
  Etat _etat = const Etat(depart, {});
  // un contrôleur par champ de texte ; une "map" : Champ -> contrôleur
  final _controleurs = {for (final c in champsTexte) c: TextEditingController(text: depart.valeur(c) as String)};

  @override
  void dispose() {
    for (final c in _controleurs.values) {
      c.dispose();
    }
    super.dispose();
  }

  // LA seule façon de changer l'état : décrire ce qui s'est passé
  void _dispatch(ActionProfil action) {
    setState(() => _etat = reducteur(_etat, action));
    // le réducteur a pu changer un texte (Majuscules, Réinitialiser) : les
    // champs de saisie doivent le montrer
    for (final MapEntry(key: champ, value: controleur) in _controleurs.entries) {
      final valeur = _etat.valeurs.valeur(champ) as String;
      if (controleur.text != valeur) {
        controleur.text = valeur;
      }
    }
  }

  Profil get _v => _etat.valeurs;

  // des valeurs DÉDUITES de l'état
  bool _modifie(Champ c) => _v.valeur(c) != depart.valeur(c);
  bool _desactive(Champ c) => c == Champ.email && !_v.newsletter; // l'e-mail exige la lettre
  bool get _formulaireModifie => Champ.values.any(_modifie);

  // un champ de texte relié au modèle : une méthode qui le fabrique
  Widget _champ(Champ champ, String libelle, {int lignes = 1}) {
    // Focus : on sait quand le champ est QUITTÉ (il perd le focus)
    return Focus(
      onFocusChange: (aLeFocus) {
        if (!aLeFocus) _dispatch(Quitter(champ));
      },
      child: TextField(
        controller: _controleurs[champ],
        enabled: !_desactive(champ),
        maxLines: lignes,
        decoration: InputDecoration(labelText: libelle),
        onChanged: (texte) => _dispatch(Modifier(champ, texte)),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final styleTitre = Theme.of(context).textTheme.titleSmall;
    return Scaffold(
      appBar: AppBar(title: const Text('Exemple 06 - un réducteur')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          RadioGroup<String>(
            groupValue: _v.titre,
            onChanged: (t) => _dispatch(Modifier(Champ.titre, t!)),
            child: Row(
              children: [
                for (final t in titres) ...[Radio<String>(value: t), Text(t), const SizedBox(width: 12)],
              ],
            ),
          ),
          Row(
            children: [
              Expanded(child: _champ(Champ.prenom, 'Prénom')),
              const SizedBox(width: 12),
              Expanded(child: _champ(Champ.nom, 'Nom')),
            ],
          ),
          Row(
            children: [
              Text('Âge : ${_v.age}', style: styleTitre),
              Expanded(
                child: Slider(
                  value: _v.age.toDouble(),
                  max: 120,
                  divisions: 120,
                  onChanged: (a) => _dispatch(Modifier(Champ.age, a.round())),
                  // le curseur est relâché : le champ a été "quitté"
                  onChangeEnd: (_) => _dispatch(const Quitter(Champ.age)),
                ),
              ),
            ],
          ),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text("Recevoir la lettre d'information"),
            value: _v.newsletter,
            onChanged: (oui) => _dispatch(Modifier(Champ.newsletter, oui)),
          ),
          _champ(Champ.email, "E-mail (si lettre d'information)"),
          const SizedBox(height: 8),
          DropdownButton<String>(
            value: _v.pays,
            items: const [
              DropdownMenuItem(value: 'fr', child: Text('France')),
              DropdownMenuItem(value: 'be', child: Text('Belgique')),
              DropdownMenuItem(value: 'ch', child: Text('Suisse')),
            ],
            onChanged: (p) => _dispatch(Modifier(Champ.pays, p!)),
          ),
          _champ(Champ.presentation, 'Présentation', lignes: 2),
          const SizedBox(height: 12),
          Wrap(
            spacing: 8,
            children: [
              OutlinedButton(onPressed: () => _dispatch(const Majuscules()), child: const Text('Nom en majuscules')),
              OutlinedButton(onPressed: () => _dispatch(const Reinitialiser()), child: const Text('Réinitialiser')),
            ],
          ),
          const SizedBox(height: 16),
          Text('Le modèle, en direct', style: styleTitre),
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.grey.shade100,
            child: Text(
              const JsonEncoder.withIndent('  ').convert(_v.versJson()),
              style: const TextStyle(fontFamily: 'RobotoMono', fontSize: 12),
            ),
          ),
          const SizedBox(height: 16),
          Text("L'état des champs", style: styleTitre),
          // DataTable : un tableau, avec ses colonnes et ses lignes
          DataTable(
            columnSpacing: 16,
            headingRowHeight: 36,
            dataRowMinHeight: 28,
            dataRowMaxHeight: 32,
            columns: const [
              DataColumn(label: Text('champ')),
              DataColumn(label: Text('touché')),
              DataColumn(label: Text('modifié')),
              DataColumn(label: Text('désactivé')),
            ],
            rows: [
              for (final c in champsSuivis)
                DataRow(
                  cells: [
                    DataCell(Text(c.name)),
                    DataCell(Text('${_etat.touches.contains(c)}')),
                    DataCell(Text('${_modifie(c)}')),
                    DataCell(Text('${_desactive(c)}')),
                  ],
                ),
            ],
          ),
          const SizedBox(height: 8),
          Text.rich(
            TextSpan(
              children: [
                const TextSpan(text: 'formulaire modifié : '),
                TextSpan(
                  text: '$_formulaireModifie',
                  style: const TextStyle(fontWeight: FontWeight.bold),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Le modèle :

  • ligne 30 : le nom de chaque champ du formulaire ;
  • lignes 34-43 : les données du formulaire : un objet non modifiable, dont tous les champs sont [final] ;
  • lignes 45-54 : un constructeur à paramètres nommés, tous obligatoires ([required]) ;
  • lignes 59-79 : pour « modifier » un objet non modifiable, on en fabrique une copie, où quelques champs changent : c'est la méthode [copyWith], une convention de [Flutter] (on l'a rencontrée avec [TextStyle]). Chaque paramètre est facultatif ([String? titre] : une chaîne ou [null]) ; [titre ?? this.titre] : la nouvelle valeur si elle est fournie, sinon l'ancienne ;
  • lignes 84-93 : la valeur d'un champ désigné par son nom : une expression [switch] sur l'énumération ;
  • lignes 97-106 : une copie dont UN champ, désigné par son nom, a une nouvelle valeur. [v as String] : la valeur reçue (de type [Object], n'importe quoi) est convertie en chaîne ; si elle n'en était pas une, ce serait une erreur à l'exécution ;
  • ligne 109 : une [Map] « nom du champ -> valeur », fabriquée par un [for] dans une [Map] littérale. [c.name] : le nom d'une valeur d'énumération (« prenom ») ;
  • lignes 112-121 : les valeurs de départ ;
  • lignes 125-129 : l'état complet : les valeurs, et l'ensemble des champs déjà quittés (« touchés »).

Les actions et le réducteur :

  • lignes 134-154 : les actions possibles : une classe scellée ([sealed]) et ses quatre sous-classes. [sealed] dit au compilateur qu'il n'existe pas d'autres sous-classes que celles de ce fichier. Une action décrit ce qui s'est passé : [Modifier(Champ.nom, 'Dupont')], [Quitter(Champ.nom)]... ;
  • lignes 160-167 : le réducteur. C'est une fonction pure : même entrée, même sortie, aucun effet de bord ; il ne modifie jamais l'état reçu, il en renvoie un nouveau. Le [switch] porte sur l'action, et chaque cas est un motif qui teste son type : [Modifier(:final champ, :final valeur)] vérifie que l'action est un [Modifier], et range ses champs [champ] et [valeur] dans des variables de même nom. Comme la classe est scellée, le compilateur vérifie que les quatre actions sont traitées ; en ajouter une cinquième sans compléter le réducteur serait une erreur de compilation ;
  • ligne 162 : un nouvel ensemble, fait des éléments de l'ancien ([...etat.touches]) et du champ quitté.

L'écran :

  • ligne 193 : l'état de l'écran : UN champ, l'objet [Etat] ;
  • ligne 195 : un contrôleur par champ de texte, dans une [Map] fabriquée par un [for] ;
  • lignes 206-213 : la SEULE façon de changer l'état : décrire ce qui s'est passé. [_dispatch] (« expédier », le nom que lui donne [React]) calcule le nouvel état avec le réducteur, dans [setState]. Puis, comme le réducteur a pu changer un texte (Majuscules, Réinitialiser), on recopie les valeurs dans les contrôleurs qui ne les ont pas déjà ;
  • lignes 221-223 : des valeurs déduites de l'état : un champ est modifié si sa valeur diffère de la valeur de départ ; l'e-mail est désactivé sans la lettre d'information ; le formulaire est modifié si l'un de ses champs l'est ([any]). [Champ.values] : la liste des valeurs de l'énumération ;
  • lignes 226-237 : une méthode qui fabrique un champ de texte relié au modèle : son contrôleur, son état désactivé ([enabled]), et deux actions : [Modifier] à chaque frappe, [Quitter] quand il perd le focus ;
  • dans [build], chaque champ décrit ses changements par une action : [_dispatch(Modifier(Champ.titre, t!))], [_dispatch(const Majuscules())]... ;
  • lignes 320-338 : un tableau ([DataTable]), avec ses colonnes et ses lignes : l'état de chaque champ suivi ;
  • lignes 344-349 : un texte fait de plusieurs morceaux de styles différents : [Text.rich] et ses [TextSpan].

On modifie le nom, on quitte le champ, on s'abonne à la lettre ; puis on met le nom en majuscules :

Toute la logique du formulaire est dans le réducteur, une fonction de quelques lignes, sans widget, qu'on peut tester seule. Les widgets ne font plus qu'afficher l'état et décrire ce qui arrive.

3.7. Exemple 07 : valider un formulaire, à la main

Une fonction pure, [valider(compte)], renvoie les erreurs des champs ; on l'appelle à chaque [build], les erreurs sont donc toujours à jour. On ne les montre pas d'emblée (un formulaire vide couvert de rouge est désagréable), seulement pour un champ déjà quitté. L'envoi fait apparaître toutes les erreurs.

// =========================================================================
// lib/ex07_validation/main.dart - valider un formulaire, à la main
// -------------------------------------------------------------------------
// Une fonction PURE, valider(compte), renvoie les erreurs des champs ; on
// l'appelle à chaque build(), les erreurs sont donc toujours à jour.
// On ne les montre pas d'emblée (un formulaire vide couvert de rouge est
// désagréable) : seulement pour un champ déjà quitté ("touché"). L'envoi
// marque tous les champs comme touchés, puis n'agit que si le formulaire
// est valide. Un TextField affiche une erreur sous le champ, en rouge,
// quand on lui donne un texte d'erreur : InputDecoration(errorText: ...).
//
// Mêmes règles que le serveur RdvMedecins (formulaire "Créer un compte").
// ATTENTION : cette validation n'est qu'un CONFORT pour l'utilisateur (une
// réponse immédiate, sans aller-retour avec le serveur). Elle ne protège
// rien : n'importe qui peut envoyer au serveur une requête fabriquée à la
// main (curl...). Le serveur revalide donc TOUJOURS tout (cf. exemple 25).
// =========================================================================

import 'package:flutter/material.dart';

enum Champ { titre, prenom, nom, login, password, confirmation }

// un "record" : un petit groupe de valeurs nommées, sans classe à écrire
typedef Compte = ({String titre, String prenom, String nom, String login, String password, String confirmation});

const titres = ['Mr', 'Mme', 'Melle'];
final formatLogin = RegExp(r'^[a-z0-9._-]{1,30}$');
const motDePasseMin = 6;

// les règles : une fonction pure, qui ne dépend que de son argument.
// Elle renvoie, pour chaque champ en erreur, son message
Map<Champ, String> valider(Compte c) {
  final erreurs = <Champ, String>{};
  if (!titres.contains(c.titre)) erreurs[Champ.titre] = 'Choisissez un titre dans la liste.';
  if (c.prenom.trim().isEmpty) {
    erreurs[Champ.prenom] = 'Le prénom est obligatoire.';
  } else if (c.prenom.length > 20) {
    erreurs[Champ.prenom] = 'Le prénom ne doit pas dépasser 20 caractères.';
  }
  if (c.nom.trim().isEmpty) {
    erreurs[Champ.nom] = 'Le nom est obligatoire.';
  } else if (c.nom.length > 20) {
    erreurs[Champ.nom] = 'Le nom ne doit pas dépasser 20 caractères.';
  }
  if (c.login.isEmpty) {
    erreurs[Champ.login] = 'Le login est obligatoire.';
  } else if (!formatLogin.hasMatch(c.login)) {
    erreurs[Champ.login] = 'Lettres minuscules non accentuées, chiffres, point, tiret, souligné.';
  }
  if (c.password.isEmpty) {
    erreurs[Champ.password] = 'Le mot de passe est obligatoire.';
  } else if (c.password.length < motDePasseMin) {
    erreurs[Champ.password] = 'Au moins $motDePasseMin caractères.';
  }
  // une règle qui compare DEUX champs
  if (c.confirmation != c.password) {
    erreurs[Champ.confirmation] = 'La confirmation ne correspond pas au mot de passe.';
  }
  return erreurs;
}

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageCompte(),
    ),
  );
}

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

  @override
  State<PageCompte> createState() => _PageCompteState();
}

class _PageCompteState extends State<PageCompte> {
  String _titre = 'Mr';
  // un contrôleur par champ de texte
  final _textes = {
    for (final c in [Champ.prenom, Champ.nom, Champ.login, Champ.password, Champ.confirmation])
      c: TextEditingController(),
  };
  final Set<Champ> _touches = {};
  bool _envoiTente = false;
  bool _succes = false;

  @override
  void dispose() {
    for (final c in _textes.values) {
      c.dispose();
    }
    super.dispose();
  }

  // le compte, tel que saisi : un record fabriqué à la demande
  Compte get _compte => (
    titre: _titre,
    prenom: _textes[Champ.prenom]!.text,
    nom: _textes[Champ.nom]!.text,
    login: _textes[Champ.login]!.text,
    password: _textes[Champ.password]!.text,
    confirmation: _textes[Champ.confirmation]!.text,
  );

  void _envoyer() {
    setState(() {
      _envoiTente = true;
      _touches.addAll(Champ.values); // toutes les erreurs apparaissent
      // ici, on enverrait le compte au serveur (cf. exemple 25)
      _succes = valider(_compte).isEmpty;
    });
  }

  @override
  Widget build(BuildContext context) {
    // les erreurs : recalculées à chaque build(), jamais rangées dans l'état
    final erreurs = valider(_compte);

    // l'erreur à AFFICHER pour un champ : seulement s'il a été touché
    String? erreur(Champ c) => _touches.contains(c) ? erreurs[c] : null;

    // un champ de texte : libellé, erreur, et une coche verte s'il est touché et valide
    Widget champ(Champ c, String libelle, {bool secret = false}) {
      final valide = _touches.contains(c) && !erreurs.containsKey(c);
      return Focus(
        onFocusChange: (aLeFocus) {
          if (!aLeFocus) setState(() => _touches.add(c));
        },
        child: TextField(
          controller: _textes[c],
          obscureText: secret, // les caractères sont masqués
          decoration: InputDecoration(
            labelText: libelle,
            errorText: erreur(c),
            // l'erreur peut tenir sur deux lignes
            errorMaxLines: 2,
            suffixIcon: valide ? const Icon(Icons.check_circle, color: Colors.green) : null,
          ),
          onChanged: (_) => setState(() => _succes = false),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(title: const Text('Exemple 07 - la validation')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          DropdownButton<String>(
            value: _titre,
            items: [for (final t in titres) DropdownMenuItem(value: t, child: Text(t))],
            onChanged: (t) => setState(() => _titre = t!),
          ),
          champ(Champ.prenom, 'Prénom'),
          champ(Champ.nom, 'Nom'),
          champ(Champ.login, 'Login'),
          champ(Champ.password, 'Mot de passe', secret: true),
          champ(Champ.confirmation, 'Confirmation', secret: true),
          const SizedBox(height: 16),
          FilledButton(onPressed: _envoyer, child: const Text('Créer le compte')),
          const SizedBox(height: 12),
          if (_envoiTente && erreurs.isNotEmpty)
            _Message('Le formulaire contient ${erreurs.length} erreur(s).', couleur: Colors.red.shade50),
          if (_succes)
            _Message('Formulaire valide : il pourrait être envoyé au serveur.', couleur: Colors.green.shade50),
        ],
      ),
    );
  }
}

// un petit widget privé : un message sur fond coloré
class _Message extends StatelessWidget {
  final String texte;
  final Color couleur;

  const _Message(this.texte, {required this.couleur});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(color: couleur, borderRadius: BorderRadius.circular(8)),
      child: Text(texte),
    );
  }
}
  • ligne 24 : le compte saisi est un record : un petit groupe de valeurs nommées, sans classe à écrire. [typedef] lui donne un nom. Un record s'écrit [(titre: 'Mr', prenom: 'Marie', ...)] et se lit [compte.prenom] ;
  • lignes 27-28 : une expression régulière (le format d'un login) et une constante : les mêmes règles que le serveur [RdvMedecins] ;
  • lignes 32-59 : les règles : une fonction pure, qui ne dépend que de son argument, et renvoie une [Map] « champ en erreur -> message ». La dernière règle compare DEUX champs : la confirmation et le mot de passe ;
  • lignes 82-84 : un contrôleur par champ de texte ;
  • ligne 86 : les champs déjà quittés ;
  • lignes 99-105 : le compte, tel que saisi : un record fabriqué à la demande, à partir des contrôleurs. [_textes[Champ.prenom]!] : l'accès à une [Map] renvoie une valeur ou [null] (la clé peut être absente) ; le [!] affirme qu'elle est là ;
  • lignes 108-113 : l'envoi marque tous les champs comme touchés (les erreurs apparaissent), puis n'agit que si le formulaire est valide ;
  • ligne 120 : dans [build], les erreurs sont recalculées à chaque fois ; elles ne sont jamais rangées dans l'état ;
  • ligne 123 : une fonction locale : l'erreur à afficher pour un champ, seulement s'il a été touché ;
  • lignes 126-142 : une fonction locale qui fabrique un champ. [InputDecoration] sait afficher une erreur : [errorText]. Quand il n'est pas [null], le champ s'encadre de rouge et le message s'affiche en dessous (sur deux lignes au plus : [errorMaxLines]). [suffixIcon] : une icône à droite du champ, ici une coche verte pour un champ touché et valide. [obscureText] masque les caractères d'un mot de passe ;
  • lignes 165-168 : les messages de fin de formulaire ;
  • lignes 176-187 : un petit widget privé, pour ne pas répéter deux fois la même boîte colorée.

On remplit quelques champs, puis on envoie ; puis on corrige tout, et on renvoie :

ATTENTION : cette validation n'est qu'un confort pour l'utilisateur, une réponse immédiate, sans aller-retour avec le serveur. Elle ne protège rien : n'importe qui peut envoyer au serveur une requête fabriquée à la main ([curl]...), et le code d'une application mobile peut être modifié. Le serveur revalide donc toujours tout (cf. exemple 25).

3.8. Exemple 08 : les formulaires de Flutter, Form et FormField

Le formulaire de l'exemple 07, écrit avec les outils que [Flutter] fournit lui-même. Là où [React] a besoin d'une bibliothèque ([React Hook Form]), [Flutter] a ses widgets de formulaire :

// =========================================================================
// lib/ex08_form/main.dart - les formulaires de Flutter : Form et FormField
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 07, écrit avec les outils de formulaire que
// Flutter fournit lui-même :
//   - Form : un widget qui REGROUPE des champs de formulaire (FormField) ;
//     on l'atteint par une "clé globale", GlobalKey<FormState> ;
//   - TextFormField, DropdownButtonFormField... : des champs qui savent se
//     valider. Leur paramètre validator est une fonction :
//         (valeur) -> null si la valeur est bonne, sinon le message d'erreur ;
//   - autovalidateMode : QUAND valider (ici, dès que l'utilisateur a
//     modifié le champ, puis à chaque frappe) ;
//   - formulaire.validate() : valide TOUS les champs, affiche leurs
//     erreurs, et renvoie true si tout est bon ;
//   - formulaire.save() : appelle le onSaved de chaque champ, qui range sa
//     valeur où l'on veut.
// Les champs ne sont PAS contrôlés : chacun garde sa valeur, le Form ne
// la lit qu'à la validation et à l'enregistrement.
// =========================================================================

import 'dart:convert';

import 'package:flutter/material.dart';

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

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageCompte(),
    ),
  );
}

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

  @override
  State<PageCompte> createState() => _PageCompteState();
}

class _PageCompteState extends State<PageCompte> {
  // la clé qui donne accès à l'état du formulaire (validate, save, reset)
  final _cleFormulaire = GlobalKey<FormState>();
  // la clé d'UN champ : pour revalider la confirmation quand le mot de passe change
  final _cleConfirmation = GlobalKey<FormFieldState<String>>();
  // on lit le mot de passe dans la règle de la confirmation : un contrôleur
  final _password = TextEditingController();
  // les valeurs rangées par les onSaved
  final Map<String, String> _compte = {};
  // null : pas encore d'envoi ; '' : envoi refusé ; sinon, le JSON envoyé
  String? _envoi;

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

  void _creer() {
    final formulaire = _cleFormulaire.currentState!;
    if (!formulaire.validate()) {
      setState(() => _envoi = '');
      return;
    }
    formulaire.save(); // chaque onSaved range sa valeur dans _compte
    setState(() => _envoi = jsonEncode(_compte));
  }

  // des FABRIQUES de règles : chacune renvoie une fonction de validation
  FormFieldValidator<String> _obligatoire(String message) =>
      (v) => (v == null || v.trim().isEmpty) ? message : null;

  FormFieldValidator<String> _maximum(int max, FormFieldValidator<String> autre) =>
      (v) => autre(v) ?? (v!.length > max ? '$max caractères au plus.' : null);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Exemple 08 - Form et FormField')),
      body: Form(
        key: _cleFormulaire,
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            // initialValue : la valeur de départ ; le champ gère ensuite la sienne
            DropdownButtonFormField<String>(
              initialValue: 'Mr',
              decoration: const InputDecoration(labelText: 'Titre'),
              items: [for (final t in titres) DropdownMenuItem(value: t, child: Text(t))],
              onChanged: (_) {},
              onSaved: (t) => _compte['titre'] = t!,
            ),
            TextFormField(
              decoration: const InputDecoration(labelText: 'Prénom'),
              autovalidateMode: AutovalidateMode.onUserInteraction,
              validator: _maximum(20, _obligatoire('Le prénom est obligatoire.')),
              onSaved: (v) => _compte['prenom'] = v!.trim(),
            ),
            TextFormField(
              decoration: const InputDecoration(labelText: 'Nom'),
              autovalidateMode: AutovalidateMode.onUserInteraction,
              validator: _maximum(20, _obligatoire('Le nom est obligatoire.')),
              onSaved: (v) => _compte['nom'] = v!.trim(),
            ),
            TextFormField(
              decoration: const InputDecoration(labelText: 'Login', errorMaxLines: 2),
              autovalidateMode: AutovalidateMode.onUserInteraction,
              // une règle écrite directement : une fonction anonyme
              validator: (v) {
                if (v == null || v.isEmpty) return 'Le login est obligatoire.';
                if (!RegExp(r'^[a-z0-9._-]{1,30}$').hasMatch(v)) {
                  return 'Lettres minuscules non accentuées, chiffres, point, tiret, souligné.';
                }
                return null;
              },
              onSaved: (v) => _compte['login'] = v!,
            ),
            TextFormField(
              controller: _password,
              obscureText: true,
              decoration: const InputDecoration(labelText: 'Mot de passe'),
              autovalidateMode: AutovalidateMode.onUserInteraction,
              validator: (v) {
                if (v == null || v.isEmpty) return 'Le mot de passe est obligatoire.';
                return v.length < 6 ? 'Au moins 6 caractères.' : null;
              },
              // le mot de passe change : la confirmation est revalidée
              // (si elle a déjà été saisie)
              onChanged: (_) {
                final confirmation = _cleConfirmation.currentState!;
                if (confirmation.value?.isNotEmpty ?? false) confirmation.validate();
              },
              onSaved: (v) => _compte['password'] = v!,
            ),
            TextFormField(
              key: _cleConfirmation,
              obscureText: true,
              decoration: const InputDecoration(labelText: 'Confirmation', errorMaxLines: 2),
              autovalidateMode: AutovalidateMode.onUserInteraction,
              // une règle qui lit un AUTRE champ
              validator: (v) => v == _password.text ? null : 'La confirmation ne correspond pas au mot de passe.',
            ),
            const SizedBox(height: 16),
            FilledButton(onPressed: _creer, child: const Text('Créer le compte')),
            const SizedBox(height: 12),
            if (_envoi != null)
              Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: _envoi!.isEmpty ? Colors.red.shade50 : Colors.green.shade50,
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(_envoi!.isEmpty ? 'Formulaire invalide.' : 'Formulaire valide : $_envoi'),
              ),
          ],
        ),
      ),
    );
  }
}
  • ligne 46 : un formulaire [Form] regroupe des champs de formulaire. Pour agir sur lui depuis le code (valider, enregistrer), on lui donne une clé globale, [GlobalKey] : un objet qui donne accès à l'état d'un widget de l'arbre ([_cleFormulaire.currentState]) ;
  • ligne 48 : la clé d'UN champ, pour pouvoir le revalider seul ;
  • ligne 50 : un contrôleur pour le mot de passe : la règle de la confirmation doit le lire ;
  • ligne 52 : les valeurs rangées à l'enregistrement ;
  • lignes 62-69 : [validate()] valide tous les champs du formulaire, affiche leurs erreurs, et renvoie [true] si tout est bon. [save()] appelle alors la fonction [onSaved] de chaque champ, qui range sa valeur ;
  • lignes 73-77 : des fabriques de règles. Une règle de validation ([FormFieldValidator]) est une fonction qui reçoit la valeur du champ et renvoie [null] si elle est bonne, sinon le message d'erreur. [_obligatoire('...')] fabrique une règle ; [_maximum(20, autre)] en fabrique une qui applique d'abord la règle [autre], puis la sienne ([??] : si la première ne trouve rien) ;
  • ligne 83 : le formulaire, et sa clé ;
  • lignes 89-94 : une liste déroulante qui est aussi un champ de formulaire. [initialValue] : sa valeur de départ ; ensuite, le champ gère la sienne ;
  • lignes 96-100 : un [TextFormField] est un [TextField] qui est aussi un champ de formulaire : il a une règle ([validator]), une fonction d'enregistrement ([onSaved]), et un mode de validation automatique : [AutovalidateMode.onUserInteraction] valide le champ dès que l'utilisateur l'a modifié, puis à chaque frappe ;
  • lignes 112-117 : une règle écrite directement, avec deux tests ;
  • lignes 132-134 : quand le mot de passe change, la confirmation est revalidée, par la clé de son champ, si elle a déjà été saisie ;
  • ligne 144 : une règle qui lit un AUTRE champ.

On saisit des valeurs fausses et on envoie ; puis on corrige :

Laquelle des deux méthodes choisir ? [Form] et [TextFormField] suffisent pour un formulaire dont les règles sont écrites dans l'application. Mais dans ce cours, ce sont les règles du serveur qui font foi : l'étude de cas n'a aucune règle dans l'application, et affiche sous chaque champ l'erreur renvoyée par le serveur (exemple 25). C'est la méthode de l'exemple 07, des champs et leurs erreurs, qu'on y retrouvera.

3.9. Exemple 09 : la mise en forme des valeurs

Chaque ligne du tableau montre un morceau de code et ce qu'il affiche.

3.9.1. Des fonctions de mise en forme

[Flutter] n'a pas de mécanisme propre à la mise en forme (les « pipes » d'[Angular]) : dans [build], on appelle des fonctions ordinaires, rangées dans un fichier et testables à part :

// =========================================================================
// lib/ex09_mise_en_forme/formatage.dart - des fonctions de mise en forme "maison"
// -------------------------------------------------------------------------
// Flutter n'a pas de mécanisme propre à la mise en forme (Angular a ses
// "pipes") : dans build(), on appelle des FONCTIONS ordinaires, rangées
// dans un fichier et testables à part :
//     horaire(creneau)          -> "8:00 - 8:20"
//     horaire(creneau, Partie.debut) -> "8:00"
// Dart permet aussi d'AJOUTER des méthodes à un type existant, par une
// "extension" : 'marie pelissier'.majusculeInitiale -> 'Marie Pelissier'.
// Pour les dates, les nombres, les montants, le paquet intl connaît les
// conventions de chaque langue (cf. main.dart).
// =========================================================================

// un créneau horaire, tel que le décrit l'API RdvMedecins
class Creneau {
  final int hdebut;
  final int mdebut;
  final int hfin;
  final int mfin;

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

enum Partie { tout, debut, fin }

// "8:05" plutôt que "8:5"
String _heure(int h, int m) => '$h:${m.toString().padLeft(2, '0')}';

// un paramètre FACULTATIF, avec sa valeur par défaut : [Partie partie = Partie.tout]
String horaire(Creneau c, [Partie partie = Partie.tout]) => switch (partie) {
  Partie.debut => _heure(c.hdebut, c.mdebut),
  Partie.fin => _heure(c.hfin, c.mfin),
  Partie.tout => '${_heure(c.hdebut, c.mdebut)} - ${_heure(c.hfin, c.mfin)}',
};

// une EXTENSION du type String : de nouvelles méthodes pour TOUTES les chaînes
extension MiseEnForme on String {
  // "marie pelissier" -> "Marie Pelissier" ; \p{L} : une lettre, accentuée ou non
  String get majusculeInitiale => replaceAllMapped(
    RegExp(r'\p{L}+', unicode: true),
    (m) => m[0]![0].toUpperCase() + m[0]!.substring(1).toLowerCase(),
  );

  // "Marie PELISSIER" -> "MP"
  String get initiales =>
      split(RegExp(r'\s+')).where((mot) => mot.isNotEmpty).map((mot) => mot[0].toUpperCase()).join();
}

// "2026-10-05" (le format de l'API RdvMedecins) -> une date
DateTime versDate(String jour) => DateTime.parse(jour);
  • lignes 16-22 : un créneau horaire, tel que le décrit l'API [RdvMedecins] : des heures et des minutes, sous forme de nombres ;
  • ligne 28 : « 8:05 » plutôt que « 8:5 » : [padLeft(2, '0')] complète à gauche avec des zéros ;
  • lignes 31-34 : une fonction à paramètre positionnel facultatif (entre crochets), avec sa valeur par défaut : [horaire(c)] ou [horaire(c, Partie.debut)] ;
  • lignes 38-47 : une extension : elle ajoute des méthodes (ici des getters) au type [String], à TOUTES les chaînes : ['marie pelissier'.majusculeInitiale]. C'est une particularité de [Dart], très pratique pour la mise en forme. [replaceAllMapped] remplace chaque mot (une suite de lettres, accentuées ou non : [\p{L}+] avec [unicode: true]) par ce que renvoie la fonction ;
  • ligne 51 : « 2026-10-05 » (le format de l'API) -> une date ([DateTime]).

3.9.2. L'écran

// =========================================================================
// lib/ex09_mise_en_forme/main.dart - mettre en forme des valeurs
// -------------------------------------------------------------------------
// Chaque ligne du tableau montre un morceau de code et ce qu'il affiche.
// Les formateurs du paquet intl (DateFormat, NumberFormat) sont créés une
// fois pour toutes, hors des widgets : les fabriquer coûte plus cher que
// de s'en servir.
// Les noms des jours et des mois de chaque langue ne sont pas chargés
// d'office : initializeDateFormatting() les charge, AVANT runApp.
// =========================================================================

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';

import 'formatage.dart';

// dates : des formats "tout prêts" (yMMMd, yMMMMEEEEd, Hms...), ou un motif
final dateCourte = DateFormat.yMMMd('fr_FR');
final dateComplete = DateFormat.yMMMMEEEEd('fr_FR');
final heureSeule = DateFormat.Hms('fr_FR');
final jourAnglais = DateFormat.yMMMMd('en_US');
final motif = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
// nombres et montants
final euros = NumberFormat.currency(locale: 'fr_FR', symbol: '€');
final entier = NumberFormat.decimalPattern('fr_FR');
final pourcent = NumberFormat.decimalPercentPattern(locale: 'fr_FR', decimalDigits: 1);

const jour = '2026-10-05'; // une date au format de l'API RdvMedecins
const prix = 1234.5;
const taux = 0.055;
const nom = 'marie pelissier';
const medecin = {'titre': 'Mme', 'prenom': 'Marie', 'nom': 'PELISSIER'};
const langages = ['Dart', 'Flutter', 'Kotlin', 'Swift', 'SQL'];
const creneaux = [Creneau(8, 0, 8, 20), Creneau(8, 20, 8, 40), Creneau(14, 5, 14, 25)];

// main peut être ASYNCHRONE : on attend le chargement des langues
Future<void> main() async {
  await initializeDateFormatting('fr_FR');
  await initializeDateFormatting('en_US');
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const PageMiseEnForme(),
    ),
  );
}

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

  @override
  State<PageMiseEnForme> createState() => _PageMiseEnFormeState();
}

class _PageMiseEnFormeState extends State<PageMiseEnForme> {
  DateTime _maintenant = DateTime.now();

  @override
  Widget build(BuildContext context) {
    // (code affiché, valeur) : une ligne du tableau, sous forme de "record"
    final lignes = <(String, String)>[
      ('dateCourte.format(maintenant)', dateCourte.format(_maintenant)),
      ('dateComplete.format(maintenant)', dateComplete.format(_maintenant)),
      ('heureSeule.format(maintenant)', heureSeule.format(_maintenant)),
      ('motif.format(maintenant)', motif.format(_maintenant)),
      ('dateComplete.format(versDate(jour))', dateComplete.format(versDate(jour))),
      ('jourAnglais.format(versDate(jour))', jourAnglais.format(versDate(jour))),
      ('maintenant.toString()', _maintenant.toString()),
      ('euros.format(prix)', euros.format(prix)),
      ('entier.format(prix)', entier.format(prix)),
      ('prix.toStringAsFixed(1)', prix.toStringAsFixed(1)),
      ('pourcent.format(taux)', pourcent.format(taux)),
      ('nom.toUpperCase()', nom.toUpperCase()),
      ('nom.majusculeInitiale', nom.majusculeInitiale),
      ("'RDV'.toLowerCase()", 'RDV'.toLowerCase()),
      ("langages.take(3).join(', ')", langages.take(3).join(', ')),
      ('jsonEncode(medecin)', jsonEncode(medecin)),
      // deux transformations à la suite : le résultat de la première passe à la seconde
      ('nom.majusculeInitiale.initiales', nom.majusculeInitiale.initiales),
    ];

    const styleCode = TextStyle(fontFamily: 'RobotoMono', fontSize: 11, color: Color(0xFFC7254E));
    return Scaffold(
      appBar: AppBar(title: const Text('Exemple 09 - la mise en forme')),
      body: ListView(
        padding: const EdgeInsets.all(12),
        children: [
          // Table : un tableau simple ; la première colonne prend 55 % de la largeur
          Table(
            columnWidths: const {0: FractionColumnWidth(0.55)},
            border: TableBorder(horizontalInside: BorderSide(color: Colors.grey.shade300)),
            children: [
              const TableRow(
                children: [
                  Padding(
                    padding: EdgeInsets.all(4),
                    child: Text('Dans le code', style: TextStyle(fontWeight: FontWeight.bold)),
                  ),
                  Padding(
                    padding: EdgeInsets.all(4),
                    child: Text('Affiché', style: TextStyle(fontWeight: FontWeight.bold)),
                  ),
                ],
              ),
              // un record se "déstructure" : (code, valeur)
              for (final (code, valeur) in lignes)
                TableRow(
                  children: [
                    Padding(
                      padding: const EdgeInsets.all(4),
                      child: Text(code, style: styleCode),
                    ),
                    Padding(padding: const EdgeInsets.all(4), child: Text(valeur)),
                  ],
                ),
            ],
          ),
          const SizedBox(height: 16),
          Text("Parcourir les entrées d'une Map", style: Theme.of(context).textTheme.titleSmall),
          for (final MapEntry(:key, :value) in medecin.entries) Text('$key = $value'),
          const SizedBox(height: 16),
          Text('Une fonction maison : horaire', style: Theme.of(context).textTheme.titleSmall),
          for (final c in creneaux) Text('${horaire(c)} (début : ${horaire(c, Partie.debut)})'),
          const SizedBox(height: 16),
          Align(
            alignment: Alignment.centerLeft,
            child: OutlinedButton(
              onPressed: () => setState(() => _maintenant = DateTime.now()),
              child: const Text("Actualiser l'heure"),
            ),
          ),
        ],
      ),
    );
  }
}
  • lignes 21-25 : des formateurs de dates, du paquet [intl]. [DateFormat] a des formats « tout prêts », désignés par les parties à afficher : [yMMMd] (année, mois abrégé, jour), [yMMMMEEEEd] (plus le jour de la semaine, en entier), [Hms] (heures, minutes, secondes)... On peut aussi donner un motif : ['dd/MM/yyyy HH:mm']. Le second paramètre est la langue ;
  • lignes 27-29 : des formateurs de nombres : montants, entiers, pourcentages ;
  • lignes 40-42 : les noms des jours et des mois de chaque langue ne sont pas chargés d'office : [initializeDateFormatting] les charge, avant [runApp]. [main] peut être asynchrone ;
  • lignes 65-83 : le tableau : une liste de records positionnels [(code, valeur)], sans noms de champs ;
  • lignes 93-111 : [Table] dessine un tableau simple. [columnWidths] : la première colonne prend 55 % de la largeur ; [border] : des filets entre les lignes. [for (final (code, valeur) in lignes)] déstructure chaque record ;
  • ligne 124 : parcourir les entrées d'une [Map] : [:key] est un raccourci pour [key: key] ;
  • ligne 127 : la fonction maison.

Pour les dates, les nombres et les montants, [intl] joue le rôle de l'API [Intl] des navigateurs : il connaît les conventions de chaque langue (séparateurs, noms des mois, ordre des parties). On remarquera, en français, l'espace fine insécable qui sépare les milliers (1 234,50 €), et l'ordre « jour mois année ».