C’est le chapitre charnière du cours : vous allez relier votre application Flutter à une vraie API Spring Boot ou Php ou autre selon le framework avec lequel vous êtes à l’aise.
http
FutureBuilder
Vous connaissez déjà ce schéma côté back-end, nous ajoutons simplement Flutter comme client :
Ce que Flutter va faire, c’est exactement ce que ferait un fetch() JavaScript ou un RestClient avec toEntity(...) avec Java : envoyer une requête HTTP, recevoir une réponse JSON, la désérialiser en objets Dart.
fetch()
RestClient
toEntity(...)
C’est le piège n°1 des dévéloppeur.euse.s débutants avec Flutter qui ont un backend en local : l’adresse localhost ne signifie pas la même chose selon où tourne l’application !
localhost
localhost:8080
http://10.0.2.2:8080
http://localhost:8080
http://<IP_LOCALE_DE_VOTRE_PC>:8080
http://192.168.1.42:8080
https://mon-api.exemple.com
Trouver l’IP locale de votre PC :
# macOS / Linux ifconfig | grep "inet " # Windows ipconfig
⚠️ Pensez aussi à autoriser le trafic HTTP non chiffré si votre backend n’est pas encore en HTTPS : sur Android, ajoutez android:usesCleartextTraffic="true" dans android/app/src/main/AndroidManifest.xml (uniquement pour le développement !). Sur iOS, configurez NSAppTransportSecurity dans ios/Runner/Info.plist.
android:usesCleartextTraffic="true"
android/app/src/main/AndroidManifest.xml
NSAppTransportSecurity
ios/Runner/Info.plist
Comme votre application Flutter (compilée nativement) n’est pas un navigateur, CORS ne s’applique pas aux appels natifs Android/iOS — uniquement à Flutter Web. Vous pouvez donc généralement ignorer la configuration CORS pour ce cours, sauf si vous testez avec flutter run -d chrome.
flutter run -d chrome
# pubspec.yaml dependencies: http: ^1.2.1
import 'package:http/http.dart' as http; import 'dart:convert'; Future<void> recupererEtudiants() async { final url = Uri.parse('http://10.0.2.2:8080/api/etudiants'); final response = await http.get(url); if (response.statusCode == 200) { final List<dynamic> data = jsonDecode(response.body); print("Nombre d'étudiants reçus : ${data.length}"); } else { throw Exception('Erreur ${response.statusCode} lors de la récupération des étudiants'); } }
Cette syntaxe async/await + gestion du statusCode est l’équivalent Dart d’un appel RestTemplate.getForEntity(...) avec vérification du code retour côté Java.
async
await
statusCode
RestTemplate.getForEntity(...)
Future<void> creerEtudiant(String nom, String email) async { final url = Uri.parse('http://10.0.2.2:8080/api/etudiants'); final response = await http.post( url, headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({'nom': nom, 'email': email}), ); if (response.statusCode != 201) { throw Exception('Erreur lors de la création : ${response.statusCode}'); } }
Future<void> modifierEtudiant(int id, String nom, String email) async { final url = Uri.parse('http://10.0.2.2:8080/api/etudiants/$id'); final response = await http.put( url, headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({'nom': nom, 'email': email}), ); if (response.statusCode != 200) throw Exception('Erreur mise à jour'); } Future<void> supprimerEtudiant(int id) async { final url = Uri.parse('http://10.0.2.2:8080/api/etudiants/$id'); final response = await http.delete(url); if (response.statusCode != 204) throw Exception('Erreur suppression'); }
Tout comme vous créez un DTO Java pour représenter la réponse JSON d’un contrôleur Spring, on crée une classe Dart avec fromJson/toJson :
fromJson
toJson
class Etudiant { final int? id; // nullable : absent lors de la création, on suppose que l'id est autogénéré en bd final String nom; final String email; Etudiant({this.id, required this.nom, required this.email}); // Désérialisation : JSON reçu de l'API -> objet Dart factory Etudiant.fromJson(Map<String, dynamic> json) { return Etudiant( id: json['id'] as int?, nom: json['nom'] as String, email: json['email'] as String, ); } // Sérialisation : objet Dart -> JSON envoyé à l'API Map<String, dynamic> toJson() { return { if (id != null) 'id': id, 'nom': nom, 'email': email, }; } }
💡 Pour aller plus loin : sur de gros projets, on évite d’écrire ce code à la main grâce à la génération de code (json_serializable, ou freezed pour des modèles immuables avec copyWith). Ce cours se concentre sur l’écriture manuelle, plus pédagogique pour comprendre le mécanisme sous-jacent — la logique reste identique.
json_serializable
freezed
copyWith
FutureBuilder est LE widget qui gère élégamment les 3 états d’un appel asynchrone : chargement, succès, erreur — l’équivalent visuel d’un bloc try/catch/finally.
try/catch/finally
class PageListeEtudiants extends StatefulWidget { const PageListeEtudiants({super.key}); @override State<PageListeEtudiants> createState() => _PageListeEtudiantsState(); } class _PageListeEtudiantsState extends State<PageListeEtudiants> { late Future<List<Etudiant>> _futureEtudiants; @override void initState() { super.initState(); _futureEtudiants = _chargerEtudiants(); } Future<List<Etudiant>> _chargerEtudiants() async { final url = Uri.parse('http://10.0.2.2:8080/api/etudiants'); final response = await http.get(url); if (response.statusCode == 200) { final List<dynamic> data = jsonDecode(response.body); return data.map((json) => Etudiant.fromJson(json)).toList(); } throw Exception('Impossible de charger les étudiants'); } Future<void> _rafraichir() async { setState(() { _futureEtudiants = _chargerEtudiants(); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Étudiants")), body: RefreshIndicator( // tirer vers le bas pour rafraîchir onRefresh: _rafraichir, child: FutureBuilder<List<Etudiant>>( future: _futureEtudiants, builder: (context, snapshot) { // État 1 : chargement en cours if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } // État 2 : erreur if (snapshot.hasError) { return Center(child: Text("Erreur : ${snapshot.error}")); } // État 3 : succès final etudiants = snapshot.data!; if (etudiants.isEmpty) { return const Center(child: Text("Aucun étudiant")); } return ListView.builder( itemCount: etudiants.length, itemBuilder: (context, index) => ListTile( title: Text(etudiants[index].nom), subtitle: Text(etudiants[index].email), ), ); }, ), ), ); } }
Sur un vrai projet, on ne met jamais les appels HTTP directement dans les widgets. On isole la logique réseau dans une classe service/repository (exactement comme un @Repository Spring Data), injectée via Provider.
@Repository
// services/etudiant_service.dart class EtudiantService { static const String _baseUrl = 'http://10.0.2.2:8080/api/etudiants'; Future<List<Etudiant>> getTous() async { final response = await http.get(Uri.parse(_baseUrl)); if (response.statusCode != 200) throw Exception('Erreur ${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 création'); return Etudiant.fromJson(jsonDecode(response.body)); } Future<void> 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 mise à jour'); } Future<void> supprimer(int id) async { final response = await http.delete(Uri.parse('$_baseUrl/$id')); if (response.statusCode != 204) throw Exception('Erreur suppression'); } }
// providers/etudiant_provider.dart class EtudiantProvider extends ChangeNotifier { final EtudiantService _service = EtudiantService(); List<Etudiant> _etudiants = []; bool _chargement = false; String? _erreur; List<Etudiant> get etudiants => _etudiants; bool get chargement => _chargement; String? get erreur => _erreur; 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> supprimer(int id) async { await _service.supprimer(id); _etudiants.removeWhere((e) => e.id == id); notifyListeners(); } }
Cette architecture en couches — Widget (UI) → Provider (état) → Service (accès réseau) → Modèle (DTO) — est le pendant exact de votre architecture Spring Boot Controller → Service → Repository → Entity.
Utilisez le mini-backend fourni en Annexe A, ou votre propre API Spring Boot, ou à défaut vous pouvez utiliser le site JSONPlaceholder (API REST publique de test) qui est bien pratique. Ce sont des données de type “fake” pour réaliser des tests.
Affichez dans une simple ListView la liste des utilisateurs renvoyée par GET https://jsonplaceholder.typicode.com/users, avec gestion des 3 états via FutureBuilder.
ListView
GET https://jsonplaceholder.typicode.com/users
En utilisant votre API Spring Boot « Étudiants » (voir Annexe A), implémentez la création (formulaire + POST) et la suppression (bouton + DELETE) d’un étudiant, avec rafraîchissement automatique de la liste.
Coupez volontairement votre serveur Spring Boot (ou mettez une mauvaise URL) et vérifiez que votre application affiche un message d’erreur clair avec un bouton « Réessayer », sans planter comme dans l’illustration de l’exercice 4.2.
👉 Voir les corrigés complets
Cahier des charges :
En vous appuyant sur l’API Spring Boot fournie en Annexe A (GET/POST/PUT/DELETE /api/etudiants), développez une application gestion_etudiants complète :
GET/POST/PUT/DELETE /api/etudiants
gestion_etudiants
models/etudiant.dart
services/etudiant_service.dart
providers/etudiant_provider.dart
screens/...
Provider
Dismissible
AlertDialog
promotion
👉 Voir le corrigé complet du TP
➡️ Chapitre 5 — Authentification, tests, déploiement et projet final
⬅️ Retour au sommaire Flutter