Aller au contenu
⬅️ Retour au sommaire ⬅️ Retour au Chapitre 3

✅ Corrigés - Journée 3

Exercice 3.1 Un provider simple (compteur)

représentation graphique du compteur provider

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

class CompteurProvider extends ChangeNotifier {
  int _valeur = 0;
  int get valeur => _valeur;

  void incrementer() {
    _valeur++;
    notifyListeners();
  }
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CompteurProvider(),
      child: const MonApp(),
    ),
  );
}

class MonApp extends StatelessWidget {
  const MonApp({super.key});
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: PageCompteurProvider());
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Provider : compteur")),
      body: const Center(child: AfficheurValeur()), // widget "frère" ou "soeur" qui affiche
      floatingActionButton: const BoutonIncrementer(), // widget "frère" ou " soeur" qui modifie
    );
  }
}

// Widget qui LIT la valeur : utilise Consumer -> se reconstruit à chaque changement
class AfficheurValeur extends StatelessWidget {
  const AfficheurValeur({super.key});
  @override
  Widget build(BuildContext context) {
    return Consumer<CompteurProvider>(
      builder: (context, provider, child) => Text(
        "Valeur : ${provider.valeur}",
        style: const TextStyle(fontSize: 32),
      ),
    );
  }
}

// Widget qui MODIFIE la valeur : utilise context.read (pas de reconstruction inutile)
class BoutonIncrementer extends StatelessWidget {
  const BoutonIncrementer({super.key});
  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () => context.read<CompteurProvider>().incrementer(),
      child: const Icon(Icons.add),
    );
  }
}

Point clé : AfficheurValeur et BoutonIncrementer sont 2 widgets totalement indépendants, sans lien parent-enfant direct ni callback manuel. Ils communiquent uniquement via le CompteurProvider, exactement comme 2 @Service Spring communiqueraient via un composant injecté commun.


Exercice 3.2 — Formulaire de connexion

représentation graphique du formulaire de connexion expliqué

import 'package:flutter/material.dart';

class PageConnexion extends StatefulWidget {
  const PageConnexion({super.key});
  @override
  State<PageConnexion> createState() => _PageConnexionState();
}

class _PageConnexionState extends State<PageConnexion> {
  final _formKey = GlobalKey<FormState>();
  final _emailCtrl = TextEditingController();
  final _mdpCtrl = TextEditingController();

  @override
  void dispose() {
    _emailCtrl.dispose();
    _mdpCtrl.dispose();
    super.dispose();
  }

  void _connecter() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("Bienvenue ${_emailCtrl.text} !")),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Connexion")),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Form(
          key: _formKey,
          child: Column(
            children: [
              TextFormField(
                controller: _emailCtrl,
                decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
                keyboardType: TextInputType.emailAddress,
                validator: (v) {
                  if (v == null || v.isEmpty) return "L'email est requis";
                  final regex = RegExp(r'^[\w\.\-]+@[\w\-]+\.[a-zA-Z]{2,}$');
                  if (!regex.hasMatch(v)) return "Format d'email invalide";
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _mdpCtrl,
                decoration: const InputDecoration(labelText: "Mot de passe", border: OutlineInputBorder()),
                obscureText: true,
                validator: (v) {
                  if (v == null || v.isEmpty) return "Le mot de passe est requis";
                  return null;
                },
              ),
              const SizedBox(height: 24),
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(onPressed: _connecter, child: const Text("Se connecter")),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Rendu final :

représentation graphique du formulaire de connexion


Exercice 3.3 — Persister une préférence (mode sombre du theme)

Objectif illustré :

représentation graphique du rendu du thème sombre

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

void main() => runApp(const MonAppTheme());

class MonAppTheme extends StatefulWidget {
  const MonAppTheme({super.key});
  @override
  State<MonAppTheme> createState() => _MonAppThemeState();
}

class _MonAppThemeState extends State<MonAppTheme> {
  bool _modeSombre = false;
  bool _chargementTermine = false;

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

  Future<void> _chargerPreference() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() {
      _modeSombre = prefs.getBool('mode_sombre') ?? false;
      _chargementTermine = true;
    });
  }

  Future<void> _changerMode(bool valeur) async {
    setState(() => _modeSombre = valeur);
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool('mode_sombre', valeur);
  }

  @override
  Widget build(BuildContext context) {
    if (!_chargementTermine) {
      return const MaterialApp(home: Scaffold(body: Center(child: CircularProgressIndicator())));
    }
    return MaterialApp(
      themeMode: _modeSombre ? ThemeMode.dark : ThemeMode.light,
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: PageParametresTheme(modeSombre: _modeSombre, onChangerMode: _changerMode),
    );
  }
}

class PageParametresTheme extends StatelessWidget {
  final bool modeSombre;
  final ValueChanged<bool> onChangerMode;

  const PageParametresTheme({super.key, required this.modeSombre, required this.onChangerMode});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Paramètres")),
      body: Center(
        child: SwitchListTile(
          title: const Text("Mode sombre"),
          value: modeSombre,
          onChanged: onChangerMode,
        ),
      ),
    );
  }
}

