À propos de ce cours
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.builderet une grille adaptative (SliverGridDelegateWithMaxCrossAxisExtent) ; - afficher une liste avec
ListView.builderetListView.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
CardetInkWell; - supprimer un élément par glissement (
Dismissible) avec annulation ; - partager un état entre plusieurs écrans avec
ChangeNotifieretListenableBuilder, sans package externe.
2. Prérequis
- Flutter 3.10 ou plus récent (pour
ListenableBuilderetBadge), 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
- Créez un nouveau fichier Design nommé
TP Listes – Catalogue – NomPrenom. - 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)
- 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 leListTiledu mode liste ;Sélection = Non / Oui– pastille « ✓ 2 modules » quand des modules sont dans le panier.
- 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 ».
- Ligne à cocher (CheckboxListTile): variante
État = Coché / Non coché / Indéterminé(case avec un tiret). L’état Indéterminé correspond àtristate: trueetvalue: null. - Icône panier avec badge: variante
Compteur = 0 / 1+(pastille masquée à 0). - 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
backgroundduDismissible). - Barre de total: texte « Total (n modules, x h) », prix en gras, bouton plein « Valider l’inscription ».
- État vide: grande icône grise, message, bouton secondaire.
- Dialogue de succès: icône, titre, message, bouton.
A.4 Dessiner les 3 maquettes (page Maquettes)
- Frames iPhone 14 (390 × 844), même taille pour les 3, nommées
01_Catalogue,02_DetailFormation,03_Panier. - 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.
- 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 varianteSé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.
- 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
- 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
- Une carte de la maquette 1 → Navigate to maquette 2 (Slide in).
- Icône panier de la maquette 1 et bouton « Mettre à jour » de la maquette 2 → maquette 3 / Back.
- Bouton « Valider l’inscription » → Open overlay: dialogue de succès.
- 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
CardavecclipBehavior: Clip.antiAliaspour 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.Expandedpour la zone d’icône : la carte s’adapte au ratio imposé par la grille.- Paramètre
modulesChoisis: c’est la variante FigmaSé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
Badgeaffichant le nombre de modules. - Recherche : un
TextFieldfiltre les formations par titre ou niveau. - Mode grille :
GridView.builderdeFormationCard. - Mode liste :
ListView.separateddeCardcontenant unListTile. - É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
Cardavec avatar, description et puces (Chip) Niveau / Durée / Modules. - Sélection : un
CheckboxListTilepar module, pré-coché si le module est déjà dans le panier. - Tout sélectionner :
CheckboxListTile(tristate: true)dont la valeur esttrue(tous),false(aucun) ounull(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
bottomNavigationBarpour 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.separatedavec unDividerentre les lignes ; chaque ligne est unListTile(avatar coloré, module, formation, prix). - Suppression : chaque ligne est enveloppée dans un
Dismissible(glissement vers la gauche, fond rouge). UnSnackBarpropose 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)
- Filtres par niveau: ajoutez sous la recherche une rangée de
FilterChip(Débutant, Intermédiaire, Avancé) dans unListViewhorizontal. Dessinez d’abord le composant dans Figma. - Tri: un
PopupMenuButtondans l’AppBar pour trier par prix ou par durée. - Favoris: un
IconButtoncœur dans letrailingduListTileet sur la carte. - Animation Hero: animez l’icône de la carte vers l’avatar de l’écran de détail avec
Hero. - Rafraîchir: enveloppez la grille dans un
RefreshIndicatorqui recharge le catalogue après une courte attente simulée. - GridView.count: réécrivez la grille avec
GridView.countet unLayoutBuilder(2 colonnes < 600 px, 3 colonnes < 900 px, 4 au-delà) et comparez avec la versionmaxCrossAxisExtent.
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.