Aller au contenu
⬅️ Retour au sommaire Flutter ⬅️ Chapitre précédent ➡️ Chapitre suivant

Chapitre 4 - Journée 4 : Consommer une API REST Spring Boot depuis Flutter

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.

Sommaire du chapitre


1. Rappel d’architecture client-serveur

Vous connaissez déjà ce schéma côté back-end, nous ajoutons simplement Flutter comme client :

illustration flutter api spring boot

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.


2. Adresses réseau selon l’environnement de test

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 !

Environnement de test URL à utiliser pour joindre localhost:8080 de votre PC
Émulateur Android http://10.0.2.2:8080 (adresse spéciale qui redirige vers l’hôte)
Simulateur iOS http://localhost:8080 (fonctionne car le simulateur partage le réseau du Mac)
Téléphone physique (Android ou iOS) http://<IP_LOCALE_DE_VOTRE_PC>:8080 (ex : http://192.168.1.42:8080) - le téléphone doit être sur le même réseau Wifi que votre ordinateur
Backend déployé (cloud) https://mon-api.exemple.com ou l’url de votre API de serveur perso

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.

Rappel CORS côté Spring Boot

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.


3. Le package http

# pubspec.yaml
dependencies:
  http: ^1.2.1

Requête GET simple

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.

Requête POST (création)

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}');
  }
}

PUT (mise à jour) et DELETE

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');
}

4. Modéliser les données JSON en Dart

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 :

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.


5. CRUD complet avec FutureBuilder

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.

Illustration succincte avec FutureBuilder

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),
              ),
            );
          },
        ),
      ),
    );
  }
}

6. Repository pattern + Provider

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.

// 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.


7. Exercices du jour

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.

Exercice 4.1 — Premier appel GET

Illustration flutter avec api et retour JSON

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.

Exercice 4.2 — CRUD complet

Illustration flutter jour 4 CRUD

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.

Exercice 4.3 — Gestion des erreurs réseau

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


8. TP de synthèse — Gestion des étudiants

illustration du fonctionnement de l'application Flutter avec le Back-end Spring

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 :

  1. Architecture en couches : models/etudiant.dart, services/etudiant_service.dart, providers/etudiant_provider.dart, screens/... ;
  2. Écran Liste : affichage de tous les étudiants (nom + email) avec FutureBuilder ou Provider, tirer-vers-le-bas pour rafraîchir, indicateur de chargement, gestion d’erreur avec bouton « Réessayer »
  3. Écran Formulaire (unique, réutilisé pour créer ET modifier) : deux champs (nom, email) avec validation, bouton « Enregistrer » qui fait un POST (création) ou un PUT (modification) selon le contexte
  4. Suppression : glissement (Dismissible) ou bouton sur chaque ligne, avec confirmation (AlertDialog) avant l’appel DELETE
  5. Bonus : ajoutez un champ promotion (ex : “L3 Info”) à l’étudiant, et un filtre par promotion sur l’écran liste.

illustration du fonctionnement de l'application Flutter avec le Back-end Spring

👉 Voir le corrigé complet du TP


➡️ Suite du cours

➡️ Chapitre 5 — Authentification, tests, déploiement et projet final

⬅️ Retour au sommaire Flutter