💡 Point clé : On affiche un CircularProgressIndicator tant que la préférence n’est pas encore chargée depuis le disque (opération asynchrone). C’est un pattern que vous retrouverez systématiquement pendant la journée 4 avec les appels réseau.

Illustrations de la correction :

représentation graphique du rendu du thème sombre correction 1

représentation graphique du rendu du thème sombre correction 2


TP de synthèse - Application Todo List (pour ceux et celle qui s’ennuient)

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

Vous pouvez la réaliser que partiellement et la terminer la prochaine fois…

// ======================= main.dart =======================
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => TacheProvider()..chargerDepuisStockage(),
      child: const TodoApp(),
    ),
  );
}

// ---------- le Modèle ----------
class Tache {
  final String id;
  String titre;
  bool estFaite;

  Tache({required this.id, required this.titre, this.estFaite = false});

  Map<String, dynamic> toJson() => {'id': id, 'titre': titre, 'estFaite': estFaite};

  factory Tache.fromJson(Map<String, dynamic> json) => Tache(
        id: json['id'],
        titre: json['titre'],
        estFaite: json['estFaite'],
      );
}

// ---------- notre Provider ----------
class TacheProvider extends ChangeNotifier {
  final List<Tache> _taches = [];
  bool _modeSombre = false;

  List<Tache> get taches => List.unmodifiable(_taches);
  bool get modeSombre => _modeSombre;
  int get nombreRestantes => _taches.where((t) => !t.estFaite).length;

  Future<void> chargerDepuisStockage() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonString = prefs.getString('taches');
    _modeSombre = prefs.getBool('mode_sombre') ?? false;

    if (jsonString != null) {
      final List<dynamic> liste = jsonDecode(jsonString);
      _taches.addAll(liste.map((e) => Tache.fromJson(e)));
    }
    notifyListeners();
  }

  Future<void> _sauvegarder() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonString = jsonEncode(_taches.map((t) => t.toJson()).toList());
    await prefs.setString('taches', jsonString);
  }

  void ajouterTache(String titre) {
    if (titre.trim().isEmpty) return;
    _taches.add(Tache(id: DateTime.now().millisecondsSinceEpoch.toString(), titre: titre.trim()));
    notifyListeners();
    _sauvegarder();
  }

  void basculerEtat(String id) {
    final tache = _taches.firstWhere((t) => t.id == id);
    tache.estFaite = !tache.estFaite;
    notifyListeners();
    _sauvegarder();
  }

  void supprimerTache(String id) {
    _taches.removeWhere((t) => t.id == id);
    notifyListeners();
    _sauvegarder();
  }

  Future<void> basculerModeSombre(bool valeur) async {
    _modeSombre = valeur;
    notifyListeners();
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool('mode_sombre', valeur);
  }
}

// ---------- notre application ----------
class TodoApp extends StatelessWidget {
  const TodoApp({super.key});

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<TacheProvider>();
    return MaterialApp(
      title: 'Todo List',
      themeMode: provider.modeSombre ? ThemeMode.dark : ThemeMode.light,
      theme: ThemeData.light(useMaterial3: true),
      darkTheme: ThemeData.dark(useMaterial3: true),
      home: const PageTodoList(),
    );
  }
}

// ---------- l'écran principal ----------
class PageTodoList extends StatefulWidget {
  const PageTodoList({super.key});
  @override
  State<PageTodoList> createState() => _PageTodoListState();
}

class _PageTodoListState extends State<PageTodoList> {
  final _controller = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<TacheProvider>();

