Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
Flutter

TP Listes et grilles

TP Flutter : maquettez un formulaire d’inscription en 3 écrans dans Figma, puis développez-le avec TextFormField, validation par regex et navigation....

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

TP Flutter : maquettez un formulaire d’inscription en 3 écrans dans Figma, puis développez-le avec TextFormField, validation par regex et navigation.

 

TP Listes et grilles : de la maquette Figma à un catalogue de formations Flutter

 

1. Objectifs du TP

Ce travail pratique (durée estimée : 6 heures, dont 1 h 30 de maquettage et 4 h 30 de développement) clôt le chapitre Listes et grilles. Vous allez concevoir puis développer une application de catalogue de formations: l’utilisateur parcourt les formations en grille ou en liste, choisit des modules et gère son panier d’inscription.

À la fin du TP, vous serez capable de :

  • réaliser manuellement dans Figma des maquettes de listes et de grilles (Auto Layout vertical et Wrap, composants, variantes, prototype) ;
  • afficher une collection avec GridView.builder et une grille adaptative (SliverGridDelegateWithMaxCrossAxisExtent) ;
  • afficher une liste avec ListView.builder et ListView.separated;
  • construire des lignes riches avec ListTile (leading, title, subtitle, trailing, onTap) ;
  • gérer une sélection multiple avec CheckboxListTile, y compris une case « Tout sélectionner » à trois états (tristate) ;
  • mettre en forme les éléments avec Card et InkWell;
  • supprimer un élément par glissement (Dismissible) avec annulation ;
  • partager un état entre plusieurs écrans avec ChangeNotifier et ListenableBuilder, sans package externe.

2. Prérequis

  • Flutter 3.10 ou plus récent (pour ListenableBuilder et Badge), un émulateur ou un téléphone.
  • Un compte Figma gratuit.
  • Avoir lu les cours du chapitre : ListTile, CheckboxListTile, ListView, GridView, Utiliser GridView, Card.

3. Règle fondamentale : une maquette = un écran

Le nombre d’écrans de l’application est égal au nombre de maquettes Figma. Chaque frame de la page « Maquettes » correspond à exactement un fichier dans lib/screens/. Ce TP comporte donc 3 maquettes et 3 écrans.

L’affichage « liste » du catalogue, la carte « sélectionnée », l’état « panier vide », le fond rouge de suppression et la boîte de dialogue de succès ne sont pas des écrans : ils sont dessinés comme variantes de composants ou comme overlay, sans frame supplémentaire.

Maquette Figma (frame) Écran Flutter Widgets principaux du chapitre
01_Catalogue catalogue_screen.dart GridView.builder, Card, ListView.separated + ListTile (mode liste)
02_DetailFormation detail_formation_screen.dart Card, CheckboxListTile (dont tristate), ListView
03_Panier panier_screen.dart ListView.separated, ListTile, Dismissible, Card

4. Partie A – Réalisation des maquettes dans Figma (1 h 30)

Maquettes réalisées à la main, sans IA. Les fonctionnalités de génération de Figma (First Draft, Figma Make, « Make designs »…) et les plugins qui génèrent des écrans, du contenu ou du code sont interdits. Sont autorisés : les outils de dessin, l’Auto Layout, les composants, et un plugin d’icônes (Material Symbols) qui ne fait qu’insérer des icônes existantes. Les textes (titres de formations, modules) sont saisis par vous. L’historique des versions du fichier peut être consulté lors de l’évaluation.

A.1 Préparer le fichier

  1. Créez un nouveau fichier Design nommé TP Listes – Catalogue – NomPrenom.
  2. Créez deux pages : Composants et Maquettes.

A.2 Définir les styles

Style Figma Valeur suggérée Équivalent Flutter
Color / Primary #3F51B5 ColorScheme.fromSeed(seedColor: ...)
Color / Background #F5F6FA scaffoldBackgroundColor
Color / Danger #D32F2F colorScheme.error (fond de suppression)
Color / Formation-x (une par formation) 6 couleurs vives champ couleur du modèle
Text / Title Roboto 20, Medium textTheme.titleLarge
Text / Card title Roboto 16, Medium textTheme.titleSmall / ListTile.title
Text / Caption Roboto 12, Regular textTheme.bodySmall / ListTile.subtitle
Effect / Card shadow Drop shadow Y 1, flou 3, 15 % Card(elevation: 2)

A.3 Créer les composants (page Composants)

  1. Carte formation (167 × 223, rayon 12, ombre) : zone colorée avec une grande icône en haut, titre sur 2 lignes, ligne « Niveau • Durée », prix. Auto Layout vertical (Maj + A), puis composant (Ctrl/Cmd + Alt + K).
    Propriétés de variante :
    • Disposition = Grille / Liste – la version Liste est une ligne pleine largeur (avatar rond + textes + prix) : c’est le ListTile du mode liste ;
    • Sélection = Non / Oui – pastille « ✓ 2 modules » quand des modules sont dans le panier.
  2. Ligne de liste (ListTile): hauteur 72, Auto Layout horizontal [leading 40 × 40] [titre + sous-titre, Fill] [trailing]. Propriétés booléennes « Leading » et « Trailing ».
  3. Ligne à cocher (CheckboxListTile): variante État = Coché / Non coché / Indéterminé (case avec un tiret). L’état Indéterminé correspond à tristate: true et value: null.
  4. Icône panier avec badge: variante Compteur = 0 / 1+ (pastille masquée à 0).
  5. Ligne supprimée: la ligne de liste décalée vers la gauche laissant apparaître un fond Danger avec une icône corbeille (c’est le background du Dismissible).
  6. Barre de total: texte « Total (n modules, x h) », prix en gras, bouton plein « Valider l’inscription ».
  7. État vide: grande icône grise, message, bouton secondaire.
  8. Dialogue de succès: icône, titre, message, bouton.

