Créer une API Node.js + Prisma + MySQL + Flutter
Créer une API Node.js + Prisma + MySQL + Flutter
-
Objectif
- À la fin de ce cours, l’étudiant sera capable de :
- Créer une base de données MySQL.
- Construire une API REST avec Node.js + Express + Prisma.
- Exécuter des opérations CRUD sur une table client.
- Consommer l’API depuis une application Flutter.
- Afficher une liste, ajouter, modifier et supprimer des clients.
-
ORM
- L’Object-Relational Mapping (Mappage Objet-Relationnel) est une technique de programmation qui permet de convertir des données entre des systèmes de types incompatibles : les objets d’un langage de programmation orienté objet (POO) et les tables d’une base de données relationnelle (SGBDR).
- Le Rôle de l’ORM : Il agit comme un traducteur ou un intermédiaire entre votre code (par exemple, Python, Java, PHP) et votre base de données (MySQL, PostgreSQL, etc.).
- L’ORM (Object-Relational Mapping) est un concept fondamental dans le développement moderne d’applications qui relie deux mondes différents :
- Le monde objet (classes, instances, méthodes)
- Le monde relationnel (tables, lignes, colonnes, SQL)
- Un ORM est un outil qui permet de manipuler une base de données comme si c’était des objets, sans écrire de SQL.
- Exemple sans ORM :
- L’utilisateur doit écrire une requête SQL manuelle.
- Exemple avec un ORM :
- Le code est plus simple, plus propre et plus sécurisé.
-
Prisma
-
Présentation
- Prisma est une boîte à outils de base de données (Database Toolkit) moderne et open-source qui inclut un ORM (Object-Relational Mapper) de nouvelle génération pour les applications Node.js et TypeScript.
- Il se distingue des ORM traditionnels par son approche axée sur la sécurité des types (type-safety) et une meilleure expérience développeur (Developer Experience – DX).
-
Les Composants Clés de Prisma
- Prisma n’est pas un simple ORM ; il est composé de trois outils principaux qui travaillent ensemble :
- Prisma Schema (Schéma Prisma)
- C’est le cœur déclaratif de Prisma.
- C’est un fichier unique où vous définissez votre modèle de données de manière intuitive (représentant les tables et leurs relations), ainsi que les sources de données et les générateurs (comme le Client Prisma).
- Exemple de syntaxe (langage de modélisation de données intuitif) :
- Prisma Client
- C’est un constructeur de requêtes (query builder) auto-généré à partir de votre Schéma Prisma.
- Il fournit une API type-safe pour interagir avec votre base de données dans votre code Node.js ou TypeScript. Les requêtes sont vérifiées au moment de la compilation, ce qui réduit les erreurs d’exécution.
- Exemple d’utilisation dans le code :
const user = await prisma.user.findUnique({ where: { email: 'test@example.com' } }); - Prisma Migrate
- C’est un système de migration pour appliquer et suivre les changements de votre schéma de données dans la base de données réelle.
- Il crée des fichiers SQL incrémentiels basés sur les modifications que vous apportez au Schéma Prisma.
-
Schéma Visuel du Flux : Flutter → Node.js → MySQL
- Voici comment les données circulent depuis l’interface Flutter jusqu’à la base MySQL :
- Schéma Visuel du Flux
- Explication étape par étape
- Flutter UI : l’utilisateur clique → ouvre un formulaire → déclenche une action.
- Controller : reçoit l’action, met à jour l’état et appelle ApiService.
- ApiService : envoie une requête HTTP vers l’API Node.js.
- Node.js API : reçoit la requête et utilise Prisma pour accéder à MySQL.
- Prisma : traduit la requête JavaScript en requête SQL.
- MySQL : stocke / met à jour / supprime / renvoie les données.
-
MySQL – Mise en place de la base de données
- Connectez-vous à MySQL :
mysql -u root -p - Exécutez les commandes SQL :
-
Backend – API Node.js + Prisma
- Dans cette partie, vous allez apprendre à construire un backend moderne avec Node.js, Express et Prisma, qui permettra à Flutter de communiquer avec MySQL.
-
Installation du projet Node.js
- Objectif
- Créer un nouveau projet Node.js qui servira de base pour l’API.
- Étapes
- Créer un dossier de projet :
mkdir api_flutter - Entrer dans le dossier :
cd api_flutter - Initialiser un projet Node.js :
npm init -y - Explication
npm init -ycrée automatiquement un fichier package.json avec les valeurs par défaut.- Ce fichier est essentiel : il permet de gérer les dépendances et la configuration du projet.
-
Installer les dépendances
- Ces commandes installent les outils nécessaires pour créer l’API.
- Commandes :
-
npm install express cors -
npm install prisma --save-dev -
npm install @prisma/client - Explication des outils :
- Pourquoi Prisma ?
- Prisma simplifie énormément l’interaction avec MySQL.
- Au lieu d’écrire des requêtes SQL, on utilise du JavaScript simple et clair.
-
Initialisation de Prisma
- Commande :
npx prisma init - Ce que fait cette commande :
- Elle crée un dossier prisma/ contenant :
- un fichier schema.prisma (la configuration de la base)
- Elle ajoute un fichier .env contenant la variable de connexion.
- Résultat obtenu :
-
Configurer Prisma 7 pour MySQL
provider = "mysql"→ indique qu’on utilise MySQL- L’URL de connexion n’est plus autorisée ici en Prisma 7
- Elle est désormais déplacée dans
prisma.config.ts - root = utilisateur MySQL
- : = obligatoire même si le mot de passe est vide
- flutter_app = nom de la base de données
- 3306 = port MySQL
- Le nom du datasource (
db) dans schema.prisma ne doit PAS être répété ici - Le champ
urlest placé dans prisma.config.ts - C’est une exigence obligatoire avec Prisma 7
-
Migration de la base
- Créer ou mettre à jour la base MySQL à partir du fichier
schema.prisma - Générer automatiquement les tables
- Créer le dossier
prisma/migrations - Analyse le modèle Client
- Génère la table client dans MySQL
- Crée les fichiers de migration versionnés
-
Flutter – Connexion à l’API et CRUD avec MVC
-
Présentation
- Flutter est un framework UI moderne créé par Google permettant de développer des applications mobiles, web et desktop à partir d’un seul code source.
- Dans ce module, vous allez apprendre à connecter une application Flutter à une API Node.js (basée sur Prisma) en utilisant une architecture claire de type MVC (Model – View – Controller).
- L’objectif est de créer une mini-application complète permettant d’effectuer des opérations CRUD (Create, Read, Update, Delete) sur des données distantes.
-
Structure du projet Flutter (Architecture MVC)
- Votre projet Flutter sera structuré comme suit :
- Cette structure permet de séparer clairement :
- Model : La représentation d’un client.
- Controller : La logique métier qui communique avec l’API.
- View : Les écrans affichés avec Flutter.
- Service : Le fichier qui réalise les appels HTTP vers l’API Node.js.
-
Installation des dépendances
- Dans le fichier pubspec.yaml, ajoutez les dépendances nécessaires :
-
Création du Modèle (Model)
- Le modèle Client représente la structure d’un client dans l’application :
-
Service API – Communication avec Node.js
- Le fichier api_service.dart contient toutes les méthodes pour appeler l’API :
- Flutter peut désormais interagir avec Node.js.
-
Contrôleur (Controller)
- Le contrôleur utilise ChangeNotifier pour mettre à jour l’interface en temps réel :
-
Vues (Views) – Interface Flutter
- Les vues sont les écrans affichés à l’utilisateur :
- client_list.dart : Liste des clients
- client_form.dart : Formulaire d’ajout/modification
- client_details.dart : Affichage détaillé
-
Configuration de Node.js pour accepter Flutter
- Dans votre serveur Node.js, vous devez activer le CORS :
- Et créer les routes CRUD avec Prisma :
- Service API (S) –
services/api_service.dart - Contrôleur (C) –
controllers/client_controller.dart - Fichier Principal –
main.dart -
Configuration API Node.js pour Flutter
-
server.js / app.js
-
Instructions d’exécution
-
Lancer l’API Node.js
-
Configurer l’adresse IP dans Flutter
- Dans
api_service.dartremplacer l’IP par : - 10.0.2.2 pour émulateur Android
- localhost pour iOS
- 192.168.x.x pour un vrai téléphone
-
Exécuter Flutter
-
Permissions Android
SELECT * FROM utilisateurs WHERE id = 1;
const user = await prisma.user.findUnique({ where: { id: 1 } });
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}
Flutter UI
↓
Controller (ChangeNotifier)
↓
ApiService (HTTP)
↓
Node.js API (Express)
↓
Prisma ORM
↓
MySQL Database
-- Créer la base de données
CREATE DATABASE flutter_app;
-- Utiliser la base de données
USE flutter_app;
-- Créer la table utilisateurs
CREATE TABLE `utilisateurs` (
`id` int NOT NULL AUTO_INCREMENT,
`email` varchar(180) NOT NULL,
`roles` json NOT NULL,
`password` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UNIQ_EMAIL` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Créer la table client
CREATE TABLE `client` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`family` varchar(100) NOT NULL,
`age` int NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Insérer des données de test
INSERT INTO `utilisateurs` (`email`, `roles`, `password`) VALUES
('admin@example.com', '["ROLE_ADMIN"]', '$2b$10$YourHashedPassword'),
('user@example.com', '["ROLE_USER"]', '$2b$10$YourHashedPassword');
INSERT INTO `client` (`name`, `family`, `age`) VALUES
('John', 'Doe', 30),
('Jane', 'Smith', 25);
| Outil | Rôle |
|---|---|
| express | Framework qui permet de créer une API web facilement. |
| cors | Permet à Flutter d’accéder à l’API (Cross-Origin Resource Sharing). |
| prisma | ORM moderne qui facilite la communication avec la base MySQL. |
| @prisma/client | Permet d’utiliser Prisma dans le code Node.js. |
/api_flutter
prisma/
schema.prisma
.env
package.json
À partir de Prisma 7, la configuration de la connexion MySQL ne se fait plus
dans schema.prisma, mais dans un nouveau fichier :
prisma.config.ts. Cette modification évite les erreurs fréquentes
telles que P1012 ou Cannot read properties of undefined (startsWith).
A. Fichier : prisma/schema.prisma
Configuration à mettre :
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql" // ✔ PAS d'URL ICI dans Prisma 7
}
Explication :
B. Fichier : .env
DATABASE_URL="mysql://root:@localhost:3306/flutter_app"
C. Fichier : prisma.config.ts
Ce fichier est indispensable en Prisma 7.
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"), // ✔ Connexion MySQL ici
},
});
Important :
D. Définir un modèle : Client
model Client {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
family String @db.VarChar(100)
age Int
}
Explication des champs :
| Champ | Type | Description |
|---|---|---|
| id | Int | Identifiant unique du client |
| @id | — | Clé primaire |
| @default(autoincrement()) | — | Auto-incrémentation |
| name | String | Prénom |
| family | String | Nom de famille |
| age | Int | Âge du client |
Commande :
npx prisma migrate dev --name init
But :
Ce que fait Prisma :
Résultat dans MySQL :
client - id - name - family - age
lib/
├── main.dart
├── models/
│ └── client.dart
├── controllers/
│ └── client_controller.dart
├── views/
│ ├── client_list.dart
│ ├── client_form.dart
│ └── client_details.dart
└── services/
└── api_service.dart
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
provider: ^6.0.5
intl: ^0.18.1
fluttertoast: ^8.2.4
shared_preferences: ^2.2.2
class Client {
int? id;
String name;
String family;
int age;
Client({
this.id,
required this.name,
required this.family,
required this.age,
});
factory Client.fromJson(Map json) {
return Client(
id: json['id'],
name: json['name'],
family: json['family'],
age: json['age'],
);
}
Map toJson() {
return {
'id': id,
'name': name,
'family': family,
'age': age,
};
}
}
static const String baseUrl = 'http://192.168.1.100:3000/api';
static Future> getClients() async { ... }
static Future createClient(Client client) async { ... }
static Future updateClient(int id, Client client) async { ... }
static Future deleteClient(int id) async { ... }
class ClientController with ChangeNotifier {
List _clients = [];
bool _isLoading = false;
Future loadClients() async { ... }
Future addClient(Client client) async { ... }
Future updateClient(Client client) async { ... }
Future deleteClient(int id) async { ... }
}
app.use
app.use(cors({
origin: ['http://localhost', 'http://10.0.2.2', 'http://192.168.1.100'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
app.get
app.get('/api/clients', async (req, res) => {
const clients = await prisma.client.findMany();
res.json(clients);
});
api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/client.dart';
class ApiService {
static const String baseUrl = 'http://192.168.1.100:3000/api';
static const String clientsEndpoint = '$baseUrl/clients';
static Future<List<Client>> getClients() async {
try {
final response = await http.get(Uri.parse(clientsEndpoint));
if (response.statusCode == 200) {
List jsonResponse = json.decode(response.body);
return jsonResponse.map((client) => Client.fromJson(client)).toList();
} else {
throw Exception('Failed to load clients: ${response.statusCode}');
}
} catch (e) {
throw Exception('Failed to connect to API: $e');
}
}
static Future<Client> getClient(int id) async {
final response = await http.get(Uri.parse('$clientsEndpoint/$id'));
if (response.statusCode == 200) {
return Client.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to load client');
}
}
static Future<Client> createClient(Client client) async {
final response = await http.post(
Uri.parse(clientsEndpoint),
headers: {'Content-Type': 'application/json'},
body: json.encode(client.toJson()),
);
if (response.statusCode == 201) {
return Client.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to create client');
}
}
static Future<Client> updateClient(int id, Client client) async {
final response = await http.put(
Uri.parse('$clientsEndpoint/$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('Failed to update client');
}
}
static Future<void> deleteClient(int id) async {
final response = await http.delete(Uri.parse('$clientsEndpoint/$id'));
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception('Failed to delete client');
}
}
}
client_controller.dart
import 'package:flutter/material.dart';
import '../models/client.dart';
import '../services/api_service.dart';
class ClientController with ChangeNotifier {
List<Client> _clients = [];
bool _isLoading = false;
String _error = '';
List<Client> get clients => _clients;
bool get isLoading => _isLoading;
String get error => _error;
Future<void> loadClients() async {
_isLoading = true;
_error = '';
notifyListeners();
try {
_clients = await ApiService.getClients();
_error = '';
} catch (e) {
_error = e.toString();
_clients = [];
}
_isLoading = false;
notifyListeners();
}
Future<bool> addClient(Client client) async {
try {
final newClient = await ApiService.createClient(client);
_clients.add(newClient);
notifyListeners();
return true;
} catch (e) {
_error = e.toString();
notifyListeners();
return false;
}
}
Future<bool> updateClient(Client client) async {
try {
final updatedClient = await ApiService.updateClient(client.id!, client);
final index = _clients.indexWhere((c) => c.id == client.id);
if (index != -1) {
_clients[index] = updatedClient;
notifyListeners();
}
return true;
} catch (e) {
_error = e.toString();
notifyListeners();
return false;
}
}
Future<bool> deleteClient(int id) async {
try {
await ApiService.deleteClient(id);
_clients.removeWhere((client) => client.id == id);
notifyListeners();
return true;
} catch (e) {
_error = e.toString();
notifyListeners();
return false;
}
}
}
Vue (V) – views/client_list.dart
client_list.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/client_controller.dart';
import '../models/client.dart';
import 'client_form.dart';
class ClientListView extends StatefulWidget {
const ClientListView({Key? key}) : super(key: key);
@override
State createState() => _ClientListViewState();
}
class _ClientListViewState extends State {
@override
void initState() {
super.initState();
_loadClients();
}
void _loadClients() {
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of(context, listen: false).loadClients();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Gestion des Clients'),
centerTitle: true,
backgroundColor: Colors.blue.shade700,
elevation: 2,
),
body: _buildBody(),
floatingActionButton: _buildFloatingActionButton(),
);
}
Widget _buildBody() {
return Consumer(
builder: (context, controller, child) {
if (controller.isLoading) {
return _buildLoadingState();
}
if (controller.error.isNotEmpty) {
return _buildErrorState(controller);
}
if (controller.clients.isEmpty) {
return _buildEmptyState();
}
return _buildClientList(controller);
},
);
}
Widget _buildLoadingState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
'Chargement des clients...',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
Widget _buildErrorState(ClientController controller) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
'Une erreur est survenue',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
controller.error,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => controller.loadClients(),
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
),
),
],
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.group_outlined,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 20),
Text(
'Aucun client',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
const Text(
'Commencez par ajouter votre premier client',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => _navigateToClientForm(),
icon: const Icon(Icons.person_add),
label: const Text('Ajouter un client'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
),
),
],
),
),
);
}
Widget _buildClientList(ClientController controller) {
return RefreshIndicator(
onRefresh: () async {
await controller.loadClients();
},
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: controller.clients.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final client = controller.clients[index];
return _buildClientCard(context, client);
},
),
);
}
Widget _buildClientCard(BuildContext context, Client client) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _showDetailsDialog(context, client),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
_buildClientAvatar(client),
const SizedBox(width: 16),
_buildClientInfo(client),
const Spacer(),
_buildActionButtons(context, client),
],
),
),
),
);
}
Widget _buildClientAvatar(Client client) {
return CircleAvatar(
backgroundColor: Colors.blue.shade100,
radius: 24,
child: Text(
client.name.isNotEmpty ? client.name[0].toUpperCase() : '?',
style: TextStyle(
color: Colors.blue.shade800,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildClientInfo(Client client) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${client.name} ${client.family}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.cake,
size: 14,
color: Colors.grey,
),
const SizedBox(width: 4),
Text(
'${client.age} ans',
style: const TextStyle(color: Colors.grey),
),
if (client.id != null) ...[
const SizedBox(width: 12),
const Icon(
Icons.badge,
size: 14,
color: Colors.grey,
),
const SizedBox(width: 4),
Text(
'#${client.id}',
style: const TextStyle(color: Colors.grey),
),
],
],
),
],
),
);
}
Widget _buildActionButtons(BuildContext context, Client client) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Icons.edit,
color: Colors.blue.shade600,
),
onPressed: () => _navigateToClientForm(client: client),
tooltip: 'Modifier',
),
IconButton(
icon: Icon(
Icons.delete,
color: Colors.red.shade600,
),
onPressed: () => _showDeleteDialog(context, client),
tooltip: 'Supprimer',
),
],
);
}
FloatingActionButton _buildFloatingActionButton() {
return FloatingActionButton(
onPressed: _navigateToClientForm,
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.add, size: 28),
);
}
void _navigateToClientForm({Client? client}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ClientFormView(client: client),
),
).then((_) {
// Rafraîchir la liste après retour
Provider.of(context, listen: false).loadClients();
});
}
void _showDeleteDialog(BuildContext context, Client client) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Voulez-vous vraiment supprimer ${client.name} ${client.family} ?',
style: const TextStyle(fontSize: 16),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
final success = await Provider.of(
context,
listen: false,
).deleteClient(client.id!);
if (success) {
_showSnackBar(
context,
'Client supprimé avec succès',
Colors.green,
);
} else {
_showSnackBar(
context,
'Échec de la suppression',
Colors.red,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
);
}
void _showDetailsDialog(BuildContext context, Client client) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text(
'Détails du Client',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailItem('ID', client.id?.toString() ?? 'N/A'),
const SizedBox(height: 12),
_buildDetailItem('Prénom', client.name),
const SizedBox(height: 12),
_buildDetailItem('Nom', client.family),
const SizedBox(height: 12),
_buildDetailItem('Âge', '${client.age} ans'),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_navigateToClientForm(client: client);
},
child: const Text('Modifier'),
),
],
),
);
}
Widget _buildDetailItem(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
);
}
void _showSnackBar(BuildContext context, String message, Color color) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: color,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
views/client_form.dart
client_form.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/client_controller.dart';
import '../models/client.dart';
class ClientFormView extends StatefulWidget {
final Client? client;
const ClientFormView({Key? key, this.client}) : super(key: key);
@override
State createState() => _ClientFormViewState();
}
class _ClientFormViewState extends State {
final _formKey = GlobalKey();
final _nameController = TextEditingController();
final _familyController = TextEditingController();
final _ageController = TextEditingController();
bool _isSaving = false;
@override
void initState() {
super.initState();
_initializeForm();
}
void _initializeForm() {
if (widget.client != null) {
_nameController.text = widget.client!.name;
_familyController.text = widget.client!.family;
_ageController.text = widget.client!.age.toString();
}
}
@override
void dispose() {
_nameController.dispose();
_familyController.dispose();
_ageController.dispose();
super.dispose();
}
Future _saveClient() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _isSaving = true);
try {
final client = Client(
id: widget.client?.id,
name: _nameController.text.trim(),
family: _familyController.text.trim(),
age: int.parse(_ageController.text.trim()),
);
final controller = Provider.of(context, listen: false);
final bool success;
if (widget.client == null) {
success = await controller.addClient(client);
} else {
success = await controller.updateClient(client);
}
setState(() => _isSaving = false);
if (success) {
_showSuccessMessage();
Navigator.pop(context);
} else {
_showErrorMessage(controller.error);
}
} catch (e) {
setState(() => _isSaving = false);
_showErrorMessage('Erreur inattendue: $e');
}
}
void _showSuccessMessage() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.client == null
? 'Client ajouté avec succès ✓'
: 'Client modifié avec succès ✓',
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
void _showErrorMessage(String error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $error'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: SnackBarAction(
label: 'Fermer',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.client == null ? 'Nouveau Client' : 'Modifier Client',
),
centerTitle: true,
backgroundColor: Colors.blue.shade700,
elevation: 2,
iconTheme: const IconThemeData(color: Colors.white),
),
body: _buildBody(),
);
}
Widget _buildBody() {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFormHeader(),
const SizedBox(height: 24),
_buildNameField(),
const SizedBox(height: 20),
_buildFamilyField(),
const SizedBox(height: 20),
_buildAgeField(),
const SizedBox(height: 32),
_buildActionButtons(),
],
),
),
),
);
}
Widget _buildFormHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
widget.client == null ? Icons.person_add : Icons.edit,
size: 48,
color: Colors.blue.shade700,
),
const SizedBox(height: 12),
Text(
widget.client == null
? 'Ajouter un nouveau client'
: 'Modifier les informations du client',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 8),
Text(
'Veuillez remplir tous les champs obligatoires',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
),
],
);
}
Widget _buildNameField() {
return TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'Prénom *',
hintText: 'Ex: Jean',
prefixIcon: const Icon(Icons.person, color: Colors.blue),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.blue.shade700, width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
textCapitalization: TextCapitalization.words,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le prénom est requis';
}
if (value.trim().length < 2) {
return 'Le prénom doit contenir au moins 2 caractères';
}
return null;
},
onChanged: (_) {
if (_formKey.currentState?.validate() ?? false) {
setState(() {});
}
},
);
}
Widget _buildFamilyField() {
return TextFormField(
controller: _familyController,
decoration: InputDecoration(
labelText: 'Nom de famille *',
hintText: 'Ex: Dupont',
prefixIcon: const Icon(Icons.family_restroom, color: Colors.blue),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.blue.shade700, width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
textCapitalization: TextCapitalization.words,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le nom de famille est requis';
}
if (value.trim().length < 2) {
return 'Le nom doit contenir au moins 2 caractères';
}
return null;
},
onChanged: (_) {
if (_formKey.currentState?.validate() ?? false) {
setState(() {});
}
},
);
}
Widget _buildAgeField() {
return TextFormField(
controller: _ageController,
decoration: InputDecoration(
labelText: 'Âge *',
hintText: 'Ex: 30',
prefixIcon: const Icon(Icons.cake, color: Colors.blue),
suffixText: 'ans',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.blue.shade700, width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'L\'âge est requis';
}
final age = int.tryParse(value.trim());
if (age == null) {
return 'Veuillez entrer un nombre valide';
}
if (age < 1) {
return 'L\'âge doit être supérieur à 0';
}
if (age > 150) {
return 'Veuillez entrer un âge réaliste (≤ 150)';
}
return null;
},
onChanged: (_) {
if (_formKey.currentState?.validate() ?? false) {
setState(() {});
}
},
);
}
Widget _buildActionButtons() {
final bool isValid = _formKey.currentState?.validate() ?? false;
return Column(
children: [
ElevatedButton(
onPressed: (_isSaving || !isValid) ? null : _saveClient,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 32),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
minimumSize: const Size(double.infinity, 52),
),
child: _isSaving
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.client == null
? Icons.person_add_alt_1
: Icons.save,
size: 20,
),
const SizedBox(width: 12),
Text(
widget.client == null
? 'Ajouter le client'
: 'Enregistrer les modifications',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 16),
OutlinedButton(
onPressed: _isSaving ? null : () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Colors.grey.shade400),
foregroundColor: Colors.grey.shade700,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 32),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
minimumSize: const Size(double.infinity, 52),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.arrow_back, size: 20),
SizedBox(width: 12),
Text(
'Annuler',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
);
}
}
main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'controllers/client_controller.dart';
import 'views/client_list.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ClientController()),
],
child: MaterialApp(
title: 'Gestion Clients API',
debugShowCheckedModeBanner: false,
home: const ClientListView(),
),
);
}
}
const express = require('express');
const cors = require('cors');
const { PrismaClient } = require('@prisma/client');
const app = express();
const prisma = new PrismaClient();
app.use(cors({
origin: ['http://localhost', 'http://10.0.2.2:3000', 'http://192.168.1.100:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type'],
}));
app.use(express.json());
// Routes CRUD pour clients
app.get('/api/clients', async (req, res) => {
try {
const clients = await prisma.client.findMany();
res.json(clients);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
(… puis tout le code complet)
cd api_flutter
npm start
flutter pub get
flutter run
<uses-permission android:name="android.permission.INTERNET" />
<application android:usesCleartextTraffic="true">
