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
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; } }Étape 2: Repository - ClientRepository.java
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); } Étape 3: Service - ClientService.java
Étape 4: Controller - ClientController.java
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 ListgetAllClients() { 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); } } Étape 5: Configuration CORS globale
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; } }
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 ListgetAllClients() { 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); } }
Partie 2: Configuration Flutter
Étape 1: Modèle Client - client_model.dart
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(Mapjson) { 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, }; } } Étape 2: Service API - api_service.dart
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'); } } } Étape 3: Page Principale - home_page.dart
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(); } } } Étape 4: Formulaire Client - client_form.dart
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(); } }
Partie 3: Configuration de la Base de Données
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 );Partie 4: Tests et Déploiement
# 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/1Exercices 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