CRUD en utilisant Flutter avec Spring Boot
Sommaire
- 1- Objectif
- 2- Présentation
- 3- Partie 1: Configuration du Backend Spring Boot
- 3.1- Étape 1: Entité JPA - Client.java
- 3.2- Étape 2: Repository - ClientRepository.java
- 3.3- Étape 3: Service - ClientService.java
- 3.4- Étape 4: Controller - ClientController.java
- 3.5- Étape 5: Configuration CORS globale
- 4- Partie 2: Configuration Flutter
- 4.1- Étape 1: Modèle Client - client_model.dart
- 4.2- Étape 2: Service API - api_service.dart
- 4.3- Étape 3: Page Principale - home_page.dart
- 4.4- É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
CRUD en utilisant Flutter avec Spring Boot
-
Objectif
- Créer une application complète de gestion de clients avec opérations CRUD (Create, Read, Update, Delete) utilisant Flutter comme frontend et Spring Boot comme backend.
-
Présentation
-
Partie 1: Configuration du Backend Spring Boot
-
Étape 1: Entité JPA – Client.java
-
Étape 2: Repository – ClientRepository.java
-
Étape 3: Service – ClientService.java
-
Étape 4: Controller – ClientController.java
-
Étape 5: Configuration CORS globale
-
Partie 2: Configuration Flutter
-
Étape 1: Modèle Client – client_model.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
package com.example.myapi.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "clients")
public class Client {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String nom;
@Column(nullable = false)
private String prenom;
@Column(nullable = false, unique = true)
private String email;
private String telephone;
// Constructeurs
public Client() {}
public Client(String nom, String prenom, String email, String telephone) {
this.nom = nom;
this.prenom = prenom;
this.email = email;
this.telephone = telephone;
}
// Getters et Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public String getPrenom() { return prenom; }
public void setPrenom(String prenom) { this.prenom = prenom; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getTelephone() { return telephone; }
public void setTelephone(String telephone) { this.telephone = telephone; }
}
package com.example.myapi.repository;
import com.example.myapi.entity.Client;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ClientRepository extends JpaRepository {
List findByNomContainingIgnoreCase(String nom);
boolean existsByEmail(String email);
Client findByEmail(String email);
}
package com.example.myapi.service;
import com.example.myapi.entity.Client;
import com.example.myapi.repository.ClientRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ClientService {
@Autowired
private ClientRepository clientRepository;
public List getAllClients() {
return clientRepository.findAll();
}
public Optional getClientById(Long id) {
return clientRepository.findById(id);
}
public Client createClient(Client client) {
if (clientRepository.existsByEmail(client.getEmail())) {
throw new RuntimeException("Email déjà utilisé");
}
return clientRepository.save(client);
}
public Client updateClient(Long id, Client clientDetails) {
Client client = clientRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Client non trouvé"));
// Vérifier si l'email est déjà utilisé par un autre client
if (!client.getEmail().equals(clientDetails.getEmail()) &&
clientRepository.existsByEmail(clientDetails.getEmail())) {
throw new RuntimeException("Email déjà utilisé");
}
client.setNom(clientDetails.getNom());
client.setPrenom(clientDetails.getPrenom());
client.setEmail(clientDetails.getEmail());
client.setTelephone(clientDetails.getTelephone());
return clientRepository.save(client);
}
public void deleteClient(Long id) {
Client client = clientRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Client non trouvé"));
clientRepository.delete(client);
}
public List searchClients(String nom) {
return clientRepository.findByNomContainingIgnoreCase(nom);
}
}
package com.example.myapi.controller;
import com.example.myapi.entity.Client;
import com.example.myapi.service.ClientService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/clients")
@CrossOrigin(origins = "*")
public class ClientController {
@Autowired
private ClientService clientService;
@GetMapping
public List getAllClients() {
return clientService.getAllClients();
}
@GetMapping("/{id}")
public ResponseEntity getClientById(@PathVariable Long id) {
return clientService.getClientById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public Client createClient(@RequestBody Client client) {
return clientService.createClient(client);
}
@PutMapping("/{id}")
public ResponseEntity updateClient(@PathVariable Long id, @RequestBody Client clientDetails) {
try {
Client updatedClient = clientService.updateClient(id, clientDetails);
return ResponseEntity.ok(updatedClient);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{id}")
public ResponseEntity> deleteClient(@PathVariable Long id) {
try {
clientService.deleteClient(id);
return ResponseEntity.ok().build();
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/search")
public List searchClients(@RequestParam String nom) {
return clientService.searchClients(nom);
}
}
package com.example.myapi.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(false);
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
class Client {
int? id;
String nom;
String prenom;
String email;
String telephone;
Client({
this.id,
required this.nom,
required this.prenom,
required this.email,
required this.telephone,
});
factory Client.fromJson(Map json) {
return Client(
id: json['id'],
nom: json['nom'] ?? '',
prenom: json['prenom'] ?? '',
email: json['email'] ?? '',
telephone: json['telephone'] ?? '',
);
}
Map toJson() {
return {
'id': id,
'nom': nom,
'prenom': prenom,
'email': email,
'telephone': telephone,
};
}
}
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