A.4 Dessiner les 3 maquettes (page Maquettes)

  1. Frames iPhone 14 (390 × 844), même taille pour les 3, nommées 01_Catalogue, 02_DetailFormation, 03_Panier.
  2. Grille de mise en page : 2 colonnes, marge 16, gouttière 12. Deux cartes de 167 px + 12 px d’espace + 2 × 16 px de marge = 390 px.
  3. N’utilisez que des instances de vos composants :
    • Maquette 1 – Catalogue: AppBar « Catalogue des formations » avec icône de bascule grille/liste et icône panier avec badge ; champ de recherche ; 6 cartes placées dans un cadre en Auto Layout → Wrap (espacement horizontal et vertical 12). Le Wrap de Figma se comporte exactement comme une GridView: les cartes passent à la ligne quand la largeur est atteinte. Une carte au moins doit être en variante Sélection = Oui.
    • Maquette 2 – Détail: AppBar avec retour et titre de la formation ; carte d’en-tête (avatar, titre, description, puces Niveau / Durée / Modules) ; titre « Modules » ; une carte contenant la ligne « Tout sélectionner » en état Indéterminé, un séparateur, puis 4 lignes à cocher (2 cochées) ; en bas, sous-total et bouton « Mettre à jour le panier (2) ».
    • Maquette 3 – Panier: AppBar « Mon panier » avec icône corbeille ; liste de 4 lignes séparées par des traits fins, dont une en état « Ligne supprimée » ; barre de total collée en bas.
  4. Testez le redimensionnement : élargissez la frame 01 à 600 px, les cartes doivent passer à 3 colonnes grâce au Wrap. Remettez la frame à 390 px.

A.5 Prototype

  1. Une carte de la maquette 1 → Navigate to maquette 2 (Slide in).
  2. Icône panier de la maquette 1 et bouton « Mettre à jour » de la maquette 2 → maquette 3 / Back.
  3. Bouton « Valider l’inscription » → Open overlay: dialogue de succès.
  4. Activez le défilement vertical (Overflow → Vertical) sur les maquettes 1 et 3 si le contenu dépasse : c’est l’équivalent du défilement d’une ListView.

A.6 Livrables Figma

  • Lien de partage du fichier (droit « peut voir »).
  • Export PNG @2x des 3 frames + une capture de la page Composants montrant toutes les variantes.

A.7 Correspondance Figma → Flutter

Élément Figma Widget Flutter
Cadre en Auto Layout Wrap de cartes GridView.builder + SliverGridDelegateWithMaxCrossAxisExtent
Carte formation (variante Grille) Card + InkWell + Column
Carte formation (variante Liste) / Ligne de liste ListTile dans une Card
Auto Layout vertical de lignes avec traits ListView.separated + Divider
Ligne à cocher (3 états) CheckboxListTile (tristate: true)
Icône panier avec badge Badge + Icon
Ligne supprimée (fond rouge) Dismissible(background: ...)
Overflow vertical du prototype Défilement natif de ListView / GridView
Overlay de succès showDialog + AlertDialog

5. Partie B – Architecture du projet Flutter

Créez le projet (flutter create tp_catalogue) et organisez le dossier lib/ ainsi :

lib/
│
├── models/
│   ├── formation.dart              # Formation, Module, formatPrix()
│   └── panier.dart                 # État partagé (ChangeNotifier)
│
├── data/
│   └── catalogue.dart              # Données de démonstration
│
├── widgets/
│   └── formation_card.dart         # Composant "Carte formation"
│
├── screens/
│   ├── catalogue_screen.dart       # Maquette 01
│   ├── detail_formation_screen.dart# Maquette 02
│   └── panier_screen.dart          # Maquette 03
│
└── main.dart                       # Thème issu des styles Figma

test/
└── panier_test.dart                # Tests unitaires du panier

Le dossier widgets/ est le pendant des composants Figma : un composant réutilisé dans plusieurs maquettes devient un widget réutilisé dans plusieurs écrans.

6. Étape 1 – Les modèles Formation et Module

Une formation contient une liste de modules. La durée et le prix total sont calculés (getters) à partir des modules, ce qui évite les incohérences.

lib/models/formation.dart

Afficher le code
import 'package:flutter/material.dart';

/// Affiche un prix en dinars tunisiens.
String formatPrix(double prix) => '${prix.toStringAsFixed(0)} DT';

class Module {
  final String id;
  final String titre;
  final int heures;
  final double prix;

