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

Chapitre 3 — Journée 3 : Gestion d’état, formulaires et stockage local

Sommaire du chapitre


1. Les limites du setState

Lors de la journée 2, nous avons vu que remonter l’état vers un widget parent (« lifting state up ») fonctionne, mais devient vite pénible : plus l’application grandit, plus il faut faire transiter des callbacks à travers de nombreuses couches de widgets.

Analogie Spring Boot : c’est un peu comme si, au lieu d’injecter un @Service avec @Autowired, vous deviez le passer en paramètre de chaque méthode, de chaque contrôleur, à travers toute la chaîne d’appel. Le conteneur d’injection de dépendances de Spring vous évite cela quoique la bonne façon de faire avec Spring Boot reste celle d’injecter un @Service via le constructeur du Controlleur(surtout pour pouvoir effectuer des tests par la suite).

Avec Flutter, nous avons Provider qui joue exactement ce rôle d’injection de dépendance.


2. Provider : injection de dépendance et état partagé

Illustration de ce que nous allons voir en détail :

représentation graphique du provider et injection

Provider est le package de state management (gestion des états) le plus utilisé et le plus simple à apprendre. Il repose sur 3 concepts exposés dans l’illustration ci-dessus.

Toutefois, vous devez vous poser la question de savoir à quel moment et comment les widgets sont abonnés ?. En fait, le widget s’abonne au moment où il lit le provider dans son build(), via context.watch(), Consumer ou Provider.of(context, listen: true).

Lire = s’abonner. C’est implicite et automatique; il n’y a pas de méthode subscribe() à appeler soi-même. Cela peut-être perturbant, mais ça fonctionne !

Mécanisme, pas à pas

Étape 1 : Le provider est enregistré à la racine (Partie 3 de l’illustration)

ChangeNotifierProvider(
  create: (context) => PanierProvider(),
  child: MaterialApp(...),
)

Cela rend le PanierProvider localisable dans l’arbre des widgets. Cependant, localisable n’est pas encore “être écouté” !

Étape 2 : Le widget lit ET s’abonne en une seule opération

Désolé, mais c’est la ligne manquante dans l’illustration !

Voici 3 écritures équivalentes :

// Option A : watch (la plus moderne et concise pour nous)
final panier = context.watch<PanierProvider>();
return Text('${panier.nombreArticles} articles');

// Option B : Consumer (explicite, découpe le build en sections)
Consumer<PanierProvider>(
  builder: (context, panier, child) {
    return Text('${panier.nombreArticles} articles');
  },
)
// et puis l'ption C : Provider.of (la forme "classique")
final panier = Provider.of<PanierProvider>(context, listen: true); // listen: true = abonnement !

Dans les 3 cas, la même chose se passe sous le capot :

  1. context.watch remonte l’arbre des widgets et localise le ChangeNotifierProvider le plus proche (via un InheritedWidget, le mécanisme de “recherche en amont” de Flutter).
  2. Le contexte du widget (techniquement, son Elément) s’ajoute à la liste d’écoute du ChangeNotifier. C’est l’équivalent d’un addListener(), fait pour vous par le package provider.
  3. Ensuite, lorsque le provider appelle notifyListeners(), Flutter reçoit la notification : il marque les éléments abonnés interprété comme “incorrectes” et du coup, leurs build() sont relancés.

Étape 3 : Flux réel

L’illustration suggère que le provider “pousse” vers les widgets A/B/C. En réalité, le flux complet est :

Habituellement, l’abonnement est une étape visible du code mais avec le package provider, la plomberie est masquée dans context.watch / Consumer : l’abonnement devient un effet de bord de la lecture. C’est plus concis, mais ça rend le mécanisme d’abonnement invisible !

Petite nuance qui sauve des performances !

context.watch<PanierProvider>()    // s'abonne puis rebuild à chaque notification
context.read<PanierProvider>()     // lit SANS s'abonner et jamais de rebuild
Provider.of(context, listen: false) // idem que read

Règle pratique

Donc, un widget s’abonne au provider en le lisant avec watch/Consumer/Provider.of(listen: true) dans son build(). Cet appel fait d’une pierre 2 coups : il récupère les données et enregistre le widget dans la liste d’écoute du ChangeNotifier. Quand notifyListeners() est utilisé, seuls les abonnés reconstruisent tandis que les autres widgets de l’écran ne bougent pas !