    return Scaffold(
      appBar: AppBar(
        title: Text("Mes tâches (${provider.nombreRestantes} restantes)"),
        actions: [
          Switch(
            value: provider.modeSombre,
            onChanged: (v) => context.read<TacheProvider>().basculerModeSombre(v),
          ),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: "Nouvelle tâche...",
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _ajouter(context),
                  ),
                ),
                const SizedBox(width: 8),
                ElevatedButton(onPressed: () => _ajouter(context), child: const Icon(Icons.add)),
              ],
            ),
          ),
          Expanded(
            child: provider.taches.isEmpty
                ? const Center(child: Text("Aucune tâche. Ajoutez-en une !"))
                : ListView.builder(
                    itemCount: provider.taches.length,
                    itemBuilder: (context, index) {
                      final tache = provider.taches[index];
                      return ListTile(
                        leading: Checkbox(
                          value: tache.estFaite,
                          onChanged: (_) => context.read<TacheProvider>().basculerEtat(tache.id),
                        ),
                        title: Text(
                          tache.titre,
                          style: TextStyle(
                            decoration: tache.estFaite ? TextDecoration.lineThrough : null,
                            color: tache.estFaite ? Colors.grey : null,
                          ),
                        ),
                        trailing: IconButton(
                          icon: const Icon(Icons.delete_outline, color: Colors.red),
                          onPressed: () => context.read<TacheProvider>().supprimerTache(tache.id),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }

  void _ajouter(BuildContext context) {
    context.read<TacheProvider>().ajouterTache(_controller.text);
    _controller.clear();
  }
}

Points clés du corrigé avec quelques explications :

create: (_) => TacheProvider()..chargerDepuisStockage(),

Le _ est un paramètre volontairement ignoré : Le create: du ChangeNotifierProvider exige une fonction de fabrique avec cette signature :

create: (context) => ...   // reçoit un BuildContext en paramètre

Cependant, ici, TacheProvider n’a pas besoin du contexte pour être créé. Dart permet de nommer un paramètre inutilisé _ **. C’est une convention universelle signifiant “je reçois quelque chose, mais je m’en fiche”. C’est purement documentaire. Que vous écriviez (context) ou (_), ça compile pareil, mais _ indique que **“ce paramètre n’est pas utilisé ici”.

create: (_) => ...        // le contexte est là, mais ignoré
create: (context) => ...  // identique techniquement, mais suggère qu'on l'utilise

Vous retrouverez cette convention partout : (, index) =>** …, **(, titre) => …, etc. On garde _ pour le(s) paramètre(s) ignorés, et des vrais noms pour ceux qu’on utilise : (context, index) => … → (context, _) => … .

La flèche => c’est la fonction fléchée (arrow syntax) : C’est l’équivalent exact de la lambda Java (->) ou de la fonction fléchée

On pourrait écrire le code comme ceci :

create: (context) {
  return TacheProvider();
}

ou bien de manière plus concise comme cela :

create: (context) => TacheProvider()

2 règles :

Elle apparaît partout : itemBuilder: (context, index) => Card(…), onPressed: () => print(‘ok’), etc.

La cascade .. ou “enchaînement sur la même instance” : C’est la seule syntaxe vraiment propre à Dart des trois ! En fait, la cascade dit : “Continue à travailler sur l’objet que tu viens de créer/obtenir, sans le réassigner.”

Comparaison claire :

// Version verbeuse
final provider = TacheProvider();
provider.chargerDepuisStockage();
return provider;

// Version cascade
TacheProvider()..chargerDepuisStockage()
//     └────────── retourne TOUJOURS le TacheProvider, PAS le résultat de la méthode

Attention : La nuance capitale : avec un point simple ., l’expression TacheProvider().chargerDepuisStockage() vaudrait ce que renvoie chargerDepuisStockage() (probablement void ou un Future). Alors qu’avec .., l’expression vaut le TacheProvider lui-même, enrichi de l’effet de bord. Donc, notre create: reçoit bien un TacheProvider, comme attendu.

La cascade est aussi pratique pour configurer un objet en série :

final controller = TextEditingController()
  ..text = "Bonjour"
  ..selection = TextSelection.collapsed(offset: 8);
// controller vaut bien le TextEditingController, avec les deux réglages appliqués

Un point d’attention sur ce code précis : Si chargerDepuisStockage() est async (ce qui est le cas ici avec le SharedPreferences), la cascade n’attend pas le Future :

TacheProvider()..chargerDepuisStockage()
//              └── la méthode async démarre, mais on n'attend pas sa fin

Ceci n’est pas une erreur : le provider est créé immédiatement, la donnée arrive quelques millisecondes plus tard, et notifyListeners() (appelé dans chargerDepuisStockage à la fin du chargement) préviendra les widgets abonnés. Le mécanisme Observer que nous avons vu fait très bien ce travail “en retard”. C’est même le pattern courant pour un chargement initial. Mais sachez que c’est du fire-and-forget (lance et oublie).


⬅️ Retour au Chapitre 3 ➡️ Chapitre 4