  const Module({
    required this.id,
    required this.titre,
    required this.heures,
    required this.prix,
  });
}

class Formation {
  final String id;
  final String titre;
  final String niveau;
  final String description;
  final IconData icone;
  final Color couleur;
  final List<Module> modules;

  const Formation({
    required this.id,
    required this.titre,
    required this.niveau,
    required this.description,
    required this.icone,
    required this.couleur,
    required this.modules,
  });

  int get dureeTotale => modules.fold<int>(0, (somme, m) => somme + m.heures);

  double get prixTotal => modules.fold<double>(0, (somme, m) => somme + m.prix);
}

7. Étape 2 – Les données du catalogue

Les données sont déclarées en const. Reprenez les mêmes titres, icônes et couleurs que dans vos maquettes Figma.

lib/data/catalogue.dart

Afficher le code
import 'package:flutter/material.dart';

import '../models/formation.dart';

const List<Formation> catalogue = [
  Formation(
    id: 'flutter',
    titre: 'Développement mobile avec Flutter',
    niveau: 'Intermédiaire',
    description: 'Créez des applications Android et iOS à partir d\'un seul code Dart.',
    icone: Icons.phone_android,
    couleur: Color(0xFF0288D1),
    modules: [
      Module(id: 'flutter-1', titre: 'Les bases de Dart', heures: 12, prix: 120),
      Module(id: 'flutter-2', titre: 'Widgets et mise en page', heures: 16, prix: 160),
      Module(id: 'flutter-3', titre: 'Listes, grilles et navigation', heures: 14, prix: 140),
      Module(id: 'flutter-4', titre: 'Formulaires et validation', heures: 10, prix: 100),
    ],
  ),
  Formation(
    id: 'web',
    titre: 'Intégration web HTML / CSS',
    niveau: 'Débutant',
    description: 'Structurez et mettez en forme des pages web modernes et responsives.',
    icone: Icons.language,
    couleur: Color(0xFFE64A19),
    modules: [
      Module(id: 'web-1', titre: 'Structure HTML5', heures: 8, prix: 70),
      Module(id: 'web-2', titre: 'CSS et Flexbox', heures: 12, prix: 100),
      Module(id: 'web-3', titre: 'CSS Grid et responsive', heures: 10, prix: 90),
    ],
  ),
  Formation(
    id: 'php',
    titre: 'Applications web PHP MVC',
    niveau: 'Avancé',
    description: 'Concevez une application web structurée en MVC avec PHP et MySQL.',
    icone: Icons.code,
    couleur: Color(0xFF5E35B1),
    modules: [
      Module(id: 'php-1', titre: 'PHP orienté objet', heures: 14, prix: 140),
      Module(id: 'php-2', titre: 'Le modèle MVC', heures: 12, prix: 120),
      Module(id: 'php-3', titre: 'PDO et MySQL', heures: 10, prix: 100),
      Module(id: 'php-4', titre: 'Sécurité des formulaires', heures: 8, prix: 80),
    ],
  ),
  Formation(
    id: 'bdd',
    titre: 'Bases de données SQL',
    niveau: 'Débutant',
    description: 'Modélisez des données et écrivez des requêtes SQL efficaces.',
    icone: Icons.storage,
    couleur: Color(0xFF2E7D32),
    modules: [
      Module(id: 'bdd-1', titre: 'Modélisation MCD / MLD', heures: 10, prix: 90),
      Module(id: 'bdd-2', titre: 'Requêtes SELECT et jointures', heures: 12, prix: 110),
      Module(id: 'bdd-3', titre: 'Procédures et triggers', heures: 8, prix: 80),
    ],
  ),
  Formation(
    id: 'design',
    titre: 'Maquettage UI avec Figma',
    niveau: 'Débutant',
    description: 'Concevez des interfaces claires avec des composants et des prototypes.',
    icone: Icons.design_services,
    couleur: Color(0xFFC2185B),
    modules: [
      Module(id: 'design-1', titre: 'Frames, grilles et styles', heures: 6, prix: 60),
      Module(id: 'design-2', titre: 'Composants et variantes', heures: 8, prix: 80),
      Module(id: 'design-3', titre: 'Prototypage', heures: 6, prix: 60),
    ],
  ),
  Formation(
    id: 'reseau',
    titre: 'Administration réseau',
    niveau: 'Intermédiaire',
    description: 'Configurez et sécurisez un réseau local d\'entreprise.',
    icone: Icons.router,
    couleur: Color(0xFFF9A825),
    modules: [
      Module(id: 'reseau-1', titre: 'Adressage IP et sous-réseaux', heures: 10, prix: 100),
      Module(id: 'reseau-2', titre: 'Routage et VLAN', heures: 12, prix: 120),
      Module(id: 'reseau-3', titre: 'Pare-feu et VPN', heures: 10, prix: 110),
    ],
  ),
];

8. Étape 3 – L’état partagé : le panier

Les trois écrans ont besoin du même panier : le catalogue affiche le badge et la pastille de sélection, le détail modifie les modules choisis, le panier les liste. On crée donc une seule instance de Panier (un ChangeNotifier) transmise aux écrans. Chaque modification appelle notifyListeners() et les ListenableBuilder se reconstruisent automatiquement.

