Travailler avec Cloud Firestore dans Flutter
Sommaire
- 1- Objectif
- 2- Introduction à Cloud Firestore
- 3- Caractéristiques Principales
- 4- Architecture MVC en Flutter + Firestore exemple collection livre)
- 4.1- MODÈLE (models/livre.dart)
- 4.2- CONTROLLER (controllers/livre_controller.dart)
- 4.3- VUE (views/ajouter_livre.dart)
- 5- Projet à réaliser
- 6- Configuration du projet Flutter avec Firestore
- 6.1- Fichier main.dart
- 6.2- Modifier les règles Firestore
- 7- Effectuer des opérations
CRUDdans Firestore - 7.1- Règles et bonnes pratiques pour créer un document dans Firestore :
- 7.1.1- Utiliser des modèles (classes) pour représenter les données
- 7.1.2- Utiliser
add()oudoc().set()selon le besoin - 7.2- Lire un document dans Firestore:
- 7.2.1- Lire tous les documents d’une collection
- 7.2.2- Lire un document spécifique via son ID
- 7.2.3- Récapitulatif visuel
- 7.3- Méthodes de Mise à Jour Firestore:
- 7.3.1- MISE À JOUR PARTIELLE - update()
- 7.3.2- REMPLACEMENT COMPLET
-set()sur document existant - 7.3.3- MISE À JOUR AVEC FUSION -
set(merge: true) - 7.4- Supprimer un document :
- 8- 🔍 Rechercher des documents dans Firestore
- 8.1- Principe général de la recherche dans Firestore
- 8.2- Rechercher selon la valeur exacte d'un champ (Rechercher des livres par auteur)
- 8.2.1- Définition
- 8.2.2- 🔹 Lecture ponctuelle (Future)
- 8.2.3- 🔹 Recherche dynamique en temps réel (Stream)
- 8.3- Recherche avec plusieurs critères
- 8.3.1- Définition
- 8.3.2- 🔹 Exemple : auteur + année minimale
- 8.4- Recherche par mot-clé (titre ou recherche partielle)
- 8.4.1- Définition
- 8.4.2- 🔹 Exemple de structure de document
- 8.4.3- 🔹 Exemple de fonction Dart
- 8.5- Récapitulatif visuel des recherches
- 8.6- Activité
- 9- 🔒 Sécurité et règles Firestore
- 10- Code de l'application
- 10.1.1- Cours Flutter
Ajouter Firebase à votre application Flutter
-
Objectif
- Comprendre ce qu’est Cloud Firestore
- Découvrir les caractéristiques principales et la structure des données
- Configurer Firebase dans un projet Flutter et effectuer les opérations CRUD
-
Introduction à Cloud Firestore
- Qu’est-ce que Cloud Firestore?
Cloud Firestoreest une base de données NoSQL cloud, temps réel et scalable développée par Google.Cloud Firestoreest une base de données NoSQL flexible, évolutive et durable pour les applications mobiles, Web et serveur Google Cloud Platform. Il maintient vos données synchronisées sur tous les clients en temps réel et continue de fonctionner lorsque votre application est hors ligne, offrant ainsi une expérience utilisateur cohérente, réactive et fiable.-
Caractéristiques Principales
- Fonctionnalité Description
- Temps Réel Synchronisation automatique des données
- Offline Fonctionne sans connexion internet
- Scalable Gère automatiquement la charge
- NoSQL Structure flexible en documents/collections
- Sécurisé Règles de sécurité granulaires
-
Architecture MVC en Flutter + Firestore exemple collection livre)
- Objectif pédagogique: Comprendre comment circule l’information entre la Vue, le Contrôleur, le Modèle, et Firestore quand on ajoute un livre.
- Schéma simplifié du flux
- La logique métier correspond à ce que fait ton application, et pourquoi elle le fait, par opposition à la logique technique (affichage, gestion de la base de données, etc.).
- Exemple concret :
- Imaginons une application de banque
- Règle métier :
- « Un compte ne peut pas être à découvert de plus de 1000 DT. »
- Dans MVC :
- Model → contient la méthode retirer(argent) qui vérifie cette règle.
- Controller → reçoit la requête de l’utilisateur (« je veux retirer 200 € »), appelle la méthode retirer() du modèle et choisit la vue à afficher selon le résultat.
- View → affiche le message “Retrait réussi” ou “Découvert maximal atteint”.
- En résumé :
- La logique métier = les règles, calculs et processus propres au domaine fonctionnel de ton application.
- Elle se trouve dans le Modèle, pas dans le Contrôleur ni dans la Vue.
-
MODÈLE (models/livre.dart)
- Rôle : Définir la structure des données.
- Comprendre que le modèle est la véritable représentation du livre.
- À retenir :
- On crée une classe Livre pour garder un code propre et clair.
.toMap()permet de transformer l’objet en format compréhensible par Firestore.-
CONTROLLER (controllers/livre_controller.dart)
- Rôle : Gérer la logique métier.
- C’est le « chef d’orchestre » entre la vue et la base de données.
- À retenir :
- Le contrôleur prépare la donnée avant l’envoi.
- On y trouve souvent try/catch pour bien gérer les erreurs.
- La vue ne parle pas directement à Firestore → elle passe par le contrôleur.
-
VUE (views/ajouter_livre.dart)
- Rôle : Afficher l’interface et récupérer les données de l’utilisateur.
- À retenir :
- La vue affiche uniquement l’interface → pas de logique Firestore ici !
- Elle utilise des champs TextField pour capturer les données.
- Au clic, elle crée un Livre puis appelle une méthode du controller.
-
Projet à réaliser
- Créer une application mobile permettant de gérer une collection de livres avec :
-
Configuration du projet Flutter avec Firestore
-
Fichier main.dart
-
Modifier les règles Firestore
- Allez dans la Console Firebase → Firestore Database → Règles
- REMPLACEZ les règles actuelles par :
-
Effectuer des opérations
CRUDdans Firestore - Firestore vous permet d’effectuer des opérations de création, de lecture, de mise à jour et de suppression
(CRUD) sur vos données. Voyons comment procéder dans Flutter. -
Règles et bonnes pratiques pour créer un document dans Firestore :
-
Utiliser des modèles (classes) pour représenter les données
- Toujours créer une classe Livre (ou autre) pour représenter un document :
- Avantage : cohérence, lisibilité du code, moins d’erreurs lors du stockage.
-
Utiliser
add()oudoc().set()selon le besoin - Dans Firestore, vous pouvez créer un document dans une collection de deux manières :
- Avec
.add()➜ Id auto-généré par Firestore : - Avec
.doc(id).set()➜ Id personnalisé : - Vous choisissez vous-même l’ID du document.
- Utile quand vous devez garantir l’unicité ou utiliser un ID déjà connu.
- Donne un meilleur contrôle sur la structure de votre base de données.
- Toujours gérer les exceptions
- Exemple :
-
Lire un document dans Firestore:
- Firestore propose 2 niveaux de lecture :
- Document spécifique (via son ID)
- Tous les documents d’une collection
- Et 2 modes de récupération :
- En continu (temps réel) avec snapshots() ➜ Retourne un Stream
- Une seule fois (ponctuel) avec get() ➜ Retourne un Future
-
Lire tous les documents d’une collection
- En temps réel (Stream)
- Mise à jour automatique des données dès qu’un changement a lieu.
- Utilisation typique :
- Afficher une liste de livres dans un StreamBuilder.
- Utile pour un flux dynamique d’informations (ex : messages chat, liste mise à
jour en direct). - Lecture ponctuelle (Future)
- Lecture «
one shot« , uniquement au moment où le code est exécuté. - Utilisation typique :
- Charger une liste statique, non mise à jour automatiquement.
- Exemple : Exporter une liste d’objets dans un fichier Excel.
-
Lire un document spécifique via son ID
- En temps réel (Stream)
- Pour suivre les changements en direct sur un seul document.
- Utilisation typique :
- Suivre l’état d’un document en particulier (ex : stock en temps réel, statut de
commande). - Lecture ponctuelle (Future)
- Lecture ciblée, destinée à une action ponctuelle ou une navigation.
- C’est idéal pour récupérer les données d’un seul document lors d’une navigation ou
d’une action spécifique, comme ouvrir une page de détails pour un livre. - Il utilise
await ... .get()→ cela indique une lecture une seule fois,
au moment précis où tu appelles la fonction. - Utilisation typique :
- Récupérer les détails d’un élément lors d’une navigation vers une nouvelle page.
-
Récapitulatif visuel
Utilisateur ➜ View (Formulaire) ➜ Controller ➜ Model ➜ Firestore
⬆︎ ⬆︎ ⬆︎
Affichage Logique MÉTIER Données stockées
Logique métier
class Livre {
String? id;
String titre;
String auteur;
int annee;
Livre({
this.id,
required this.titre,
required this.auteur,
required this.annee,
});
// Convertir Livre en Map pour Firestore
Map toMap() {
return {
'titre': titre,
'auteur': auteur,
'annee': annee,
};
}
// Créer Livre depuis Firestore document
factory Livre.fromFirestore(Map data, String id) {
return Livre(
id: id,
titre: data['titre'] ?? '',
auteur: data['auteur'] ?? '',
annee: data['annee'] ?? 0,
);
}
}
class LivreController {
// Instance principale de Firestore (point d’accès à la base de données)
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
// Référence à la collection "livres" dans Firestore
// Cela évite de répéter _firestore.collection('livres') partout
CollectionReference get _livresRef => _firestore.collection('livres');
// ======================
// 🔹 CREATE - Ajouter un livre
// ======================
Future<void> ajouterLivre(Livre livre) async {
try {
// Ajoute un nouveau document dans la collection "livres"
await _livresRef.add(livre.toMap());
} catch (e) {
throw 'Erreur lors de l\'ajout: $e';
}
}
// ======================
// 🔹 READ - Récupérer tous les livres (en temps réel)
// ======================
Stream<List<Livre>> getLivresStream() {
// Écoute en continu la collection "livres" (avec snapshots)
// Chaque changement (ajout, modif, suppression) est automatiquement émis dans le Stream
return _livresRef.snapshots().map((snapshot) {
// Convertit chaque document Firestore en objet Livre
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
});
}
// ======================
// 🔹 READ - Récupérer un livre par son ID
// ======================
Future<Livre?> getLivreById(String id) async {
try {
// Récupère un document spécifique selon son ID
final doc = await _livresRef.doc(id).get();
// Si le document existe, on le transforme en objet Livre
if (doc.exists) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}
// Sinon, on retourne null
return null;
} catch (e) {
throw 'Erreur lors de la récupération: $e';
}
}
// ======================
// 🔹 UPDATE - Modifier un livre existant
// ======================
Future<void> modifierLivre(String id, Livre livre) async {
try {
// Met à jour le document correspondant à l’ID avec les nouvelles données
await _livresRef.doc(id).update(livre.toMap());
} catch (e) {
throw 'Erreur lors de la modification: $e';
}
}
// ======================
// 🔹 DELETE - Supprimer un livre
// ======================
Future<void> supprimerLivre(String id) async {
try {
// Supprime le document correspondant à l’ID fourni
await _livresRef.doc(id).delete();
} catch (e) {
throw 'Erreur lors de la suppression: $e';
}
}
// ======================
// 🔹 SEARCH - Rechercher des livres par titre
// ======================
Stream<List<Livre>> rechercherLivres(String query) {
// Effectue une recherche "commence par" sur le champ 'titre'
// grâce à isGreaterThanOrEqualTo et isLessThan
return _livresRef
.where('titre', isGreaterThanOrEqualTo: query)
.where('titre', isLessThan: query + 'z')
.snapshots()
.map((snapshot) {
// Transforme les résultats Firestore en liste de Livres
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
});
}
}
import 'package:flutter/material.dart';
import '../models/livre.dart';
import '../controllers/livre_controller.dart';
class AjouterLivreView extends StatelessWidget {
final _titreController = TextEditingController();
final _auteurController = TextEditingController();
final _anneeController = TextEditingController();
final LivreController livreController = LivreController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Ajouter un Livre")),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(controller: _titreController, decoration: InputDecoration(labelText: "Titre")),
TextField(controller: _auteurController, decoration: InputDecoration(labelText: "Auteur")),
TextField(controller: _anneeController, decoration: InputDecoration(labelText: "Année"), keyboardType: TextInputType.number),
SizedBox(height: 20),
ElevatedButton(
onPressed: () {
Livre livre = Livre(
titre: _titreController.text,
auteur: _auteurController.text,
annee: int.parse(_anneeController.text),
);
livreController.ajouterLivre(livre);
},
child: Text("Ajouter"),
),
],
),
),
);
}
}
-
✅ Ajouter de nouveaux livres
📖 Consulter la liste des livres
✏️ Modifier les informations d’un livre
🗑️ Supprimer des livres
🔍 Rechercher des livres
Architecture MVC imposée
lib/
├── main.dart # Point d'entrée de l'application
├── firebase_options.dart # Configuration Firebase
├── models/
│ └── livre_model.dart # Modèle de données
├── controllers/
│ └── livre_controller.dart # Logique métier & Firestore
└── views/
├── liste_livres_view.dart # Écran principal
└── ajouter_livre_view.dart # Formulaire d'ajout
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialisation Firebase avec la configuration spécifique
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, // ← Utilise notre config
);
runApp(MyApp());
}
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// RÈGLES POUR DÉVELOPPEMENT (À CHANGER EN PRODUCTION)
match /livres/{document} {
allow read, write: if true; // Autorise tout le monde
}
// OU pour tester avec authentification :
// match /livres/{document} {
// allow read, write: if request.auth != null;
// }
}
}
class Livre {
final String titre;
final String auteur;
final int annee;
Livre({required this.titre, required this.auteur, required this.annee});
Map toMap() {
return {
'titre': titre,
'auteur': auteur,
'annee': annee,
};
}
}
await FirebaseFirestore.instance
.collection('livres')
.add(livre.toMap());
await FirebaseFirestore.instance
.collection('livres')
.doc(livreId)
.set(livre.toMap());
try {
await _livresRef.add(livre.toMap());
} catch (e) {
print("Erreur lors de l'ajout: $e");
}
Important : ne jamais laisser une erreur Firestore passer en silence.
Stream, doc.id);
}).toList();
});
}
Future<List<Livre>> getTousLesLivres() async {
try {
final snapshot = await _livresRef.get();
return snapshot.docs
.map((doc) => Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id))
.toList();
} catch (e) {
throw 'Erreur lors de la récupération : $e';
}
}
Stream<Livre?> getLivreStreamById(String id) {
return _livresRef.doc(id).snapshots().map((doc) {
if (doc.exists) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
});
}
Future<Livre?> getLivreById(String id) async {
try {
final doc = await _livresRef.doc(id).get();
if (doc.exists) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
throw 'Erreur lors de la récupération: $e';
}
}
| Lecture | Cible | Mode | Retourne | Exemples |
|---|---|---|---|---|
.collection().snapshots() |
Tous les documents | Temps réel | Stream<List> | Liste des livres qui se met à jour automatiquement |
.collection().get() |
Tous les documents | Ponctuel | Future<List> | Exporter tous les livres une seule fois |
.doc(id).snapshots() |
Document spécifique | Temps réel | Stream | Observer l’état d’un seul livre (ex : stock, statut) |
.doc(id).get() |
Document spécifique | Ponctuel | Future | Lire un livre quand on en a besoin (page détail) |
Méthodes de Mise à Jour Firestore:
-
MISE À JOUR PARTIELLE – update()
- Concept : Modifier seulement certains champs d’un document existant
- MISE À JOUR D’UN SEUL CHAMP
- MISE À JOUR DE PLUSIEURS CHAMPS
-
REMPLACEMENT COMPLET
-set()sur document existant - Concept : Remplacer TOUT le document existant
-
MISE À JOUR AVEC FUSION –
set(merge: true) - Concept : Mettre à jour certains champs sans supprimer les autres
- Exemple—>AVANT/APRÈS :
await FirebaseFirestore.instance
.collection('livres')
.doc('abc123') // Document existant
.update({
'titre': 'Nouveau Titre' // Seul ce champ sera modifié
});
await FirebaseFirestore.instance
.collection('livres')
.doc('abc123')
.update({
'titre': 'Titre Modifié',
'auteur': 'Nouvel Auteur',
'annee': 2024
});
await FirebaseFirestore.instance
.collection('livres')
.doc('abc123') // Document doit EXISTER
.set({
'titre': 'Nouveau Titre',
'auteur': 'Nouvel Auteur',
'annee': 2024
// Tous les anciens champs sont remplacés
});
await FirebaseFirestore.instance
.collection('livres')
.doc('abc123')
.set({
'titre': 'Titre Modifié',
'auteur': 'Auteur Modifié'
}, SetOptions(merge: true)); // ← Les autres champs sont conservés
{
"titre": "Ancien Titre",
"auteur": "Auteur Original",
"annee": 2000,
"genre": "Roman"
}
// APRÈS set() avec merge: true
{
"titre": "Titre Modifié", // ← Modifié
"auteur": "Auteur Modifié", // ← Modifié
"annee": 2000, // ← Conservé !
"genre": "Roman" // ← Conservé !
}
Supprimer un document :
- Pour supprimer un document, vous pouvez utiliser la méthode ‘delete’ sur une référence de
document. Par exemple :
Firestore.instance.collection('collection').document('document').delete();
🔍 Rechercher des documents dans Firestore
- Firestore permet d’effectuer des recherches filtrées sur les collections grâce aux requêtes (
Query). - Ces requêtes servent à extraire uniquement les documents correspondant à certains critères (par exemple : auteur, année, mot-clé…).
-
Principe général de la recherche dans Firestore
- Firestore ne fonctionne pas comme une base SQL :
- Il n’y a pas de
SELECT * FROM ... WHERE ... LIKE. - À la place, on utilise des requêtes structurées avec la méthode
.where(). - Les requêtes Firestore :
- Filtrent les documents selon des champs spécifiques,
- Peuvent être ponctuelles (
get()) ou en temps réel (snapshots()), - Nécessitent parfois des index pour plusieurs conditions.
-
Rechercher selon la valeur exacte d’un champ (Rechercher des livres par auteur)
-
Définition
-
Recherche avec plusieurs critères
-
Définition
-
Recherche par mot-clé (titre ou recherche partielle)
-
Définition
-
Récapitulatif visuel des recherches
-
Activité
- Ajouter une zone de recherche dans ton interface pour filtrer les livres en temps réel selon le titre saisi.
- Dans une petite application, il est plus ergonomique d’intégrer la recherche dans la liste principale.
Cependant, dans une application plus complexe (ex : librairie en ligne), il est préférable d’avoir une page de recherche dédiée, pour gérer plusieurs filtres et une interface plus riche. - Étapes globales
- Transformer
ListeLivresViewenStatefulWidget(car la recherche change dynamiquement). - Ajouter un
TextField(champ de recherche) dans la page. - Utiliser
livreController.rechercherLivres(query)quand le champ n’est pas vide, sinon afficher la liste complète avecgetLivresStream().
Cette recherche consiste à filtrer les documents selon la valeur exacte d’un champ.
Exemple : retrouver tous les livres écrits par Victor Hugo.
🔹 Lecture ponctuelle (Future)
Lecture « one-shot », exécutée une seule fois au moment de l’appel.
Future<List<Livre>> rechercherLivresParAuteur(String auteur) async {
try {
final snapshot = await FirebaseFirestore.instance
.collection('livres')
.where('auteur', isEqualTo: auteur) // Filtre sur le champ 'auteur'
.get(); // Lecture ponctuelle
// Conversion des documents Firestore en objets Livre
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
} catch (e) {
throw 'Erreur lors de la recherche : $e';
}
}
🧭 Utilisation typique :
Afficher la liste des livres d’un auteur donné.
Exemple : l’utilisateur saisit « Victor Hugo » dans un champ de recherche.
🔹 Recherche dynamique en temps réel (Stream)
Lecture en continu, les résultats sont automatiquement mis à jour lorsqu’un changement a lieu dans Firestore.
Stream<List<Livre>> rechercherLivresStream(String auteur) {
return FirebaseFirestore.instance
.collection('livres')
.where('auteur', isEqualTo: auteur)
.snapshots() // Écoute en temps réel
.map((snapshot) {
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
});
}
🧭 Utilisation typique :
Rafraîchir automatiquement la liste affichée à l’écran.
Exemple : affichage avec un StreamBuilder dans Flutter.
Tu peux combiner plusieurs conditions (where) pour affiner la recherche.
⚠️ Firestore impose certaines limites (souvent un index combiné est requis).
🔹 Exemple : auteur + année minimale
Future<List<Livre>> rechercherLivresAvances(String auteur, int anneeMin) async {
try {
final snapshot = await FirebaseFirestore.instance
.collection('livres')
.where('auteur', isEqualTo: auteur)
.where('annee', isGreaterThanOrEqualTo: anneeMin)
.get();
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
} catch (e) {
throw 'Erreur lors de la recherche avancée : $e';
}
}
🧭 Utilisation typique :
Rechercher tous les livres de Victor Hugo publiés après 1850.
Utile pour des filtres combinés (par année, catégorie, auteur…).
Firestore ne prend pas en charge les recherches textuelles floues (contains, startsWith, etc.) comme SQL.
➡️ Solution : créer un champ d’index de mots-clés (keywords) dans chaque document.
🔹 Exemple de structure de document
{
"titre": "Les Misérables",
"auteur": "Victor Hugo",
"annee": 1862,
"keywords": ["les", "misérables", "les misérables", "victor hugo"]
}
Chaque document contient un tableau keywords utilisé pour la recherche.
🔹 Exemple de fonction Dart
Future<List<Livre>> rechercherParMotCle(String mot) async {
try {
final snapshot = await FirebaseFirestore.instance
.collection('livres')
.where('keywords', arrayContains: mot.toLowerCase())
.get();
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
} catch (e) {
throw 'Erreur lors de la recherche par mot-clé : $e';
}
}
🧭 Utilisation typique :
Recherche « intelligente » dans une barre de recherche.
Exemple : taper “mis” retourne Les Misérables grâce au champ keywords.
| Type de recherche | Méthode Firestore | Mode | Retourne | Exemple d’usage |
|---|---|---|---|---|
| Filtrage simple | .where('auteur', isEqualTo: ...) |
Ponctuel (get()) |
Future<List> |
Trouver tous les livres d’un auteur |
| Filtrage numérique | .where('annee', isGreaterThanOrEqualTo: ...) |
Ponctuel ou Stream | Liste filtrée | Livres publiés après une certaine année |
| Temps réel | .snapshots() |
Stream | Stream<List> |
Résultats mis à jour automatiquement |
| Recherche par mot-clé | .where('keywords', arrayContains: ...) |
Ponctuel | Liste filtrée | Recherche partielle sur le titre |
🔒 Sécurité et règles Firestore
- Les règles Firestore contrôlent qui peut lire et écrire des données.
- Exemple de règles basiques :
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// RÈGLES POUR DÉVELOPPEMENT (À CHANGER EN PRODUCTION)
match /livres/{document} {
allow read, write: if true; // Autorise tout le monde
}
// OU pour tester avec authentification :
// match /livres/{document} {
// allow read, write: if request.auth != null;
// }
}
}
Code de l’application
modelscontrollersviews
livre_model.dart
class Livre {
String? id;
String titre;
String auteur;
int annee;
Livre({
this.id,
required this.titre,
required this.auteur,
required this.annee,
});
// Convertir Livre en Map pour Firestore
Map<String, dynamic> toMap() {
return {
'titre': titre,
'auteur': auteur,
'annee': annee,
};
}
// Créer Livre depuis Firestore document
factory Livre.fromFirestore(Map data, String id) {
return Livre(
id: id,
titre: data['titre'] ?? '',
auteur: data['auteur'] ?? '',
annee: data['annee'] ?? 0,
);
}
}
livre_controller.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/livre_model.dart';
class LivreController {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
// Référence à la collection
CollectionReference get _livresRef => _firestore.collection('livres');
// CREATE - Ajouter un livre
Future<void> ajouterLivre(Livre livre) async {
try {
await _livresRef.add(livre.toMap());
} catch (e) {
throw 'Erreur lors de l\'ajout: $e';
}
}
// READ - Récupérer tous les livres (Stream)
Stream<List<Livre>> getLivresStream() {
return _livresRef.snapshots().map((snapshot) {
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
});
}
// READ - Récupérer un livre par ID
Future<Livre?> getLivreById(String id) async {
try {
final doc = await _livresRef.doc(id).get();
if (doc.exists) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
throw 'Erreur lors de la récupération: $e';
}
}
// UPDATE - Modifier un livre
Future<void> modifierLivre(String id, Livre livre) async {
try {
await _livresRef.doc(id).update(livre.toMap());
} catch (e) {
throw 'Erreur lors de la modification: $e';
}
}
// DELETE - Supprimer un livre
Future<void> supprimerLivre(String id) async {
try {
await _livresRef.doc(id).delete();
} catch (e) {
throw 'Erreur lors de la suppression: $e';
}
}
// Recherche par titre
Stream<List<Livre>> rechercherLivres(String query) {
return _livresRef
.where('titre', isGreaterThanOrEqualTo: query)
.where('titre', isLessThan: query + 'z')
.snapshots()
.map((snapshot) {
return snapshot.docs.map((doc) {
return Livre.fromFirestore(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
});
}
}
ajouter_livre_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/livre_controller.dart';
import '../models/livre_model.dart';
class AjouterLivreView extends StatefulWidget {
const AjouterLivreView({super.key});
@override
State<AjouterLivreView> createState() => _AjouterLivreViewState();
}
class _AjouterLivreViewState extends State<AjouterLivreView> {
final _formKey = GlobalKey<FormState>();
final _titreController = TextEditingController();
final _auteurController = TextEditingController();
final _anneeController = TextEditingController();
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final livreController = Provider.of<LivreController>(context, listen: false);
return Scaffold(
appBar: AppBar(
title: const Text('Ajouter un livre'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titreController,
decoration: const InputDecoration(
labelText: 'Titre',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un titre';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _auteurController,
decoration: const InputDecoration(
labelText: 'Auteur',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un auteur';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _anneeController,
decoration: const InputDecoration(
labelText: 'Année',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer une année';
}
final annee = int.tryParse(value);
if (annee == null || annee < 0) {
return 'Veuillez entrer une année valide';
}
return null;
},
),
const SizedBox(height: 24),
_isLoading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: _submitForm,
child: const Text('Ajouter le livre'),
),
],
),
),
),
);
}
Future<void> _submitForm() async {
if (_formKey.currentState!.validate()) {
setState(() => _isLoading = true);
final livreController = Provider.of<LivreController>(context, listen: false);
final nouveauLivre = Livre(
titre: _titreController.text,
auteur: _auteurController.text,
annee: int.parse(_anneeController.text),
);
try {
await livreController.ajouterLivre(nouveauLivre);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Livre ajouté avec succès')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_titreController.dispose();
_auteurController.dispose();
_anneeController.dispose();
super.dispose();
}
}
liste_livres_view_recherche.dart
import '../views/modifier_livre_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/livre_controller.dart';
import '../models/livre_model.dart';
import 'ajouter_livre_view.dart';
class ListeLivresView extends StatefulWidget {
const ListeLivresView({super.key});
@override
State<ListeLivresView> createState() => _ListeLivresViewState();
}
class _ListeLivresViewState extends State<ListeLivresView> {
String _searchQuery = '';
@override
Widget build(BuildContext context) {
final livreController = Provider.of<LivreController>(context, listen: false);
// On choisit le flux en fonction du champ de recherche
final Stream<List<Livre>> livresStream = _searchQuery.isEmpty
? livreController.getLivresStream()
: livreController.rechercherLivres(_searchQuery);
return Scaffold(
appBar: AppBar(
title: const Text('Ma Bibliothèque'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AjouterLivreView()),
);
},
),
],
),
body: Column(
children: [
// 🔎 Zone de recherche
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Rechercher un livre par titre...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: (value) {
setState(() {
_searchQuery = value.trim();
});
},
),
),
// 📚 Liste des livres
Expanded(
child: StreamBuilder<List<Livre>>(
stream: livresStream,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Erreur: ${snapshot.error}'));
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final livres = snapshot.data ?? [];
if (livres.isEmpty) {
return const Center(child: Text('Aucun livre trouvé'));
}
return ListView.builder(
itemCount: livres.length,
itemBuilder: (context, index) {
final livre = livres[index];
return LivreTile(livre: livre);
},
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AjouterLivreView()),
);
},
child: const Icon(Icons.add),
),
);
}
}
class LivreTile extends StatelessWidget {
final Livre livre;
const LivreTile({super.key, required this.livre});
@override
Widget build(BuildContext context) {
final livreController = Provider.of<LivreController>(context, listen: false);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
title: Text(livre.titre),
subtitle: Text('${livre.auteur} - ${livre.annee}'),
trailing: PopupMenuButton(
onSelected: (value) {
switch (value) {
case 'modifier':
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ModifierLivreView(livre: livre),
),
);
break;
case 'supprimer':
_showDeleteDialog(context, livreController, livre.id!);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'modifier',
child: Row(
children: [
Icon(Icons.edit, color: Color(0xFF4CAF50), size: 20),
const SizedBox(width: 8),
const Text('Modifier'),
],
),
),
PopupMenuItem(
value: 'supprimer',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red[600], size: 20),
const SizedBox(width: 8),
const Text('Supprimer'),
],
),
),
],
),
),
);
}
void _showDeleteDialog(
BuildContext context, LivreController controller, String id) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: const Text('Voulez-vous vraiment supprimer ce livre ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
try {
await controller.supprimerLivre(id);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Livre supprimé avec succès')),
);
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
},
child: const Text('Supprimer'),
),
],
),
);
}
}
liste_livres_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/livre_controller.dart';
import '../models/livre_model.dart';
import 'ajouter_livre_view.dart';
class ListeLivresView extends StatelessWidget {
const ListeLivresView({super.key});
@override
Widget build(BuildContext context) {
final livreController = Provider.of<LivreController>(context, listen: false);
return Scaffold(
appBar: AppBar(
title: const Text('Ma Bibliothèque'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => AjouterLivreView()),
);
},
),
],
),
body: StreamBuilder<List<Livre>>(
stream: livreController.getLivresStream(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Erreur: ${snapshot.error}'));
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final livres = snapshot.data ?? [];
if (livres.isEmpty) {
return const Center(child: Text('Aucun livre trouvé'));
}
return ListView.builder(
itemCount: livres.length,
itemBuilder: (context, index) {
final livre = livres[index];
return LivreTile(livre: livre);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => AjouterLivreView()),
);
},
child: const Icon(Icons.add),
),
);
}
}
class LivreTile extends StatelessWidget {
final Livre livre;
const LivreTile({super.key, required this.livre});
@override
Widget build(BuildContext context) {
final livreController = Provider.of<LivreController>(context, listen: false);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
title: Text(livre.titre),
subtitle: Text('${livre.auteur} - ${livre.annee}'),
trailing: PopupMenuButton(
onSelected: (value) {
switch (value) {
case 'modifier':
// Navigation vers modification
break;
case 'supprimer':
_showDeleteDialog(context, livreController, livre.id!);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'modifier', child: Text('Modifier')),
const PopupMenuItem(value: 'supprimer', child: Text('Supprimer')),
],
),
),
);
}
void _showDeleteDialog(BuildContext context, LivreController controller, String id) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: const Text('Voulez-vous vraiment supprimer ce livre ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
try {
await controller.supprimerLivre(id);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Livre supprimé avec succès')),
);
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
},
child: const Text('Supprimer'),
),
],
),
);
}
}
modifier_livre_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/livre_controller.dart';
import '../models/livre_model.dart';
class ModifierLivreView extends StatefulWidget {
final Livre livre;
const ModifierLivreView({
super.key,
required this.livre,
});
@override
State<ModifierLivreView> createState() => _ModifierLivreViewState();
}
class _ModifierLivreViewState extends State<ModifierLivreView> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _titreController;
late TextEditingController _auteurController;
late TextEditingController _anneeController;
bool _isLoading = false;
@override
void initState() {
super.initState();
// Initialiser les contrôleurs avec les valeurs existantes du livre
_titreController = TextEditingController(text: widget.livre.titre);
_auteurController = TextEditingController(text: widget.livre.auteur);
_anneeController = TextEditingController(text: widget.livre.annee.toString());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Modifier le livre'),
actions: [
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _showDeleteDialog(context),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titreController,
decoration: const InputDecoration(
labelText: 'Titre',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un titre';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _auteurController,
decoration: const InputDecoration(
labelText: 'Auteur',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un auteur';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _anneeController,
decoration: const InputDecoration(
labelText: 'Année',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer une année';
}
final annee = int.tryParse(value);
if (annee == null || annee < 0) {
return 'Veuillez entrer une année valide';
}
return null;
},
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: const Text('Annuler'),
),
),
const SizedBox(width: 16),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: ElevatedButton(
onPressed: _submitForm,
child: const Text('Modifier'),
),
),
],
),
],
),
),
),
);
}
Future<void> _submitForm() async {
if (_formKey.currentState!.validate()) {
setState(() => _isLoading = true);
final livreController = Provider.of<LivreController>(context, listen: false);
// Créer un livre mis à jour avec le même ID
final livreModifie = Livre(
id: widget.livre.id, // Garder le même ID
titre: _titreController.text,
auteur: _auteurController.text,
annee: int.parse(_anneeController.text),
);
try {
// Appel à votre méthode modifierLivre qui prend l'ID et le livre
await livreController.modifierLivre(livreModifie.id!, livreModifie);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Livre modifié avec succès')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
}
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Supprimer le livre'),
content: const Text('Êtes-vous sûr de vouloir supprimer ce livre ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.pop(context); // Fermer la dialog
await _deleteLivre(context);
},
child: const Text(
'Supprimer',
style: TextStyle(color: Colors.red),
),
),
],
);
},
);
}
Future<void> _deleteLivre(BuildContext context) async {
setState(() => _isLoading = true);
final livreController = Provider.of<LivreController>(context, listen: false);
try {
await livreController.supprimerLivre(widget.livre.id!);
Navigator.pop(context); // Retour à la liste
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Livre supprimé avec succès')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
void dispose() {
_titreController.dispose();
_auteurController.dispose();
_anneeController.dispose();
super.dispose();
}
}
main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:provider/provider.dart';
import 'firebase_options.dart'; // ← Fichier de configuration
import 'controllers/livre_controller.dart';
//import 'views/liste_livres_view.dart';
import 'views/liste_livres_view_recherche.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialisation Firebase avec la configuration spécifique
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, // ← Utilise notre config
);
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<LivreController>(
create: (_) => LivreController(),
),
],
child: MaterialApp(
title: 'Ma Bibliothèque',
// theme: ThemeData(
// primarySwatch: Colors.blue,
// useMaterial3: true,
// ),
theme: ThemeData(
useMaterial3: false, // ⚠️ Pour compatibilité avec cardTheme comme ci-dessous
primaryColor: const Color(0xFF4CAF50),
scaffoldBackgroundColor: const Color(0xFFF5F5F5),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF4CAF50),
foregroundColor: Colors.white,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Color(0xFF4CAF50),
),
cardTheme: CardThemeData( // ← ICI : CardThemeData au lieu de CardTheme ancienne version
color: Colors.white,
elevation: 4,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
popupMenuTheme: PopupMenuThemeData(
color: Colors.white,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey[300]!, width: 1),
),
textStyle: TextStyle(
color: Colors.grey[800],
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
home: const ListeLivresView(),
debugShowCheckedModeBanner: false,
),
);
}
}
