setState
ChangeNotifier
@Bean
Form
shared_preferences
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).
@Service
@Autowired
Avec Flutter, nous avons Provider qui joue exactement ce rôle d’injection de dépendance.
Provider
Illustration de ce que nous allons voir en détail :
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).
context.watch()
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 !
ChangeNotifierProvider( create: (context) => PanierProvider(), child: MaterialApp(...), )
Cela rend le PanierProvider localisable dans l’arbre des widgets. Cependant, localisable n’est pas encore “être écouté” !
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 :
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
(onPressed: () => context.read<PanierProvider>().ajouter(art))
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.
# pubspec.yaml dependencies: flutter: sdk: flutter provider: ^6.1.2
flutter pub get # équivalent de `mvn install` : télécharge les dépendances
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é.
notifyListeners()
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(), )
Méthode 1 — context.watch<T>() : reconstruit le widget à chaque notifyListeners() :
context.watch<T>()
@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) :
context.read<T>()
onPressed
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<T>
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 !
watch
build()
read
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 :
Les formulaires sont l’équivalent mobile d’un formulaire HTML avec validation comme Bean Validation (@NotNull, @Email…) côté Spring.
@NotNull
@Email
Illustration :
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")), ], ), ); } }
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.
application.properties
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 :
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.
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).
CompteurProvider extends ChangeNotifier
incrementer()
Consumer
Voici ce que vous devez afficher :
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.
SnackBar
Voici ce que vous devez afficher (avec vos couleurs) :
Objectif illustré :
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).
Switch
Attendre avant de Voir les corrigés complets
Cahier des charges :
Développez une application complète de gestion de tâches todo_app :
todo_app
Tache
id
titre
estFaite
TacheProvider extends ChangeNotifier
ajouterTache(titre)
basculerEtat(id)
supprimerTache(id)
taches
TextFormField
ListView.builder
Checkbox
String
AppBar
Pour vous aider :
👉 Voir le corrigé complet du TP
Nous allons maintenant connecter notre application Flutter à votre API Spring Boot !
➡️ Chapitre 4 — Flutter ↔ Spring Boot : HTTP, JSON, CRUD
⬅️ Retour au sommaire Flutter