lib/models/panier.dart

Afficher le code
import 'package:flutter/foundation.dart';

import 'formation.dart';

class ElementPanier {
  final Formation formation;
  final Module module;

  const ElementPanier(this.formation, this.module);

  /// Clé unique, utilisée par Dismissible.
  String get cle => module.id;
}

class Panier extends ChangeNotifier {
  final List<ElementPanier> _elements = [];

  List<ElementPanier> get elements => List.unmodifiable(_elements);
  int get nombre => _elements.length;
  bool get estVide => _elements.isEmpty;

  int get totalHeures => _elements.fold<int>(0, (s, e) => s + e.module.heures);
  double get totalPrix => _elements.fold<double>(0, (s, e) => s + e.module.prix);

  bool contient(Module m) => _elements.any((e) => e.module.id == m.id);

  /// Identifiants des modules d'une formation déjà présents dans le panier.
  Set<String> modulesDe(Formation f) => _elements
      .where((e) => e.formation.id == f.id)
      .map((e) => e.module.id)
      .toSet();

  /// Remplace les modules choisis pour une formation.
  void definirModules(Formation f, Set<String> idsModules) {
    _elements.removeWhere((e) => e.formation.id == f.id);
    for (final m in f.modules) {
      if (idsModules.contains(m.id)) _elements.add(ElementPanier(f, m));
    }
    notifyListeners();
  }

  /// Retire un élément et renvoie sa position (pour pouvoir l'annuler).
  int retirer(ElementPanier e) {
    final index = _elements.indexWhere((x) => x.cle == e.cle);
    if (index >= 0) {
      _elements.removeAt(index);
      notifyListeners();
    }
    return index;
  }

  /// Réinsère un élément (bouton « Annuler » du SnackBar).
  void inserer(int index, ElementPanier e) {
    if (index < 0 || contient(e.module)) return;
    final position = index > _elements.length ? _elements.length : index;
    _elements.insert(position, e);
    notifyListeners();
  }

  void vider() {
    _elements.clear();
    notifyListeners();
  }
}

9. Étape 4 – Le composant FormationCard

  • Card avec clipBehavior: Clip.antiAlias pour que l’effet d’encre et la zone colorée respectent les coins arrondis.
  • InkWell à l’intérieur de la carte pour rendre toute la surface cliquable avec un retour visuel.
  • Expanded pour la zone d’icône : la carte s’adapte au ratio imposé par la grille.
  • Paramètre modulesChoisis: c’est la variante Figma Sélection = Oui.

lib/widgets/formation_card.dart

Afficher le code
import 'package:flutter/material.dart';

import '../models/formation.dart';

class FormationCard extends StatelessWidget {
  final Formation formation;
  final int modulesChoisis;
  final VoidCallback onTap;

  const FormationCard({
    super.key,
    required this.formation,
    required this.onTap,
    this.modulesChoisis = 0,
  });