Dans les chapitres suivants, j’ai mis plus de code pour mieux comprendre ce principe pas évident à écrire la première fois.

2.1 Installation

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.2
flutter pub get   # équivalent de `mvn install` : télécharge les dépendances

2.2 Créer un modèle observable (ChangeNotifier)

import 'package:flutter/foundation.dart';

class PanierProvider extends ChangeNotifier {
  final List<String> _articles = [];

  List<String> get articles => List.unmodifiable(_articles);
  int get nombreArticles => _articles.length;

  void ajouter(String article) {
    _articles.add(article);
    notifyListeners(); // indispensable : prévient tous les widgets abonnés
  }

  void retirer(String article) {
    _articles.remove(article);
    notifyListeners(); // idem
  }
}

ChangeNotifier est l’équivalent d’un Observable (pattern Observer) : les widgets qui « écoutent » ce provider sont automatiquement reconstruits lorsque notifyListeners() est appelé.

2.3 Enregistrer le provider (au niveau racine, comme un @Bean Spring)

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => PanierProvider(),
      child: const MonApplication(),
    ),
  );
}

Pour plusieurs providers :

MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => PanierProvider()),  // notre panier
    ChangeNotifierProvider(create: (_) => ThemeProvider()),   // le thème de notre appiication
  ],
  child: const MonApplication(),
)

2.4 Consommer le provider dans un widget

Méthode 1 — context.watch<T>() : reconstruit le widget à chaque notifyListeners() :

@override
Widget build(BuildContext context) {
  final panier = context.watch<PanierProvider>();
  return Text("Panier (${panier.nombreArticles})");
}

Méthode 2 — context.read<T>() : accède au provider sans s’abonner aux changements (utilisé dans les callbacks onPressed, jamais dans build) :

ElevatedButton(
  onPressed: () => context.read<PanierProvider>().ajouter("T-Shirt"),
  child: const Text("Ajouter au panier"),
)

Méthode 3 — Consumer<T> : reconstruit seulement une sous-partie de l’arbre (optimisation de performance) :

Consumer<PanierProvider>(
  builder: (context, panier, child) => Text("${panier.nombreArticles} article(s)"),
)

Règle d’or : watch dans build(), read dans les callbacks. C’est l’erreur lorsque l’on débute avec Provider !


Code complet pour tester

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

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => PanierProvider(),          // le "bean" est créé ici
      child: const MonApplication(),
    ),
  );
}

class MonApplication extends StatelessWidget {
  const MonApplication({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: EcranPanier(),                             // ici, l'écran qui consomme
    );
  }
}

class EcranPanier extends StatelessWidget {
  const EcranPanier({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // le watch : ce Text est ABONNÉ et il se reconstruit à chaque ajouter()
        title: Text("Panier (${context.watch<PanierProvider>().nombreArticles})"),
      ),
      body: Consumer<PanierProvider>(                 // la liste aussi est abonnée
        builder: (context, panier, child) {
          if (panier.articles.isEmpty) {
            return const Center(child: Text("Panier vide"));
          }
          return ListView.builder(
            itemCount: panier.articles.length,
            itemBuilder: (context, i) => ListTile(
              title: Text(panier.articles[i]),
              trailing: IconButton(
                icon: const Icon(Icons.delete),
                // ici avec le read : on agit sans s'abonner (on est dans un callback)
                onPressed: () => context.read<PanierProvider>().retirer(panier.articles[i]),
              ),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        // read encore : le bouton n'a pas besoin de se reconstruire !
        onPressed: () => context.read<PanierProvider>().ajouter("Article ${DateTime.now().second}"),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Illustration du code ci-dessus :

Illustration pour expliquer l'abonnement implicite des widgets au PanierProvider


3. Les formulaires avec Form et validation

Les formulaires sont l’équivalent mobile d’un formulaire HTML avec validation comme Bean Validation (@NotNull, @Email…) côté Spring.

Illustration :

représentation graphique du formulaire et validation

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

  @override
  State<FormulaireInscription> createState() => _FormulaireInscriptionState();
}

class _FormulaireInscriptionState extends State<FormulaireInscription> {
  final _formKey = GlobalKey<FormState>();       // clé unique pour piloter le formulaire
  final _emailController = TextEditingController();
  final _mdpController = TextEditingController();

  @override
  void dispose() {
    // Libère les ressources, comme un @PreDestroy côté Spring
    _emailController.dispose();
    _mdpController.dispose();
    super.dispose();
  }

  void _soumettre() {
    if (_formKey.currentState!.validate()) {
      // Toutes les règles de validation sont respectées
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("Inscription réussie pour ${_emailController.text}")),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
            keyboardType: TextInputType.emailAddress,
            validator: (valeur) {
              if (valeur == null || valeur.isEmpty) return "L'email est obligatoire";
              if (!valeur.contains("@")) return "Email invalide";
              return null; // null = pas d'erreur
            },
          ),
          const SizedBox(height: 12),
          TextFormField(
            controller: _mdpController,
            decoration: const InputDecoration(labelText: "Mot de passe", border: OutlineInputBorder()),
            obscureText: true,
            validator: (valeur) {
              if (valeur == null || valeur.length < 6) {
                return "6 caractères minimum";
              }
              return null;
            },
          ),
          const SizedBox(height: 20),
          ElevatedButton(onPressed: _soumettre, child: const Text("S'inscrire")),
        ],
      ),
    );
  }
}

4. Stockage local avec shared_preferences

Pour sauvegarder de petites données simples (préférences, token, thème…) de façon persistante entre les lancements de l’application, nous avons l’équivalent d’un cache très simple ou d’un fichier comme application.properties modifiable à l’exécution avec Java. ça fonctionne avec le principe des clefs valeurs.

dependencies:
  shared_preferences: ^2.2.3
import 'package:shared_preferences/shared_preferences.dart';

// Écrire une valeur
Future<void> sauvegarderPseudo(String pseudo) async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('pseudo', pseudo);
}

