Connecter un Backend Spring Boot à un Frontend Flutter
Sommaire
- 1- Objectif
- 2- Architecture de l'Application
- 3- Partie 1 : Configuration du Backend Spring Boot
- 3.1- Étape 1.1 : Initialisation du Projet
- 3.2- Étape 1.2 : Configuration de la Base de Données
- 3.3- Étape 1.3 : Configuration CORS
- 3.4- Étape 1.4 : Création des Entités et Repository
- 3.5- Étape 1.5 : Implémentation du Controller REST
- 3.6- Étape 1.6 : Configuration de la Sécurité (JWT)
- 3.7- Étape 1.7 : Test de l'API
- 4- Partie 2 : Développement du Frontend Flutter
- 4.1- Étape 2.1 : Initialisation du Projet Flutter
- 4.2- Étape 2.2 : Ajout des Dépendances
- 4.3- Étape 2.3 : Modèle de Données
- 4.4- Étape 2: Service API - api_service.dart
- 4.5- Étape 3: Page Principale - home_page.dart
- 4.6- Étape 4: Formulaire Client - client_form.dart
- 5- Partie 3: Configuration de la Base de Données
- 6- Partie 4: Tests et Déploiement
- 7- Exercices d'Approfondissement
- 7.1.1- Cours Flutter
Connecter un Backend Spring Boot à un Frontend Flutter
-
Objectif
- À la fin de ce guide, vous serez capable de :
- Créer une API REST avec Spring Boot
- Configurer CORS pour autoriser les requêtes Flutter
- Développer une application Flutter consommant l’API
- Gérer l’authentification avec JWT
- Déployer et tester l’application complète
-
Architecture de l’Application
-
Partie 1 : Configuration du Backend Spring Boot
-
Étape 1.1 : Initialisation du Projet
- Utiliser Spring Initializr (start.spring.io)
- Sélectionner les dépendances :
- Spring Web (pour les API REST)
- Spring Data JPA (pour la persistance)
- MySQL Driver (ou PostgreSQL)
- Spring Security (pour l’authentification)
- Lombok (pour réduire le code boilerplate)
-
Étape 1.2 : Configuration de la Base de Données
- Modifier le fichier application.properties :
-
Étape 1.3 : Configuration CORS
- Créer une classe de configuration CORS :
-
Étape 1.4 : Création des Entités et Repository
- Définir l’entité Product avec JPA
- Créer un repository ProductRepository qui étend JpaRepository
-
Étape 1.5 : Implémentation du Controller REST
- Créer ProductController avec les endpoints :
- GET /api/products → Liste tous les produits
- GET /api/products/{id} → Récupère un produit par ID
- POST /api/products → Crée un nouveau produit
- PUT /api/products/{id} → Met à jour un produit
- DELETE /api/products/{id} → Supprime un produit
-
Étape 1.6 : Configuration de la Sécurité (JWT)
- Ajouter les dépendances JWT dans pom.xml
- Créer une classe JwtUtil pour générer et valider les tokens
- Configurer SecurityConfig pour protéger les endpoints
- Créer un endpoint /api/auth/login pour l’authentification
-
Étape 1.7 : Test de l’API
- Tester tous les endpoints avec Postman ou curl
- Vérifier les réponses JSON et les codes HTTP
-
Partie 2 : Développement du Frontend Flutter
-
Étape 2.1 : Initialisation du Projet Flutter
-
Étape 2.2 : Ajout des Dépendances
-
Étape 2.3 : Modèle de Données
- Créer lib/models/product.dart :
-
Étape 2: Service API – api_service.dart
-
Étape 3: Page Principale – home_page.dart
-
Étape 4: Formulaire Client – client_form.dart
-
Partie 3: Configuration de la Base de Données
-
Partie 4: Tests et Déploiement
-
Exercices d’Approfondissement
- Ajouter la pagination dans l’API Spring Boot et Flutter
- Implémenter l’authentification JWT pour sécuriser les endpoints
- Ajouter la validation avancée des emails et numéros de téléphone
- Créer des tests unitaires pour le service Spring Boot
- Ajouter le refresh token pour maintenir la session utilisateur
- Implémenter la recherche avancée avec multiple critères
- Ajouter l’exportation des données en CSV/Excel
- Créer un dashboard avec des statistiques clients
# Configuration Database
spring.datasource.url=jdbc:mysql://localhost:3306/nom_bdd
spring.datasource.username=utilisateur
spring.datasource.password=mot_de_passe
# JPA Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
# Server Port
server.port=8080
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*") // En dev, spécifier l'IP en prod
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(false);
}
};
}
}
- Modifier
pubspec.yaml
:
flutter create flutter_spring_app
cd flutter_spring_app
dependencies:
flutter:
sdk: flutter
http: ^0.13.5
provider: ^6.0.5
shared_preferences: ^2.2.2
flutter_secure_storage: ^8.0.0
class Product {
final int? id;
final String name;
final String description;
final double price;
Product({this.id, required this.name, required this.description, required this.price});
factory Product.fromJson(Map json) {
return Product(
id: json['id'],
name: json['name'],
description: json['description'],
price: json['price'].toDouble(),
);
}
Map toJson() {
return {
'id': id,
'name': name,
'description': description,
'price': price,
};
}
}
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'client_model.dart';
class ApiService {
static const String baseUrl = "http://10.0.2.2:8080/api/clients";
static Future> getClients() async {
final response = await http.get(Uri.parse(baseUrl));
if (response.statusCode == 200) {
List data = json.decode(response.body);
return data.map((json) => Client.fromJson(json)).toList();
} else {
throw Exception('Erreur lors du chargement des clients');
}
}
static Future getClient(int id) async {
final response = await http.get(Uri.parse('$baseUrl/$id'));
if (response.statusCode == 200) {
return Client.fromJson(json.decode(response.body));
} else {
throw Exception('Erreur lors du chargement du client');
}
}
static Future createClient(Client client) async {
final response = await http.post(
Uri.parse(baseUrl),
headers: {'Content-Type': 'application/json'},
body: json.encode(client.toJson()),
);
if (response.statusCode == 200) {
return Client.fromJson(json.decode(response.body));
} else {
throw Exception('Erreur lors de la création du client');
}
}
static Future updateClient(Client client) async {
final response = await http.put(
Uri.parse('$baseUrl/${client.id}'),
headers: {'Content-Type': 'application/json'},
body: json.encode(client.toJson()),
);
if (response.statusCode == 200) {
return Client.fromJson(json.decode(response.body));
} else {
throw Exception('Erreur lors de la mise à jour du client');
}
}
static Future deleteClient(int id) async {
final response = await http.delete(Uri.parse('$baseUrl/$id'));
if (response.statusCode != 200) {
throw Exception('Erreur lors de la suppression du client');
}
}
static Future<List<Client>> searchClients(String query) async {
final response = await http.get(Uri.parse('$baseUrl/search?nom=$query'));
if (response.statusCode == 200) {
List<dynamic> data = json.decode(response.body);
return data.map((json) => Client.fromJson(json)).toList();
} else {
throw Exception('Erreur lors de la recherche');
}
}
}
import 'package:flutter/material.dart';
import 'api_service.dart';
import 'client_model.dart';
import 'client_form.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State {
List _clients = [];
bool _isLoading = true;
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadClients();
}
Future _loadClients() async {
setState(() => _isLoading = true);
try {
final clients = await ApiService.getClients();
setState(() => _clients = clients);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
Future _searchClients() async {
if (_searchQuery.isEmpty) {
_loadClients();
return;
}
setState(() => _isLoading = true);
try {
final clients = await ApiService.searchClients(_searchQuery);
setState(() => _clients = clients);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
Future _deleteClient(int id) async {
try {
await ApiService.deleteClient(id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Client supprimé avec succès')),
);
_loadClients();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
void _showDeleteDialog(Client client) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Êtes-vous sûr de vouloir supprimer ${client.prenom} ${client.nom} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_deleteClient(client.id!);
},
child: const Text('Supprimer', style: TextStyle(color: Colors.red)),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Gestion des Clients'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadClients,
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: InputDecoration(
labelText: 'Rechercher par nom',
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: _searchClients,
),
),
onChanged: (value) => _searchQuery = value,
onSubmitted: (_) => _searchClients(),
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _clients.isEmpty
? const Center(child: Text('Aucun client trouvé'))
: ListView.builder(
itemCount: _clients.length,
itemBuilder: (context, index) {
final client = _clients[index];
return ListTile(
title: Text('${client.prenom} ${client.nom}'),
subtitle: Text(client.email),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
onPressed: () => _navigateToForm(client),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _showDeleteDialog(client),
),
],
),
onTap: () => _navigateToForm(client),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _navigateToForm(null),
child: const Icon(Icons.add),
),
);
}
void _navigateToForm(Client? client) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ClientFormPage(client: client),
),
);
if (result == true) {
_loadClients();
}
}
}
import 'package:flutter/material.dart';
import 'api_service.dart';
import 'client_model.dart';
class ClientFormPage extends StatefulWidget {
final Client? client;
const ClientFormPage({super.key, this.client});
@override
_ClientFormPageState createState() => _ClientFormPageState();
}
class _ClientFormPageState extends State {
final _formKey = GlobalKey();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _telephoneController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
if (widget.client != null) {
_nomController.text = widget.client!.nom;
_prenomController.text = widget.client!.prenom;
_emailController.text = widget.client!.email;
_telephoneController.text = widget.client!.telephone;
}
}
Future _saveClient() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final client = Client(
id: widget.client?.id,
nom: _nomController.text,
prenom: _prenomController.text,
email: _emailController.text,
telephone: _telephoneController.text,
);
if (widget.client == null) {
await ApiService.createClient(client);
} else {
await ApiService.updateClient(client);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Client ${widget.client == null ? 'créé' : 'modifié'} avec succès')),
);
Navigator.pop(context, true);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.client == null ? 'Nouveau Client' : 'Modifier Client'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nomController,
decoration: const InputDecoration(labelText: 'Nom'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un nom';
}
return null;
},
),
TextFormField(
controller: _prenomController,
decoration: const InputDecoration(labelText: 'Prénom'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un prénom';
}
return null;
},
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un email';
}
if (!value.contains('@')) {
return 'Email invalide';
}
return null;
},
),
TextFormField(
controller: _telephoneController,
decoration: const InputDecoration(labelText: 'Téléphone'),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 20),
_isLoading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: _saveClient,
child: Text(widget.client == null ? 'Créer' : 'Modifier'),
),
],
),
),
),
);
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_telephoneController.dispose();
super.dispose();
}
}
CREATE TABLE clients (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(255) NOT NULL,
prenom VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
telephone VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
# Récupérer tous les clients
curl http://localhost:8080/api/clients
# Créer un client
curl -X POST http://localhost:8080/api/clients \
-H "Content-Type: application/json" \
-d '{"nom":"Dupont", "prenom":"Jean", "email":"jean.dupont@email.com", "telephone":"0123456789"}'
# Modifier un client
curl -X PUT http://localhost:8080/api/clients/1 \
-H "Content-Type: application/json" \
-d '{"nom":"Dupont", "prenom":"Jean", "email":"jean.dupont@email.com", "telephone":"0987654321"}'
# Supprimer un client
curl -X DELETE http://localhost:8080/api/clients/1