  @override
  Widget build(BuildContext context) {
    final texte = Theme.of(context).textTheme;

    return Card(
      elevation: 2,
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      child: InkWell(
        onTap: onTap,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Expanded(
              child: Stack(
                children: [
                  Positioned.fill(
                    child: Container(
                      color: formation.couleur.withAlpha(38),
                      child: Icon(formation.icone, size: 48, color: formation.couleur),
                    ),
                  ),
                  if (modulesChoisis > 0)
                    Positioned(
                      top: 8,
                      right: 8,
                      child: Container(
                        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
                        decoration: BoxDecoration(
                          color: formation.couleur,
                          borderRadius: BorderRadius.circular(12),
                        ),
                        child: Text(
                          '✓ $modulesChoisis',
                          style: const TextStyle(color: Colors.white, fontSize: 12),
                        ),
                      ),
                    ),
                ],
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(10),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    formation.titre,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: texte.titleSmall,
                  ),
                  const SizedBox(height: 4),
                  Text(
                    '${formation.niveau} • ${formation.dureeTotale} h',
                    style: texte.bodySmall,
                  ),
                  const SizedBox(height: 4),
                  Text(
                    formatPrix(formation.prixTotal),
                    style: texte.labelLarge?.copyWith(color: formation.couleur),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

10. Étape 5 – Écran 1 : Catalogue (maquette 01)

10.1 Choisir le bon constructeur de GridView

Constructeur Principe Quand l’utiliser
GridView.count Nombre de colonnes fixe (crossAxisCount) Peu d’éléments, toujours le même nombre de colonnes
GridView.extent Largeur maximale d’une case (maxCrossAxisExtent) Peu d’éléments, grille adaptative
GridView.builder Construit les cases à la demande (itemBuilder) Listes de données, longues ou de taille variable – choix de ce TP

Avec SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 220), Flutter calcule le nombre de colonnes selon la largeur disponible : 2 colonnes sur un téléphone de 390 px, 3 ou plus sur une tablette – exactement comme le Wrap testé dans Figma.

10.2 Cahier des charges

  • AppBar : titre « Catalogue des formations », bouton de bascule grille/liste, icône panier avec Badge affichant le nombre de modules.
  • Recherche : un TextField filtre les formations par titre ou niveau.
  • Mode grille : GridView.builder de FormationCard.
  • Mode liste : ListView.separated de Card contenant un ListTile.
  • État vide : message si la recherche ne donne aucun résultat.

lib/screens/catalogue_screen.dart

Afficher le code
import 'package:flutter/material.dart';

import '../data/catalogue.dart';
import '../models/formation.dart';
import '../models/panier.dart';
import '../widgets/formation_card.dart';
import 'detail_formation_screen.dart';
import 'panier_screen.dart';

class CatalogueScreen extends StatefulWidget {
  final Panier panier;

  const CatalogueScreen({super.key, required this.panier});

  @override
  State<CatalogueScreen> createState() => _CatalogueScreenState();
}

class _CatalogueScreenState extends State<CatalogueScreen> {
  bool _enGrille = true;
  String _recherche = '';

  List<Formation> get _formationsFiltrees {
    final q = _recherche.trim().toLowerCase();
    if (q.isEmpty) return catalogue;
    return catalogue
        .where((f) =>
            f.titre.toLowerCase().contains(q) || f.niveau.toLowerCase().contains(q))
        .toList();
  }

  void _ouvrirDetail(Formation f) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => DetailFormationScreen(formation: f, panier: widget.panier),
      ),
    );
  }

  void _ouvrirPanier() {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (_) => PanierScreen(panier: widget.panier)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Catalogue des formations'),
        actions: [
          IconButton(
            tooltip: _enGrille ? 'Afficher en liste' : 'Afficher en grille',
            icon: Icon(_enGrille ? Icons.view_list : Icons.grid_view),
            onPressed: () => setState(() => _enGrille = !_enGrille),
          ),
          ListenableBuilder(
            listenable: widget.panier,
            builder: (context, _) => IconButton(
              tooltip: 'Mon panier',
              onPressed: _ouvrirPanier,
              icon: Badge(
                isLabelVisible: widget.panier.nombre > 0,
                label: Text('${widget.panier.nombre}'),
                child: const Icon(Icons.shopping_cart_outlined),
              ),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
            child: TextField(
              decoration: const InputDecoration(
                hintText: 'Rechercher une formation…',
                prefixIcon: Icon(Icons.search),
              ),
              onChanged: (v) => setState(() => _recherche = v),
            ),
          ),
          Expanded(
            child: ListenableBuilder(
              listenable: widget.panier,
              builder: (context, _) {
                final formations = _formationsFiltrees;
                if (formations.isEmpty) return const _AucunResultat();
                return _enGrille ? _grille(formations) : _liste(formations);
              },
            ),
          ),
        ],
      ),
    );
  }

  Widget _grille(List<Formation> formations) {
    return GridView.builder(
      padding: const EdgeInsets.all(16),
      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 220,
        mainAxisSpacing: 12,
        crossAxisSpacing: 12,
        childAspectRatio: 0.75, // 167 / 223 comme dans Figma
      ),
      itemCount: formations.length,
      itemBuilder: (context, index) {
        final f = formations[index];
        return FormationCard(
          formation: f,
          modulesChoisis: widget.panier.modulesDe(f).length,
          onTap: () => _ouvrirDetail(f),
        );
      },
    );
  }

  Widget _liste(List<Formation> formations) {
    return ListView.separated(
      padding: const EdgeInsets.all(16),
      itemCount: formations.length,
      separatorBuilder: (_, __) => const SizedBox(height: 8),
      itemBuilder: (context, index) {
        final f = formations[index];
        final choisis = widget.panier.modulesDe(f).length;
        return Card(
          elevation: 1,
          child: ListTile(
            leading: CircleAvatar(
              backgroundColor: f.couleur.withAlpha(38),
              child: Icon(f.icone, color: f.couleur),
            ),
            title: Text(f.titre),
            subtitle: Text(
              '${f.niveau} • ${f.modules.length} modules • ${f.dureeTotale} h'
              '${choisis > 0 ? '\n✓ $choisis module(s) choisi(s)' : ''}',
            ),
            isThreeLine: choisis > 0,
            trailing: Text(formatPrix(f.prixTotal)),
            onTap: () => _ouvrirDetail(f),
          ),
        );
      },
    );
  }
}

class _AucunResultat extends StatelessWidget {
  const _AucunResultat();

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.search_off, size: 64, color: Colors.grey),
          SizedBox(height: 8),
          Text('Aucune formation ne correspond à votre recherche'),
        ],
      ),
    );
  }
}

11. Étape 6 – Écran 2 : Détail de la formation (maquette 02)

  • En-tête : une Card avec avatar, description et puces (Chip) Niveau / Durée / Modules.
  • Sélection : un CheckboxListTile par module, pré-coché si le module est déjà dans le panier.
  • Tout sélectionner : CheckboxListTile(tristate: true) dont la valeur est true (tous), false (aucun) ou null (certains). Un clic coche tout, sauf si tout est déjà coché.
  • Barre du bas : sous-total de la sélection et bouton « Mettre à jour le panier » placés dans bottomNavigationBar pour rester visibles pendant le défilement.

lib/screens/detail_formation_screen.dart

Afficher le code
import 'package:flutter/material.dart';

import '../models/formation.dart';
import '../models/panier.dart';