// Lire une valeur
Future<String?> chargerPseudo() async {
  final prefs = await SharedPreferences.getInstance();
  return prefs.getString('pseudo');
}

Présentation illustrée pour bien comprendre :

représentation graphique du formulaire et validation

Pour des données structurées et volumineuses (listes d’objets complexes, relations), on utilisera plutôt une base de données embarquée comme sqflite (SQL) ou Hive/Isar (NoSQL) — hors périmètre de ce cours de 5 jours, mais évoqué en Annexe B.


5. Exercices du jour

Exercice 3.1 — Provider simple

Créez un CompteurProvider extends ChangeNotifier avec une méthode incrementer(). Affichez la valeur dans un widget via Consumer, et incrémentez-la depuis un bouton dans un autre widget frère (sans passer par des callbacks manuels).

Voici ce que vous devez afficher :

représentation graphique du compteur provider

Exercice 3.2 — Formulaire de connexion

Créez un formulaire Email + Mot de passe avec validation (email valide, mot de passe non vide). En cas de succès, affichez une SnackBar de bienvenue.

Voici ce que vous devez afficher (avec vos couleurs) :

représentation graphique du formulaire de connexion

Exercice 3.3 — Persister une préférence

Objectif illustré :

représentation graphique du rendu du thème sombre

Créez un écran avec un Switch « Mode sombre ». Sauvegardez l’état dans shared_preferences et rechargez-le au démarrage de l’application (l’état doit survivre à un redémarrage complet de l’app).

Attendre avant de Voir les corrigés complets


6. TP de synthèse - Application traditionnelle de “Todo List”

Cahier des charges :

Développez une application complète de gestion de tâches todo_app :

  1. Modèle Tache : id, titre, estFaite (bool) ;
  2. TacheProvider extends ChangeNotifier exposant : ajouterTache(titre), basculerEtat(id), supprimerTache(id), taches (getter) ;
  3. Écran principal : un TextFormField + bouton pour ajouter une tâche, une ListView.builder affichant chaque tâche avec une Checkbox (cocher = fait, avec texte barré) et une icône de suppression ;
  4. Persistance : à chaque modification, sauvegardez la liste des tâches sous forme de JSON (String) dans shared_preferences, et rechargez-la au démarrage ;
  5. Bonus : ajoutez un compteur « X tâches restantes » dans l’AppBar, et un mode sombre activable/persisté comme dans l’exercice 3.3.

Pour vous aider :

représentation graphique du rendu de la todo list et thème sombre

👉 Voir le corrigé complet du TP


➡️ Suite du cours

Nous allons maintenant connecter notre application Flutter à votre API Spring Boot !

➡️ Chapitre 4 — Flutter ↔ Spring Boot : HTTP, JSON, CRUD

⬅️ Retour au sommaire Flutter