L’objectif, ici est de reprendre soit la correction du TP du jour1 ou bien votre propre code et d’ajouter une page pour pouvoir naviguer entre les 2 pages. ça permet de ne pas tout réécrire. Je vous propose pour cet exercice de construire une structure différente avec des packages correspondants aux classes de widgets utilisées.
lib/ ├── main.dart point d'entrée avec "MaterialApp" (minimum pour lancer notre application) ├── pages/ │ ├── page_carte_visite.dart votre "pageCarteVisite" déjà existante │ └── page_experience.dart votre nouvelle "PageExperience" (à créer) └── widgets/ └── ligne_contact.dart votre "LigneContact" (réutilisable) et existant
_LigneContact
LigneContact
Voici les rendus sur l’émulateur Android Pixel7:
import 'package:flutter/material.dart'; import 'pages/page_carte_visite.dart'; void main() => runApp(const CarteVisiteApp()); class CarteVisiteApp extends StatelessWidget { const CarteVisiteApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Ma Carte de Visite', theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true), home: const PageCarteVisite(), // appel constructeur de la page carte de visite ); } }
import 'package:flutter/material.dart'; import '../widgets/ligne_contact.dart'; import 'page_experience.dart'; // votre nouvelle page sur l'expérience en alternance class PageCarteVisite extends StatefulWidget { const PageCarteVisite({super.key}); @override State<PageCarteVisite> createState() => _PageCarteVisiteState(); } class _PageCarteVisiteState extends State<PageCarteVisite> { int _vues = 0; // ici, on utilise une SnackBar pour afficher un message temporaire lorsque l'utilisateur appuie longuement sur l'avatar. void _incrementerVues() { setState(() => _vues++); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Vue n°$_vues enregistrée !"), duration: const Duration(seconds: 1), ), ); } void _envoyerMessage() { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text("Message envoyé !"))); } void _ouvrirExperience() { // navigation pour aller vers la page d'expérience en alternance Navigator.push( context, MaterialPageRoute(builder: (context) => const PageExperience()), // on appel le constructeur de la page d'expérience ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Ma Carte de Visite")), body: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ GestureDetector( onLongPress: _incrementerVues, child: const CircleAvatar( radius: 60, backgroundColor: Colors.indigo, child: Icon(Icons.person, size: 70, color: Colors.white), ), ), const SizedBox(height: 8), Text("Vues de la carte : $_vues", style: TextStyle(color: Colors.grey[600])), const SizedBox(height: 20), const Text("Camille Dupont", style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)), Text("Développeuse Mobile Flutter", style: TextStyle(fontSize: 16, color: Colors.grey[600])), const SizedBox(height: 24), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: const [ LigneContact(icone: Icons.email, texte: "camille.dupont@email.com"), LigneContact(icone: Icons.phone, texte: "+33 6 12 34 56 78"), LigneContact(icone: Icons.location_on, texte: "Paris, France"), ], ), const SizedBox(height: 24), // ← remis ici (il manquait) ElevatedButton.icon( onPressed: _envoyerMessage, icon: const Icon(Icons.send), label: const Text("Me contacter"), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), const SizedBox(height: 12), // votre nouveau bouton de navigation pour aller vers la page d'expérience en alternance OutlinedButton.icon( onPressed: _ouvrirExperience, icon: const Icon(Icons.work_outline), label: const Text("Voir mon expérience en alternance"), ), ], ), ), ), ); } }
import 'package:flutter/material.dart'; // Page pour afficher l'expérience en alternance // Cette page est accessible depuis la page carte de visite via un bouton de navigation. // Elle présente les détails de l'expérience professionnelle, y compris l'entreprise, le poste, la période et les missions réalisées. // Vous pouvez personnaliser le contenu de cette page selon vos besoins. // Ici, nous avons utilisé un widget StatelessWidget car cette page ne nécessite pas de gestion d'état complexe ! class PageExperience extends StatelessWidget { const PageExperience({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Expérience en Alternance")), body: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // en-tête : entreprise + intitulé Row( children: const [ CircleAvatar( radius: 28, backgroundColor: Colors.indigo, child: Icon(Icons.business, color: Colors.white, size: 30), ), SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "TechSolutions Ivry", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), ), SizedBox(height: 4), Text( "Développeuse Full-Stack en alternance (Spring Boot / Flutter)", style: TextStyle(fontSize: 15, color: Colors.indigo), ), ], ), ), ], ), const SizedBox(height: 16), Text("Septembre 2025 – Aujourd'hui", style: TextStyle(color: Colors.grey[600], fontStyle: FontStyle.italic)), const SizedBox(height: 24), // Description des tâches const Text( "Missions réalisées", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), _Tache(texte: "Développement de microservices REST avec Spring Boot " "(Java 17, Spring Data JPA, Spring Security) pour la gestion des données clients."), _Tache(texte: "Création d'une application mobile Flutter consommant ces API " "(gestion d'état avec Provider, appels HTTP avec Dio, stockage local avec Hive)."), _Tache(texte: "Écriture de tests unitaires (JUnit, Mockito) et de tests " "d'intégration pour les composants backend."), _Tache(texte: "Participation aux revues de code et à la mise en place " "d'une pipeline CI/CD (Git, GitLab CI)."), ], ), ), ); } } // class pour afficher une tâche avec une icône de check // Cette classe est utilisée dans la page d'expérience pour lister les missions réalisées. // Elle prend un texte en paramètre et l'affiche à côté d'une icône de check. class _Tache extends StatelessWidget { final String texte; const _Tache({required this.texte}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.only(top: 3), child: Icon(Icons.check_circle, size: 18, color: Colors.indigo), ), const SizedBox(width: 10), Expanded(child: Text(texte, style: const TextStyle(fontSize: 15))), ], ), ); } }
import 'package:flutter/material.dart'; // Widget pour afficher une ligne de contact avec une icône et un texte // Cette classe est utilisée dans la page carte de visite pour afficher les informations de contact (email, téléphone, LinkedIn, etc.). // Elle prend une icône et un texte en paramètre et les affiche dans une ligne avec un espacement entre eux. class LigneContact extends StatelessWidget { final IconData icone; final String texte; const LigneContact({super.key, required this.icone, required this.texte}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icone, color: Colors.indigo, size: 20), const SizedBox(width: 10), Text(texte, style: const TextStyle(fontSize: 15)), ], ), ); } }
import 'package:flutter/material.dart'; void main() { runApp(const MonApplication()); } class MonApplication extends StatelessWidget { const MonApplication({super.key}); /* --------------------------------------------------------- Quelques explications sur le code ci-dessous sur le fromseed() En Flutter, ColorScheme.fromSeed permet de générer automatiquement toute la palette de couleurs de votre application à partir d'une seule couleur de base (la "graine" ou seed). Cette méthode s'appuie sur le système de design "Material 3" de Google. Elle calcule scientifiquement les nuances de couleurs complémentaires (les couleurs principales, secondaires, de fond, de texte, etc.) pour qu'elles s'harmonisent parfaitement entre elles et respectent les standards d'accessibilité et de contraste. ------------------------------------------------------------*/ @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, // désactive la bannière de debug title: 'Catalogue produits', theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.green, ), useMaterial3: true, ), home: PageListeProduits(), // page d'accueil de l'application statelessWidget ); } } // --------------------------------------------------------- // Notre MODÈLE de Produit (nom, prix, catégorie, emoji) // --------------------------------------------------------- class Produit { final String nom; final double prix; final String categorie; // représentation visuelle simple du produit. final String emoji; const Produit( this.nom, this.prix, this.categorie, this.emoji, ); } // --------------------------------------------------------- // Pour la page de "LISTE DES PRODUITS" // --------------------------------------------------------- class PageListeProduits extends StatelessWidget { const PageListeProduits({super.key}); final List<Produit> produits = const [ Produit( "Pomme", 0.50, "Fruits", "🍏", ), Produit( "Banane", 0.30, "Fruits", "🍌", ), Produit( "Jus d'orange", 2.90, "Boissons", "🧃", ), Produit( "Eau minérale", 0.80, "Boissons", "💧", ), Produit( "Baguette", 1.10, "Boulangerie", "🥖", ), Produit( "Croissant", 1.30, "Boulangerie", "🥐", ), Produit( "Kiwi", 0.60, "Fruits", "🥝", ), Produit( "Café", 3.50, "Boissons", "☕", ), Produit( "Pain au chocolat", 1.40, "Boulangerie", "🍫", ), Produit( "Mangue", 1.90, "Fruits", "🥭", ), ]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("Nos produits"), ), body: ListView.builder( itemCount: produits.length, itemBuilder: (context, index) { final produit = produits[index]; return Card( margin: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), child: ListTile( // -------------------------------------------- // Emoji représentant le produit pour éviter de télécharger des images // -------------------------------------------- leading: CircleAvatar( backgroundColor: Colors.green.shade50, child: Text( produit.emoji, style: const TextStyle( fontSize: 28, ), ), ), // Nom du produit title: Text( produit.nom, style: const TextStyle( fontWeight: FontWeight.bold, ), ), // Catégorie subtitle: Text( produit.categorie, ), // Prix trailing: Text( "${produit.prix.toStringAsFixed(2)} €", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ); }, ), ); } }
Et voici en bonus du code avec des images à télécharger (à ne pas faire pour le moment, nous verrons cela plus tard dans la formation).
import 'package:flutter/material.dart'; void main() { runApp(const MonApplication()); } class MonApplication extends StatelessWidget { const MonApplication({super.key}); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Catalogue produits', theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.green, ), useMaterial3: true, ), home: PageListeProduits(), ); } } // --------------------------------------------------------- // MODÈLE // --------------------------------------------------------- class Produit { final String nom; final double prix; final String categorie; // Chemin de l'image locale. final String image; const Produit( this.nom, this.prix, this.categorie, this.image, ); } // --------------------------------------------------------- // PAGE LISTE DES PRODUITS // --------------------------------------------------------- class PageListeProduits extends StatelessWidget { PageListeProduits({super.key}); final List<Produit> produits = const [ Produit( "Pomme", 0.50, "Fruits", "assets/images/produits/pomme.png", ), Produit( "Banane", 0.30, "Fruits", "assets/images/produits/banane.png", ), Produit( "Jus d'orange", 2.90, "Boissons", "assets/images/produits/jus-orange.png", ), Produit( "Eau minérale", 0.80, "Boissons", "assets/images/produits/eau.png", ), Produit( "Baguette", 1.10, "Boulangerie", "assets/images/produits/baguette.png", ), Produit( "Croissant", 1.30, "Boulangerie", "assets/images/produits/croissant.png", ), Produit( "Kiwi", 0.60, "Fruits", "assets/images/produits/kiwi.png", ), Produit( "Café", 3.50, "Boissons", "assets/images/produits/cafe.png", ), Produit( "Pain au chocolat", 1.40, "Boulangerie", "assets/images/produits/pain-chocolat.png", ), Produit( "Mangue", 1.90, "Fruits", "assets/images/produits/mangue.png", ), ]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("Nos produits"), ), body: ListView.builder( itemCount: produits.length, itemBuilder: (context, index) { final produit = produits[index]; return Card( margin: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), child: ListTile( // -------------------------------------------- // Image locale du produit // -------------------------------------------- leading: CircleAvatar( radius: 26, backgroundColor: Colors.green.shade50, child: ClipOval( child: Image.asset( produit.image, width: 46, height: 46, fit: BoxFit.cover, ), ), ), title: Text( produit.nom, style: const TextStyle( fontWeight: FontWeight.bold, ), ), subtitle: Text( produit.categorie, ), trailing: Text( "${produit.prix.toStringAsFixed(2)} €", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ); }, ), ); } }
Cette version necéssite de récupérer des images à mettre dans le répertoire assets/images/produits et de modifier le fichier pubspec.yaml :
mon_application/ │ ├── android/ ├── ios/ ├── lib/ │ └── main.dart │ ├── assets/ │ └── images/ │ └── produits/ │ ├── pomme.png │ ├── banane.png │ ├── jus-orange.png │ ├── eau.png │ ├── baguette.png │ ├── croissant.png │ ├── kiwi.png │ ├── cafe.png │ ├── pain-chocolat.png │ └── mangue.png │ └── pubspec.yaml
Vos images doivent avoir les propriétés suivantes :
Dans pubspec.yaml :
pubspec.yaml
flutter: uses-material-design: true assets: - assets/images/produits/
Suivi de la commande flutter pub get
flutter pub get
Vous chacun des exercices, vous pouvez choisir des couleurs et thèmes différents…
import 'package:flutter/material.dart'; class PageListeProduitsNav extends StatelessWidget { final List<Produit> produits; const PageListeProduitsNav({super.key, required this.produits}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Produits")), body: ListView.builder( itemCount: produits.length, itemBuilder: (context, index) { final produit = produits[index]; return ListTile( title: Text(produit.nom), trailing: Text("${produit.prix.toStringAsFixed(2)} €"), onTap: () { // Navigation avec passage de données au constructeur Navigator.push( context, MaterialPageRoute(builder: (context) => PageDetailProduit(produit: produit)), ); }, ); }, ), ); } } class PageDetailProduit extends StatelessWidget { final Produit produit; const PageDetailProduit({super.key, required this.produit}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(produit.nom)), body: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(produit.nom, style: const TextStyle(fontSize: 26, fontWeight: FontWeight.bold)), const SizedBox(height: 8), Text("Catégorie : ${produit.categorie}", style: const TextStyle(fontSize: 16)), const SizedBox(height: 8), Text("Prix : ${produit.prix.toStringAsFixed(2)} €", style: const TextStyle(fontSize: 20, color: Colors.green)), const Spacer(), Center( child: ElevatedButton.icon( onPressed: () => Navigator.pop(context), // retour à la liste icon: const Icon(Icons.arrow_back), label: const Text("Retour"), ), ), ], ), ), ); } }
import 'package:flutter/material.dart'; void main() => runApp(const MonAppDrawer()); class MonAppDrawer extends StatelessWidget { const MonAppDrawer({super.key}); @override Widget build(BuildContext context) => const MaterialApp(home: PageAvecDrawer()); } class PageAvecDrawer extends StatefulWidget { const PageAvecDrawer({super.key}); @override State<PageAvecDrawer> createState() => _PageAvecDrawerState(); } class _PageAvecDrawerState extends State<PageAvecDrawer> { int _indexActif = 0; final List<String> _titres = ["Accueil", "Favoris", "Profil"]; final List<IconData> _icones = [Icons.home, Icons.favorite, Icons.person]; // nos 3 widgets qui correspondent à nos 3 pages final List<Widget> _pages = const [ Center(child: Text("Bienvenue sur l'accueil", style: TextStyle(fontSize: 20))), Center(child: Text("Vos favoris apparaîtront ici", style: TextStyle(fontSize: 20))), Center(child: Text("Votre profil", style: TextStyle(fontSize: 20))), ]; void _selectionnerPage(int index) { setState(() => _indexActif = index); Navigator.pop(context); // ferme le Drawer après sélection } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(_titres[_indexActif])), drawer: Drawer( child: ListView( padding: EdgeInsets.zero, children: [ const DrawerHeader( decoration: BoxDecoration(color: Colors.indigo), child: Text("Menu principal", style: TextStyle(color: Colors.white, fontSize: 22)), ), for (int i = 0; i < _titres.length; i++) ListTile( leading: Icon(_icones[i]), title: Text(_titres[i]), // Mise en évidence de l'élément actif : selected: _indexActif == i, selectedTileColor: Colors.indigo.withOpacity(0.1), onTap: () => _selectionnerPage(i), ), ], ), ), body: _pages[_indexActif], ); } }
Architecture retenue : un widget racine StatefulWidget (CatalogueApp) conserve la liste des identifiants favoris dans son état, et la transmet aux écrans enfants par constructeur + callback : c’est le mécanisme de remontée d’état (dit « lifting state up »), la base de la gestion d’état avant d’introduire Provider la prochaine journée de formation.
StatefulWidget
CatalogueApp
Provider
import 'package:flutter/material.dart'; void main() => runApp(const CatalogueApp()); // ---------- MODELE ---------- class Livre { final int id; final String titre; final String auteur; final String resume; final String imageUrl; final double note; // sur 5 const Livre({ required this.id, required this.titre, required this.auteur, required this.resume, required this.imageUrl, required this.note, }); } final List<Livre> catalogueLivres = [ Livre( id: 1, titre: "1984", auteur: "George Orwell", resume: "Une dystopie glaçante sur la surveillance de masse et le totalitarisme.", imageUrl: "https://picsum.photos/seed/1984/200/300", note: 4.8, ), Livre( id: 2, titre: "Dune", auteur: "Frank Herbert", resume: "Sur la planète désertique Arrakis, une lutte de pouvoir façonne l'univers.", imageUrl: "https://picsum.photos/seed/dune/200/300", note: 4.7, ), Livre( id: 3, titre: "Fondation", auteur: "Isaac Asimov", resume: "L'effondrement d'un empire galactique prédit par la psychohistoire.", imageUrl: "https://picsum.photos/seed/fondation/200/300", note: 4.5, ), Livre( id: 4, titre: "Sapiens", auteur: "Yuval Noah Harari", resume: "Une brève histoire de l'humanité, de la préhistoire à nos jours.", imageUrl: "https://picsum.photos/seed/sapiens/200/300", note: 4.6, ), Livre( id: 5, titre: "Clean Code", auteur: "Robert C. Martin", resume: "Les bonnes pratiques pour écrire du code lisible et maintenable.", imageUrl: "https://picsum.photos/seed/cleancode/200/300", note: 4.3, ), Livre( id: 6, titre: "Le Petit Prince", auteur: "Antoine de Saint-Exupéry", resume: "Un conte poétique et philosophique sur l'enfance et l'essentiel.", imageUrl: "https://picsum.photos/seed/petitprince/200/300", note: 4.9, ), Livre( id: 7, titre: "Fahrenheit 451", auteur: "Ray Bradbury", resume: "Dans un futur où les livres sont interdits, un pompier brûle... des livres.", imageUrl: "https://picsum.photos/seed/f451/200/300", note: 4.4, ), Livre( id: 8, titre: "L'Étranger", auteur: "Albert Camus", resume: "Meursault, un homme indifférent au monde, confronté à l'absurde.", imageUrl: "https://picsum.photos/seed/etranger/200/300", note: 4.2, ), ]; // ---------- Racine de l'appli ---------- class CatalogueApp extends StatefulWidget { const CatalogueApp({super.key}); @override State<CatalogueApp> createState() => _CatalogueAppState(); } class _CatalogueAppState extends State<CatalogueApp> { final Set<int> _favorisIds = {}; // état central des favoris (remonté ici) void _toggleFavori(int livreId) { setState(() { if (_favorisIds.contains(livreId)) { _favorisIds.remove(livreId); } else { _favorisIds.add(livreId); } }); } @override Widget build(BuildContext context) { return MaterialApp( title: 'Catalogue de livres', theme: ThemeData(primarySwatch: Colors.brown, useMaterial3: true), home: PageAccueilCatalogue( favorisIds: _favorisIds, onToggleFavori: _toggleFavori, ), ); } } // ---------- écran avec le Drawer (Liste et favoris) ---------- class PageAccueilCatalogue extends StatefulWidget { final Set<int> favorisIds; final void Function(int) onToggleFavori; const PageAccueilCatalogue({ super.key, required this.favorisIds, required this.onToggleFavori, }); @override State<PageAccueilCatalogue> createState() => _PageAccueilCatalogueState(); } class _PageAccueilCatalogueState extends State<PageAccueilCatalogue> { bool _afficherFavorisUniquement = false; @override Widget build(BuildContext context) { final livresAffiches = _afficherFavorisUniquement ? catalogueLivres.where((l) => widget.favorisIds.contains(l.id)).toList() : catalogueLivres; return Scaffold( appBar: AppBar(title: Text(_afficherFavorisUniquement ? "Mes favoris" : "Catalogue")), drawer: Drawer( child: ListView( children: [ const DrawerHeader( decoration: BoxDecoration(color: Colors.brown), child: Text("Bibliothèque", style: TextStyle(color: Colors.white, fontSize: 24)), ), ListTile( leading: const Icon(Icons.list), title: const Text("Tout le catalogue"), selected: !_afficherFavorisUniquement, onTap: () { setState(() => _afficherFavorisUniquement = false); Navigator.pop(context); }, ), ListTile( leading: const Icon(Icons.favorite), title: const Text("Mes favoris"), selected: _afficherFavorisUniquement, onTap: () { setState(() => _afficherFavorisUniquement = true); Navigator.pop(context); }, ), ], ), ), body: livresAffiches.isEmpty ? const Center(child: Text("Aucun livre favori pour le moment.")) : ListView.builder( itemCount: livresAffiches.length, itemBuilder: (context, index) { final livre = livresAffiches[index]; final estFavori = widget.favorisIds.contains(livre.id); return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: ListTile( leading: ClipRRect( borderRadius: BorderRadius.circular(4), child: Image.network(livre.imageUrl, width: 50, fit: BoxFit.cover), ), title: Text(livre.titre, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(livre.auteur), trailing: Icon( estFavori ? Icons.favorite : Icons.favorite_border, color: estFavori ? Colors.red : null, ), onTap: () async { // On navigue vers le détail ET on écoute un éventuel changement de favori await Navigator.push( context, MaterialPageRoute( builder: (context) => PageDetailLivre( livre: livre, estFavori: widget.favorisIds.contains(livre.id), onToggleFavori: () => widget.onToggleFavori(livre.id), ), ), ); setState(() {}); // rafraîchit la liste au retour (si favori changé) }, ), ); }, ), ); } } // ---------- écran pour le détail ---------- class PageDetailLivre extends StatefulWidget { final Livre livre; final bool estFavori; final VoidCallback onToggleFavori; // callback vers le parent const PageDetailLivre({ super.key, required this.livre, required this.estFavori, required this.onToggleFavori, }); @override State<PageDetailLivre> createState() => _PageDetailLivreState(); } class _PageDetailLivreState extends State<PageDetailLivre> { late bool _estFavori; @override void initState() { super.initState(); _estFavori = widget.estFavori; } void _basculerFavori() { widget.onToggleFavori(); // notifie le parent (remontée d'état) setState(() => _estFavori = !_estFavori); // rafraîchit l'icône localement } @override Widget build(BuildContext context) { final livre = widget.livre; final etoilesPleines = livre.note.round(); return Scaffold( appBar: AppBar(title: Text(livre.titre)), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network(livre.imageUrl, height: 220), ), const SizedBox(height: 16), Text(livre.titre, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), Text(livre.auteur, style: TextStyle(fontSize: 16, color: Colors.grey[600])), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(5, (i) { return Icon( i < etoilesPleines ? Icons.star : Icons.star_border, color: Colors.amber, ); }), ), const SizedBox(height: 16), Text(livre.resume, textAlign: TextAlign.center, style: const TextStyle(fontSize: 15)), const SizedBox(height: 24), ElevatedButton.icon( onPressed: _basculerFavori, icon: Icon(_estFavori ? Icons.favorite : Icons.favorite_border), label: Text(_estFavori ? "Retirer des favoris" : "Ajouter aux favoris"), style: ElevatedButton.styleFrom( backgroundColor: _estFavori ? Colors.red : null, ), ), ], ), ), ); } }
Points clés du corrigé :
Set<int>
favorisIds
onToggleFavori
setState