class DetailFormationScreen extends StatefulWidget {
  final Formation formation;
  final Panier panier;

  const DetailFormationScreen({
    super.key,
    required this.formation,
    required this.panier,
  });

  @override
  State<DetailFormationScreen> createState() => _DetailFormationScreenState();
}

class _DetailFormationScreenState extends State<DetailFormationScreen> {
  late final Set<String> _selection;

  Formation get f => widget.formation;

  @override
  void initState() {
    super.initState();
    _selection = widget.panier.modulesDe(f);
  }

  /// true = tous cochés, false = aucun, null = certains (état indéterminé)
  bool? get _etatTout {
    if (_selection.isEmpty) return false;
    if (_selection.length == f.modules.length) return true;
    return null;
  }

  double get _sousTotal => f.modules
      .where((m) => _selection.contains(m.id))
      .fold<double>(0, (s, m) => s + m.prix);

  void _basculerTout() {
    setState(() {
      if (_etatTout == true) {
        _selection.clear();
      } else {
        _selection.addAll(f.modules.map((m) => m.id));
      }
    });
  }

  void _basculerModule(Module m, bool? coche) {
    setState(() {
      if (coche == true) {
        _selection.add(m.id);
      } else {
        _selection.remove(m.id);
      }
    });
  }

  void _valider() {
    final messenger = ScaffoldMessenger.of(context);
    widget.panier.definirModules(f, _selection);
    Navigator.pop(context);
    messenger.showSnackBar(
      SnackBar(
        content: Text(_selection.isEmpty
            ? '« ${f.titre} » retirée du panier'
            : '${_selection.length} module(s) de « ${f.titre} » dans le panier'),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final texte = Theme.of(context).textTheme;

    return Scaffold(
      appBar: AppBar(title: Text(f.titre, overflow: TextOverflow.ellipsis)),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      CircleAvatar(
                        radius: 28,
                        backgroundColor: f.couleur.withAlpha(38),
                        child: Icon(f.icone, color: f.couleur, size: 30),
                      ),
                      const SizedBox(width: 12),
                      Expanded(child: Text(f.titre, style: texte.titleMedium)),
                    ],
                  ),
                  const SizedBox(height: 12),
                  Text(f.description),
                  const SizedBox(height: 12),
                  Wrap(
                    spacing: 8,
                    runSpacing: 8,
                    children: [
                      Chip(avatar: const Icon(Icons.signal_cellular_alt, size: 18), label: Text(f.niveau)),
                      Chip(avatar: const Icon(Icons.schedule, size: 18), label: Text('${f.dureeTotale} h')),
                      Chip(avatar: const Icon(Icons.view_module, size: 18), label: Text('${f.modules.length} modules')),
                    ],
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          Text('Modules', style: texte.titleMedium),
          const SizedBox(height: 8),
          Card(
            child: Column(
              children: [
                CheckboxListTile(
                  tristate: true,
                  value: _etatTout,
                  onChanged: (_) => _basculerTout(),
                  title: const Text('Tout sélectionner'),
                  controlAffinity: ListTileControlAffinity.leading,
                ),
                const Divider(height: 1),
                ...f.modules.map(
                  (m) => CheckboxListTile(
                    value: _selection.contains(m.id),
                    onChanged: (v) => _basculerModule(m, v),
                    title: Text(m.titre),
                    subtitle: Text('${m.heures} h • ${formatPrix(m.prix)}'),
                    secondary: Icon(Icons.menu_book_outlined, color: f.couleur),
                    controlAffinity: ListTileControlAffinity.leading,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  const Text('Sous-total'),
                  Text(formatPrix(_sousTotal), style: texte.titleMedium),
                ],
              ),
              const SizedBox(height: 8),
              FilledButton.icon(
                onPressed: _valider,
                icon: const Icon(Icons.add_shopping_cart),
                label: Text('Mettre à jour le panier (${_selection.length})'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

12. Étape 7 – Écran 3 : Mon panier (maquette 03)

  • Liste : ListView.separated avec un Divider entre les lignes ; chaque ligne est un ListTile (avatar coloré, module, formation, prix).
  • Suppression : chaque ligne est enveloppée dans un Dismissible (glissement vers la gauche, fond rouge). Un SnackBar propose d’annuler.
  • Total : barre fixe en bas (nombre de modules, heures, prix) et bouton « Valider l’inscription ».
  • AppBar : icône corbeille pour vider le panier après confirmation, désactivée si le panier est vide.
  • État vide : message et bouton de retour au catalogue.

lib/screens/panier_screen.dart

Afficher le code
import 'package:flutter/material.dart';

import '../models/formation.dart';
import '../models/panier.dart';

class PanierScreen extends StatelessWidget {
  final Panier panier;

  const PanierScreen({super.key, required this.panier});

  void _retirer(BuildContext context, ElementPanier e) {
    final index = panier.retirer(e);
    ScaffoldMessenger.of(context)
      ..hideCurrentSnackBar()
      ..showSnackBar(
        SnackBar(
          content: Text('« ${e.module.titre} » retiré'),
          action: SnackBarAction(
            label: 'Annuler',
            onPressed: () => panier.inserer(index, e),
          ),
        ),
      );
  }

  Future<void> _vider(BuildContext context) async {
    final ok = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Vider le panier ?'),
        content: const Text('Tous les modules sélectionnés seront retirés.'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
          FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Vider')),
        ],
      ),
    );
    if (ok == true) panier.vider();
  }

  Future<void> _valider(BuildContext context) async {
    await showDialog<void>(
      context: context,
      barrierDismissible: false,
      builder: (ctx) => AlertDialog(
        icon: const Icon(Icons.check_circle, color: Colors.green, size: 48),
        title: const Text('Inscription enregistrée'),
        content: Text(
          '${panier.nombre} module(s) • ${panier.totalHeures} h\n'
          'Montant : ${formatPrix(panier.totalPrix)}',
          textAlign: TextAlign.center,
        ),
        actions: [
          FilledButton(onPressed: () => Navigator.pop(ctx), child: const Text('Terminer')),
        ],
      ),
    );
    panier.vider();
    if (context.mounted) Navigator.popUntil(context, (route) => route.isFirst);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Mon panier'),
        actions: [
          ListenableBuilder(
            listenable: panier,
            builder: (context, _) => IconButton(
              tooltip: 'Vider le panier',
              icon: const Icon(Icons.delete_outline),
              onPressed: panier.estVide ? null : () => _vider(context),
            ),
          ),
        ],
      ),
      body: ListenableBuilder(
        listenable: panier,
        builder: (context, _) {
          if (panier.estVide) return const _PanierVide();
          final elements = panier.elements;

          return Column(
            children: [
              Expanded(
                child: ListView.separated(
                  itemCount: elements.length,
                  separatorBuilder: (_, __) => const Divider(height: 1),
                  itemBuilder: (context, index) {
                    final e = elements[index];
                    return Dismissible(
                      key: ValueKey(e.cle),
                      direction: DismissDirection.endToStart,
                      background: Container(
                        color: Theme.of(context).colorScheme.error,
                        alignment: Alignment.centerRight,
                        padding: const EdgeInsets.symmetric(horizontal: 24),
                        child: const Icon(Icons.delete, color: Colors.white),
                      ),
                      onDismissed: (_) => _retirer(context, e),
                      child: ListTile(
                        tileColor: Colors.white,
                        leading: CircleAvatar(
                          backgroundColor: e.formation.couleur.withAlpha(38),
                          child: Icon(e.formation.icone, color: e.formation.couleur),
                        ),
                        title: Text(e.module.titre),
                        subtitle: Text('${e.formation.titre} • ${e.module.heures} h'),
                        trailing: Text(formatPrix(e.module.prix)),
                      ),
                    );
                  },
                ),
              ),
              _BarreTotal(panier: panier, onValider: () => _valider(context)),
            ],
          );
        },
      ),
    );
  }
}

class _BarreTotal extends StatelessWidget {
  final Panier panier;
  final VoidCallback onValider;

  const _BarreTotal({required this.panier, required this.onValider});

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 8,
      child: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text('Total (${panier.nombre} modules, ${panier.totalHeures} h)'),
                  Text(
                    formatPrix(panier.totalPrix),
                    style: Theme.of(context).textTheme.titleLarge,
                  ),
                ],
              ),
              const SizedBox(height: 12),
              FilledButton.icon(
                onPressed: onValider,
                icon: const Icon(Icons.check),
                label: const Text("Valider l'inscription"),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _PanierVide extends StatelessWidget {
  const _PanierVide();

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Icon(Icons.remove_shopping_cart_outlined, size: 72, color: Colors.grey),
          const SizedBox(height: 12),
          const Text('Votre panier est vide'),
          const SizedBox(height: 16),
          OutlinedButton.icon(
            onPressed: () => Navigator.pop(context),
            icon: const Icon(Icons.arrow_back),
            label: const Text('Parcourir le catalogue'),
          ),
        ],
      ),
    );
  }
}

13. Étape 8 – Le thème et le point d’entrée

Le panier est créé une seule fois dans main.dart puis transmis au premier écran, qui le transmet aux suivants.

lib/main.dart

Afficher le code
import 'package:flutter/material.dart';

import 'models/panier.dart';
import 'screens/catalogue_screen.dart';

void main() => runApp(CatalogueApp());

class CatalogueApp extends StatelessWidget {
  CatalogueApp({super.key});

