Illustration du corrigé :
import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; class Utilisateur { final int id; final String nom; final String email; Utilisateur({required this.id, required this.nom, required this.email}); factory Utilisateur.fromJson(Map<String, dynamic> json) { return Utilisateur(id: json['id'], nom: json['name'], email: json['email']); } } Future<List<Utilisateur>> chargerUtilisateurs() async { final url = Uri.parse('https://jsonplaceholder.typicode.com/users'); final response = await http.get(url); if (response.statusCode == 200) { final List<dynamic> data = jsonDecode(response.body); return data.map((e) => Utilisateur.fromJson(e)).toList(); } throw Exception('Erreur ${response.statusCode}'); } class PageUtilisateurs extends StatefulWidget { const PageUtilisateurs({super.key}); @override State<PageUtilisateurs> createState() => _PageUtilisateursState(); } class _PageUtilisateursState extends State<PageUtilisateurs> { late Future<List<Utilisateur>> _future; @override void initState() { super.initState(); _future = chargerUtilisateurs(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Utilisateurs (JSONPlaceholder)")), body: FutureBuilder<List<Utilisateur>>( future: _future, builder: (context, snapshot) { // Etat 1 : chargement if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } // Etat 2 : erreur if (snapshot.hasError) { return Center(child: Text("Erreur : ${snapshot.error}")); } // Etat 3 : succès final utilisateurs = snapshot.data!; return ListView.builder( itemCount: utilisateurs.length, itemBuilder: (context, index) => ListTile( leading: CircleAvatar(child: Text("${utilisateurs[index].id}")), title: Text(utilisateurs[index].nom), subtitle: Text(utilisateurs[index].email), ), ); }, ), ); } }
import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; class Etudiant { final int? id; final String nom; final String email; Etudiant({this.id, required this.nom, required this.email}); factory Etudiant.fromJson(Map<String, dynamic> json) => Etudiant(id: json['id'], nom: json['nom'], email: json['email']); Map<String, dynamic> toJson() => {'nom': nom, 'email': email}; } const String baseUrl = 'http://10.0.2.2:8080/api/etudiants'; // adapter selon l'environnement class PageEtudiantsCrud extends StatefulWidget { const PageEtudiantsCrud({super.key}); @override State<PageEtudiantsCrud> createState() => _PageEtudiantsCrudState(); } class _PageEtudiantsCrudState extends State<PageEtudiantsCrud> { List<Etudiant> _etudiants = []; bool _chargement = true; String? _erreur; @override void initState() { super.initState(); _charger(); } Future<void> _charger() async { setState(() { _chargement = true; _erreur = null; }); try { final response = await http.get(Uri.parse(baseUrl)); if (response.statusCode != 200) throw Exception('Code ${response.statusCode}'); final List<dynamic> data = jsonDecode(response.body); setState(() => _etudiants = data.map((e) => Etudiant.fromJson(e)).toList()); } catch (e) { setState(() => _erreur = e.toString()); } finally { setState(() => _chargement = false); } } Future<void> _creerEtudiant(String nom, String email) async { final response = await http.post( Uri.parse(baseUrl), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'nom': nom, 'email': email}), ); if (response.statusCode == 201) { await _charger(); // rafraîchit la liste } } Future<void> _supprimerEtudiant(int id) async { final response = await http.delete(Uri.parse('$baseUrl/$id')); if (response.statusCode == 204) { await _charger(); } } void _afficherFormulaireAjout() { final nomCtrl = TextEditingController(); final emailCtrl = TextEditingController(); showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Nouvel étudiant"), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField(controller: nomCtrl, decoration: const InputDecoration(labelText: "Nom")), TextField(controller: emailCtrl, decoration: const InputDecoration(labelText: "Email")), ], ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("Annuler")), ElevatedButton( onPressed: () { Navigator.pop(context); _creerEtudiant(nomCtrl.text, emailCtrl.text); }, child: const Text("Créer"), ), ], ), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Étudiants (CRUD)")), floatingActionButton: FloatingActionButton( onPressed: _afficherFormulaireAjout, child: const Icon(Icons.add), ), body: _chargement ? const Center(child: CircularProgressIndicator()) : _erreur != null ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text("Erreur : $_erreur"), ElevatedButton(onPressed: _charger, child: const Text("Réessayer")), ], ), ) : RefreshIndicator( onRefresh: _charger, child: ListView.builder( itemCount: _etudiants.length, itemBuilder: (context, index) { final e = _etudiants[index]; return ListTile( title: Text(e.nom), subtitle: Text(e.email), trailing: IconButton( icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _supprimerEtudiant(e.id!), ), ); }, ), ), ); } }
// Extrait clé : gestion du timeout et des erreurs de connexion Future<List<Etudiant>> chargerAvecGestionErreurs() async { try { final response = await http .get(Uri.parse(baseUrl)) .timeout(const Duration(seconds: 5)); // évite une attente infinie if (response.statusCode == 200) { final List<dynamic> data = jsonDecode(response.body); return data.map((e) => Etudiant.fromJson(e)).toList(); } else { throw Exception('Le serveur a répondu avec le code ${response.statusCode}'); } } on http.ClientException { throw Exception('Impossible de contacter le serveur. Vérifiez votre connexion.'); } on FormatException { throw Exception('Réponse du serveur illisible (JSON invalide).'); } catch (e) { throw Exception('Erreur inattendue : $e'); } } // Dans le widget, le bloc catch de FutureBuilder / try-catch du _charger() // affiche déjà ce message avec un bouton "Réessayer" (voir Exercice 4.2 ci-dessus) : // Center(child: Column(children: [Text("Erreur : $_erreur"), ElevatedButton(onPressed: _charger, ...)]))
Points clés :
.timeout(Duration)
ResourceAccessException
HttpClientErrorException
RestTemplate
Exception
Architecture en couches complète. Structure de fichiers :
lib/ ├── main.dart ├── models/etudiant.dart ├── services/etudiant_service.dart ├── providers/etudiant_provider.dart └── screens/ ├── page_liste_etudiants.dart └── page_formulaire_etudiant.dart
models/etudiant.dart
class Etudiant { final int? id; final String nom; final String email; final String promotion; Etudiant({this.id, required this.nom, required this.email, required this.promotion}); factory Etudiant.fromJson(Map<String, dynamic> json) => Etudiant( id: json['id'], nom: json['nom'], email: json['email'], promotion: json['promotion'] ?? '', ); Map<String, dynamic> toJson() => {'nom': nom, 'email': email, 'promotion': promotion}; Etudiant copyWith({String? nom, String? email, String? promotion}) => Etudiant( id: id, nom: nom ?? this.nom, email: email ?? this.email, promotion: promotion ?? this.promotion, ); }
services/etudiant_service.dart
import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/etudiant.dart'; class EtudiantService { static const String _baseUrl = 'http://10.0.2.2:8080/api/etudiants'; // à adapter Future<List<Etudiant>> getTous() async { final response = await http.get(Uri.parse(_baseUrl)).timeout(const Duration(seconds: 5)); if (response.statusCode != 200) throw Exception('Erreur serveur (${response.statusCode})'); final List<dynamic> data = jsonDecode(response.body); return data.map((e) => Etudiant.fromJson(e)).toList(); } Future<Etudiant> creer(Etudiant etudiant) async { final response = await http.post( Uri.parse(_baseUrl), headers: {'Content-Type': 'application/json'}, body: jsonEncode(etudiant.toJson()), ); if (response.statusCode != 201) throw Exception('Erreur lors de la création'); return Etudiant.fromJson(jsonDecode(response.body)); } Future<Etudiant> mettreAJour(Etudiant etudiant) async { final response = await http.put( Uri.parse('$_baseUrl/${etudiant.id}'), headers: {'Content-Type': 'application/json'}, body: jsonEncode(etudiant.toJson()), ); if (response.statusCode != 200) throw Exception('Erreur lors de la mise à jour'); return Etudiant.fromJson(jsonDecode(response.body)); } Future<void> supprimer(int id) async { final response = await http.delete(Uri.parse('$_baseUrl/$id')); if (response.statusCode != 204) throw Exception('Erreur lors de la suppression'); } }
providers/etudiant_provider.dart
import 'package:flutter/foundation.dart'; import '../models/etudiant.dart'; import '../services/etudiant_service.dart'; class EtudiantProvider extends ChangeNotifier { final EtudiantService _service = EtudiantService(); List<Etudiant> _etudiants = []; bool _chargement = false; String? _erreur; String? _filtrePromotion; bool get chargement => _chargement; String? get erreur => _erreur; String? get filtrePromotion => _filtrePromotion; List<Etudiant> get etudiants => _filtrePromotion == null ? _etudiants : _etudiants.where((e) => e.promotion == _filtrePromotion).toList(); List<String> get promotionsDisponibles => _etudiants.map((e) => e.promotion).toSet().toList()..sort(); void filtrerParPromotion(String? promotion) { _filtrePromotion = promotion; notifyListeners(); } Future<void> charger() async { _chargement = true; _erreur = null; notifyListeners(); try { _etudiants = await _service.getTous(); } catch (e) { _erreur = e.toString(); } finally { _chargement = false; notifyListeners(); } } Future<void> ajouter(Etudiant etudiant) async { final nouveau = await _service.creer(etudiant); _etudiants.add(nouveau); notifyListeners(); } Future<void> modifier(Etudiant etudiant) async { final maj = await _service.mettreAJour(etudiant); final index = _etudiants.indexWhere((e) => e.id == maj.id); if (index != -1) _etudiants[index] = maj; notifyListeners(); } Future<void> supprimer(int id) async { await _service.supprimer(id); _etudiants.removeWhere((e) => e.id == id); notifyListeners(); } }
screens/page_liste_etudiants.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/etudiant_provider.dart'; import 'page_formulaire_etudiant.dart'; class PageListeEtudiants extends StatefulWidget { const PageListeEtudiants({super.key}); @override State<PageListeEtudiants> createState() => _PageListeEtudiantsState(); } class _PageListeEtudiantsState extends State<PageListeEtudiants> { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read<EtudiantProvider>().charger(); }); } Future<void> _confirmerSuppression(BuildContext context, int id, String nom) async { final confirme = await showDialog<bool>( context: context, builder: (context) => AlertDialog( title: const Text("Confirmer la suppression"), content: Text("Supprimer $nom ?"), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Annuler")), TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("Supprimer")), ], ), ); if (confirme == true && context.mounted) { context.read<EtudiantProvider>().supprimer(id); } } @override Widget build(BuildContext context) { final provider = context.watch<EtudiantProvider>(); return Scaffold( appBar: AppBar( title: const Text("Gestion des étudiants"), actions: [ // Bonus : filtre par promotion if (provider.promotionsDisponibles.isNotEmpty) PopupMenuButton<String?>( icon: const Icon(Icons.filter_list), onSelected: provider.filtrerParPromotion, itemBuilder: (context) => [ const PopupMenuItem(value: null, child: Text("Toutes les promotions")), ...provider.promotionsDisponibles .map((p) => PopupMenuItem(value: p, child: Text(p))), ], ), ], ), floatingActionButton: FloatingActionButton( onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const PageFormulaireEtudiant()), ), child: const Icon(Icons.add), ), body: _construireCorps(provider), ); } Widget _construireCorps(EtudiantProvider provider) { if (provider.chargement) { return const Center(child: CircularProgressIndicator()); } if (provider.erreur != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text("Erreur : ${provider.erreur}", textAlign: TextAlign.center), const SizedBox(height: 12), ElevatedButton(onPressed: provider.charger, child: const Text("Réessayer")), ], ), ); } if (provider.etudiants.isEmpty) { return const Center(child: Text("Aucun étudiant")); } return RefreshIndicator( onRefresh: provider.charger, child: ListView.builder( itemCount: provider.etudiants.length, itemBuilder: (context, index) { final etudiant = provider.etudiants[index]; return Dismissible( key: ValueKey(etudiant.id), direction: DismissDirection.endToStart, background: Container( color: Colors.red, alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete, color: Colors.white), ), confirmDismiss: (_) async { await _confirmerSuppression(context, etudiant.id!, etudiant.nom); return false; // on gère la suppression via le provider, pas via le swipe direct }, child: ListTile( title: Text(etudiant.nom), subtitle: Text("${etudiant.email} · ${etudiant.promotion}"), onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => PageFormulaireEtudiant(etudiant: etudiant)), ), ), ); }, ), ); } }
screens/page_formulaire_etudiant.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/etudiant.dart'; import '../providers/etudiant_provider.dart'; class PageFormulaireEtudiant extends StatefulWidget { final Etudiant? etudiant; // null = création, non-null = modification const PageFormulaireEtudiant({super.key, this.etudiant}); @override State<PageFormulaireEtudiant> createState() => _PageFormulaireEtudiantState(); } class _PageFormulaireEtudiantState extends State<PageFormulaireEtudiant> { final _formKey = GlobalKey<FormState>(); late TextEditingController _nomCtrl; late TextEditingController _emailCtrl; late TextEditingController _promotionCtrl; bool _enregistrement = false; bool get _estModification => widget.etudiant != null; @override void initState() { super.initState(); _nomCtrl = TextEditingController(text: widget.etudiant?.nom ?? ''); _emailCtrl = TextEditingController(text: widget.etudiant?.email ?? ''); _promotionCtrl = TextEditingController(text: widget.etudiant?.promotion ?? ''); } @override void dispose() { _nomCtrl.dispose(); _emailCtrl.dispose(); _promotionCtrl.dispose(); super.dispose(); } Future<void> _enregistrer() async { if (!_formKey.currentState!.validate()) return; setState(() => _enregistrement = true); final etudiant = Etudiant( id: widget.etudiant?.id, nom: _nomCtrl.text.trim(), email: _emailCtrl.text.trim(), promotion: _promotionCtrl.text.trim(), ); try { final provider = context.read<EtudiantProvider>(); if (_estModification) { await provider.modifier(etudiant); } else { await provider.ajouter(etudiant); } if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Erreur : $e"))); } } finally { if (mounted) setState(() => _enregistrement = false); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(_estModification ? "Modifier l'étudiant" : "Nouvel étudiant")), body: Padding( padding: const EdgeInsets.all(20), child: Form( key: _formKey, child: Column( children: [ TextFormField( controller: _nomCtrl, decoration: const InputDecoration(labelText: "Nom", border: OutlineInputBorder()), validator: (v) => (v == null || v.isEmpty) ? "Le nom est requis" : null, ), const SizedBox(height: 12), TextFormField( controller: _emailCtrl, decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()), validator: (v) => (v == null || !v.contains('@')) ? "Email invalide" : null, ), const SizedBox(height: 12), TextFormField( controller: _promotionCtrl, decoration: const InputDecoration(labelText: "Promotion", border: OutlineInputBorder()), validator: (v) => (v == null || v.isEmpty) ? "La promotion est requise" : null, ), const SizedBox(height: 24), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _enregistrement ? null : _enregistrer, child: _enregistrement ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Text("Enregistrer"), ), ), ], ), ), ), ); } }
main.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'providers/etudiant_provider.dart'; import 'screens/page_liste_etudiants.dart'; void main() { runApp( ChangeNotifierProvider( create: (_) => EtudiantProvider(), child: const GestionEtudiantsApp(), ), ); } class GestionEtudiantsApp extends StatelessWidget { const GestionEtudiantsApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Gestion des Étudiants', theme: ThemeData(primarySwatch: Colors.teal, useMaterial3: true), home: const PageListeEtudiants(), ); } }
Points clés du corrigé complet :
widget.etudiant == null
AlertDialog
DELETE
.where(...)
GET /api/etudiants?promotion=L3