  /// Instance unique partagée par les trois écrans.
  final Panier panier = Panier();

  // Valeurs reprises des styles Figma
  static const Color primary = Color(0xFF3F51B5);
  static const Color background = Color(0xFFF5F6FA);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Catalogue de formations',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(seedColor: primary),
        scaffoldBackgroundColor: background,
        appBarTheme: const AppBarTheme(
          backgroundColor: primary,
          foregroundColor: Colors.white,
        ),
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          fillColor: Colors.white,
          border: OutlineInputBorder(borderRadius: BorderRadius.circular(28)),
          contentPadding: const EdgeInsets.symmetric(horizontal: 16),
        ),
        filledButtonTheme: FilledButtonThemeData(
          style: FilledButton.styleFrom(
            minimumSize: const Size.fromHeight(48),
            shape: const StadiumBorder(),
          ),
        ),
      ),
      home: CatalogueScreen(panier: panier),
    );
  }
}

14. Étape 9 – Tester l’application

14.1 Scénario de tests manuels

Action Résultat attendu
Lancer l’application sur un téléphone Grille de 6 cartes sur 2 colonnes, badge panier masqué
Faire pivoter l’appareil / lancer sur tablette ou web La grille passe automatiquement à 3 colonnes ou plus
Toucher l’icône de bascule Affichage en liste (ListTile), l’icône change
Rechercher sql puis zzz 1 formation, puis le message « Aucune formation… »
Ouvrir « Flutter », cocher 2 modules « Tout sélectionner » passe en état indéterminé (tiret), sous-total mis à jour
Toucher « Tout sélectionner » deux fois Tout coché, puis tout décoché
Valider avec 2 modules Retour au catalogue, SnackBar, badge « 2 », pastille « ✓ 2 » sur la carte
Rouvrir « Flutter » Les 2 modules sont déjà cochés
Dans le panier, glisser une ligne vers la gauche Fond rouge, ligne retirée, total recalculé, SnackBar « Annuler »
Toucher « Annuler » La ligne réapparaît à sa position d’origine
Vider le panier Confirmation, puis état vide ; icône corbeille désactivée
Valider l’inscription Dialogue de succès, panier vidé, retour au catalogue

14.2 Tests unitaires du panier

Remplacez tp_catalogue par le nom de votre projet, puis lancez flutter test.

test/panier_test.dart

Afficher le code
import 'package:flutter_test/flutter_test.dart';
import 'package:tp_catalogue/data/catalogue.dart';
import 'package:tp_catalogue/models/panier.dart';

void main() {
  final flutter = catalogue.firstWhere((f) => f.id == 'flutter');
  final web = catalogue.firstWhere((f) => f.id == 'web');

  test('un panier neuf est vide', () {
    final p = Panier();
    expect(p.estVide, isTrue);
    expect(p.totalPrix, 0);
  });

  test('definirModules ajoute les modules choisis', () {
    final p = Panier()..definirModules(flutter, {'flutter-1', 'flutter-3'});
    expect(p.nombre, 2);
    expect(p.totalHeures, 12 + 14);
    expect(p.totalPrix, 120 + 140);
  });

  test('definirModules remplace la sélection précédente', () {
    final p = Panier()
      ..definirModules(flutter, {'flutter-1', 'flutter-2'})
      ..definirModules(web, {'web-1'})
      ..definirModules(flutter, {'flutter-4'});
    expect(p.modulesDe(flutter), {'flutter-4'});
    expect(p.nombre, 2);
  });

  test('retirer puis inserer restaure la position', () {
    final p = Panier()..definirModules(flutter, {'flutter-1', 'flutter-2', 'flutter-3'});
    final e = p.elements[1];
    final index = p.retirer(e);
    expect(p.nombre, 2);
    p.inserer(index, e);
    expect(p.elements[1].cle, 'flutter-2');
  });

  test('notifie les écouteurs à chaque modification', () {
    final p = Panier();
    var appels = 0;
    p.addListener(() => appels++);
    p.definirModules(web, {'web-1'});
    p.vider();
    expect(appels, 2);
  });
}

15. Exercices complémentaires (bonus)

  1. Filtres par niveau: ajoutez sous la recherche une rangée de FilterChip (Débutant, Intermédiaire, Avancé) dans un ListView horizontal. Dessinez d’abord le composant dans Figma.
  2. Tri: un PopupMenuButton dans l’AppBar pour trier par prix ou par durée.
  3. Favoris: un IconButton cœur dans le trailing du ListTile et sur la carte.
  4. Animation Hero: animez l’icône de la carte vers l’avatar de l’écran de détail avec Hero.
  5. Rafraîchir: enveloppez la grille dans un RefreshIndicator qui recharge le catalogue après une courte attente simulée.
  6. GridView.count: réécrivez la grille avec GridView.count et un LayoutBuilder (2 colonnes < 600 px, 3 colonnes < 900 px, 4 au-delà) et comparez avec la version maxCrossAxisExtent.

16. Livrables et barème (/20)

Critère Points
Figma – 3 frames nommées, même taille, grille 2 colonnes, styles utilisés 1,5
Figma – grille de cartes en Auto Layout Wrap qui s’adapte à la largeur 1
Figma – composants et variantes (carte Grille/Liste/Sélection, case 3 états, badge, ligne supprimée) 2
Figma – prototype navigable + overlay, réalisé sans IA 1,5
Flutter – conformité visuelle aux maquettes (3 écrans = 3 maquettes) 3
Flutter – GridView.builder adaptative et bascule grille / liste 2,5
Flutter – ListView, ListTile et Card correctement utilisés 2
Flutter – CheckboxListTile et « Tout sélectionner » à 3 états 2
Flutter – panier partagé, Dismissible avec annulation, total et validation 2,5
Tests – flutter test passe sans erreur 2
Total 20

À rendre : le lien Figma, les exports PNG, l’archive des dossiers lib/ et test/, et trois captures d’écran de l’application placées à côté des maquettes correspondantes.