Implémenter la Gestion des Rôles Firebase avec Flutter
Sommaire
- Objectif
- Présentation
- Les 4 rôles de l'application
- Architecture du projet (MVC)
- Configuration du projet
- Créer le compte Administrateur
- Fichiers réalisés
- Extension de l'application : Gestion de la scolarité
- Nouveaux modèles
- Nouveau contrôleur : scolarite_controller.dart
- Dashboards mis à jour
- Correction de la déconnexion dans les dashboards
- Mise à jour des Security Rules Firestore
Implémenter la Gestion des Rôles Firebase avec Flutter
-
Objectif
- À l’issue de ce tutoriel, vous serez capable d’intégrer un système complet de gestion des rôles dans une application Flutter en utilisant Firebase Authentication et Cloud Firestore, avec VS Code comme environnement de développement.
- Vous apprendrez à :
- Créer et configurer un projet Firebase avec Auth et Firestore.
- Structurer votre projet Flutter selon l’architecture MVC (Modèle – Vue – Contrôleur).
- Définir un enum UserRole pour représenter les 4 rôles.
- Créer une interface de connexion unique redirigeant chaque utilisateur vers son dashboard.
- Permettre à l’admin de créer tous les comptes avec email, mot de passe et rôle.
- Sécuriser Firestore avec des Security Rules basées sur les rôles.
- Gérer les erreurs de connexion et naviguer entre les écrans.
-
Présentation
- La gestion des rôles (Role-Based Access Control — RBAC) est un concept fondamental en développement d’applications multi-utilisateurs. Plutôt que de donner les mêmes droits à tous les utilisateurs, on définit des rôles, et chaque rôle dispose de permissions spécifiques.
- Dans ce tutoriel, nous utilisons deux services Firebase :
- Firebase Authentication : gère l’identité (email + mot de passe) de chaque utilisateur.
- Cloud Firestore : stocke le profil de l’utilisateur, notamment son rôle.
- 💡 Concept clé : Le rôle N’EST PAS stocké dans Firebase Auth. Il est stocké dans Firestore (collection
users), et lu après chaque connexion pour décider vers quel écran rediriger l’utilisateur. -
Les 4 rôles de l’application
- L’application distingue 4 types d’utilisateurs, chacun avec ses propres permissions :
- admin 🔴 : Crée et supprime tous les comptes utilisateurs, voit tous les utilisateurs.
- enseignant 🟢 : Crée et gère ses propres cours (propriétaire des cours).
- etudiant 🔵 : Consulte les cours disponibles et peut s’inscrire.
- responsable_etudiant 🟠 : Supervise la liste des étudiants et tous les cours.
- ⚠️ Important : Tous les utilisateurs se connectent via la MÊME interface (
LoginView). C’est après la connexion que l’application lit le rôle dans Firestore et redirige vers le bon dashboard. -
Architecture du projet (MVC)
- Le projet est organisé selon le pattern MVC (Modèle – Vue – Contrôleur) avec la structure suivante :
-
Configuration du projet
-
Étape 1 : Créer un projet Firebase
- Va sur Firebase Console
- Clique sur « Ajouter un projet »
- Donne un nom à ton projet, accepte les conditions, clique sur Continuer
- Active Firebase Authentication → méthode Email/Mot de passe
- Active Cloud Firestore en mode production
-
Étape 2 : Ajouter Firebase à ton projet Flutter
-
Créer une nouvelle application Flutter
flutter create gestion_roles- Ouvrez le projet dans un IDE (Android Studio, VS Code…)
-
Se connecter à Firebase et configurer FlutterFire CLI
- Si ce n’est pas encore fait, installe FlutterFire CLI :
dart pub global activate flutterfire_cli - Puis connecte-toi :
flutterfire login - Et configure Firebase pour ce projet :
flutterfire configure - Lors de la configuration :
- Choisis ton projet Firebase existant.
- Sélectionne Android comme plateforme.
- Entre le bon nom de package (ex:
com.example.gestionroles). - Cela génère un fichier
firebase_options.dartdanslib/. -
Configurer Firebase dans le projet Flutter
- a. Copier le google-services.json :
- Téléchargé depuis la console Firebase (section Android).
- Colle-le dans :
android/app/google-services.json - b. Modifier android/build.gradle :
- c. Modifier android/app/build.gradle :
- d. Modifier le fichier : android/app/build.gradle.kts
- Dans ton fichier
android/app/build.gradle.kts, ajoute (ou modifie) ce bloc dans android : - e. Ajouter les dépendances dans pubspec.yaml :
-
Créer le compte Administrateur
- Avant de lancer l’application, vous devez créer manuellement le compte administrateur. Contrairement aux autres utilisateurs qui seront créés depuis l’application, le premier compte admin doit être configuré directement dans la console Firebase en deux étapes obligatoires : une dans Firebase Authentication et une dans Firestore.
- ⚠️ Pourquoi deux étapes ? Firebase Auth vérifie uniquement l’identité (email + mot de passe). Le rôle de l’utilisateur est stocké dans Firestore. Sans le document Firestore, l’application ne peut pas connaître le rôle et retournera
nullaprès la connexion. -
Étape 1 : Créer le compte dans Firebase Authentication
- Allez sur console.firebase.google.com
- Votre projet → Authentication → Users → Add user
- Saisissez l’email et le mot de passe de l’admin
- Cliquez Add user
- ⚠️ Copiez l’UID généré (ex :
abc123xyz...) — vous en aurez besoin à l’étape suivante -
Étape 2 : Créer le document dans Firestore ⚠️ OBLIGATOIRE
- Sans cette étape, l’admin peut se connecter mais l’application ne trouvera pas son rôle et retournera
null. - Votre projet → Firestore → collection
users→ Add document - Dans Document ID, collez l’UID exact copié à l’étape 1
- Ajoutez les champs suivants :
- ⚠️ La valeur du champ
roledoit être exactement"admin"(en minuscule), c’est la valeur lue parUserRoleExtension.fromString()dans le modèle. -
Pourquoi ces deux étapes sont-elles nécessaires ?
- Une fois ce compte admin créé, il pourra se connecter via l’application et créer tous les autres comptes (enseignants, étudiants, responsables) directement depuis son dashboard sans passer par la console Firebase.
-
Fichiers réalisés
-
Fichier
firebase_options.dart -
Fichier
models/user_model.dart - Ce fichier définit l’enum UserRole et le modèle UserModel avec les méthodes de conversion vers/depuis Firestore :
-
Fichier
controllers/auth_controller.dart - Le contrôleur d’authentification gère la connexion, la déconnexion et la lecture du rôle depuis Firestore :
-
Fichier
controllers/user_controller.dart - Ce contrôleur gère la création des comptes (réservée à l’admin) et toutes les opérations sur les cours :
-
Fichier
main.dart -
Fichier
views/login_view.dart - Interface de connexion unique pour tous les rôles. Après authentification, l’utilisateur est redirigé vers son dashboard selon son rôle :
-
Fichier
views/admin/admin_dashboard.dart - Le dashboard admin comporte deux onglets : création de comptes et liste des utilisateurs :
-
Fichier
views/enseignant/enseignant_dashboard.dart - L’enseignant est propriétaire de ses cours. Ses cours sont filtrés par son UID dans Firestore :
-
Fichier
views/etudiant/etudiant_dashboard.dart - L’étudiant a un accès en lecture seule sur les cours. Il peut rechercher et s’inscrire :
-
Fichier
views/responsable/responsable_dashboard.dart - Le responsable étudiant a une vue de supervision en lecture seule sur les étudiants et les cours :
-
Fichier
firestore.rules— Security Rules - Copiez ces règles dans Firebase Console → Firestore → Règles pour sécuriser l’accès côté serveur :
-
Extension de l’application : Gestion de la scolarité
- Dans cette partie, nous allons étendre l’application en ajoutant la gestion complète de la scolarité : matières, groupes d’étudiants, affectations enseignant/matière et saisie des notes avec affichage du bulletin.
- Voici les nouvelles collections Firestore ajoutées :
- La nouvelle architecture complète du projet est la suivante :
-
Nouveaux modèles
-
Fichier
models/matiere_model.dart - Ce modèle représente une matière avec son coefficient, créée par le responsable étudiant :
-
Fichier
models/groupe_model.dart - Un groupe contient un nom, un niveau (ex: L2, M1) et la liste des UIDs des étudiants qui en font partie :
-
Fichier
models/affectation_model.dart - Une affectation lie un enseignant à une matière et un groupe :
-
Fichier
models/note_model.dart - Une note est liée à un étudiant, une matière, un groupe et un semestre. Le coefficient est stocké directement pour faciliter le calcul de la moyenne pondérée :
-
Nouveau contrôleur :
scolarite_controller.dart - Ce contrôleur centralise toute la logique métier liée à la scolarité : création et lecture des matières, groupes, affectations et notes, ainsi que le calcul de la moyenne pondérée :
-
Dashboards mis à jour
-
Fichier
views/responsable/responsable_dashboard.dart - Le dashboard responsable est enrichi de 4 onglets : matières, groupes, affectations et étudiants :
- Onglet Matières : créer une matière (nom + coefficient) → appel à
createMatiere() - Onglet Groupes : créer un groupe (nom + niveau), affecter des étudiants via un dialog de sélection
- Onglet Affectations : lier un enseignant à une matière et un groupe via 3 dropdowns
- Onglet Étudiants : voir tous les étudiants avec leur groupe affiché
-
Fichier
views/enseignant/enseignant_dashboard.dart - Le dashboard enseignant contient 2 onglets : ses affectations et la saisie des notes :
- Onglet Mes affectations : liste des matières/groupes auxquels il est affecté (filtrés par son UID)
- Onglet Saisir notes : sélection affectation + semestre → liste des étudiants du groupe avec champ de note → bouton enregistrer tout
- 💡 Note : Si une note existe déjà pour un étudiant/matière/semestre,
saisirNote()la met à jour au lieu d’en créer une nouvelle. Les champs sont pré-remplis avec les valeurs existantes. -
Fichier
views/etudiant/etudiant_dashboard.dart - Le dashboard étudiant contient 2 onglets : son profil et son bulletin :
- Onglet Mon profil : nom, email, groupe affecté, nombre de notes enregistrées
- Onglet Mon bulletin : toggle S1/S2 → liste des notes avec barre de progression colorée + carte moyenne générale + statut admis ✅ / ajourné ❌
-
Fichier
views/admin/admin_dashboard.dart - Le dashboard admin est mis à jour avec un 3ème onglet Affectations, car l’admin peut également affecter des enseignants aux matières en plus du responsable :
-
Correction de la déconnexion dans les dashboards
- Dans chaque dashboard, la méthode
_logout()doit rediriger versLoginView. Ajoutez l'import et modifiez la méthode dans les 4 fichiers : -
Mise à jour des Security Rules Firestore
- Les Security Rules Firestore sont des règles de sécurité appliquées côté serveur par Firebase, indépendamment du code Flutter. Même si un utilisateur modifie le code de l'application, il ne pourra jamais contourner ces règles.
- Elles répondent à la question : « Qui a le droit de lire ou d'écrire dans cette collection ? »
- Dans notre application, les règles sont basées sur le rôle de l'utilisateur connecté, stocké dans le document Firestore
users/{uid}. -
Principe de fonctionnement
- Avant chaque lecture ou écriture, Firebase exécute la règle correspondante.
- Si la condition retourne
true→ l'accès est autorisé. - Si la condition retourne
false→ l'accès est refusé avec une erreurPERMISSION_DENIED. - Les fonctions
isAdmin(),isEnseignant(), etc. lisent le champroledepuis Firestore en temps réel pour vérifier le rôle de l'utilisateur connecté. -
Tableau des permissions par collection
-
Collection Lecture (read) Écriture (write) usersAdmin ou l'utilisateur lui-même Admin uniquement matieresTout utilisateur connecté Admin ou Responsable groupesTout utilisateur connecté Admin ou Responsable affectationsTout utilisateur connecté Admin ou Responsable notesTout utilisateur connecté Admin ou Enseignant -
Règles complètes commentées
- Copiez ces règles dans Firebase Console → Firestore → Règles :
- ⚠️ Remarque importante : La règle
allow read: if isAuth()sur les notes donne accès à toutes les notes à tout utilisateur connecté. Pour restreindre un étudiant à ses seules notes, remplacez par : -
Responsable
-
Que se passe-t-il si vous n'écrivez pas les Security Rules ?
- Le comportement dépend du mode choisi lors de la création de Firestore. Il existe deux modes :
- Mode Production (par défaut recommandé) :
- ❌ Conséquence : Personne ne peut lire ni écrire. L'application ne fonctionne pas du tout. Vous verrez l'erreur
PERMISSION_DENIEDpartout. - Mode Test (souvent choisi par erreur) :
- ⚠️ Conséquence : N'importe qui peut lire, modifier ou supprimer toutes vos données sans être connecté. C'est extrêmement dangereux.
-
Résumé des risques
-
Situation Conséquence Mode production sans règles personnalisées App bloquée, rien ne fonctionne ( PERMISSION_DENIED)Mode test sans règles personnalisées Toutes les données accessibles publiquement sur Internet Sans règle sur notesUn étudiant peut lire les notes de tous les autres étudiants Sans règle sur usersN'importe qui peut lire ou modifier les rôles de tous les utilisateurs -
Conclusion
- Les Security Rules sont obligatoires pour deux raisons :
- Fonctionnement : sans elles en mode production, l'application est complètement bloquée.
- Sécurité : sans elles en mode test, vos données sont exposées publiquement sur Internet et accessibles par n'importe qui via l'API Firestore REST, même sans votre application.
- 💡 Firebase affiche d'ailleurs un avertissement rouge dans la console si votre Firestore est en mode test depuis plus de 30 jours.
lib/
├── main.dart ← Point d'entrée de l'application
├── firebase_options.dart ← Configuration Firebase (généré automatiquement)
│
├── models/ ← 📦 MODÈLES (données)
│ ├── user_model.dart ← Modèle utilisateur + enum UserRole
│ └── cours_model.dart ← Modèle cours
│
├── controllers/ ← 🧠 CONTRÔLEURS (logique métier)
│ ├── auth_controller.dart ← Connexion / Déconnexion / Lecture rôle
│ └── user_controller.dart ← CRUD utilisateurs et cours
│
└── views/ ← 🖥️ VUES (interface utilisateur)
├── login_view.dart ← Interface de connexion unique
├── admin/
│ └── admin_dashboard.dart ← 🔴 Dashboard Administrateur
├── enseignant/
│ └── enseignant_dashboard.dart ← 🟢 Dashboard Enseignant
├── etudiant/
│ └── etudiant_dashboard.dart ← 🔵 Dashboard Étudiant
└── responsable/
└── responsable_dashboard.dart ← 🟠 Dashboard Responsable Étudiant
buildscript {
dependencies {
classpath("com.google.gms:google-services:4.3.15")
}
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
}
android {
ndkVersion = "27.0.12077973"
// ... le reste de ta configuration Android
}
dependencies:
flutter:
sdk: flutter
firebase_core: ^2.24.2
firebase_auth: ^4.16.0
cloud_firestore: ^4.14.0
{
"email": "admin@univ.tn", // type : string
"nom": "Admin", // type : string
"prenom": "Super", // type : string
"role": "admin", // type : string ← valeur exacte obligatoire
"createdAt": [date actuelle] // type : timestamp
}
Firebase Auth → vérifie email + mot de passe ✅
Firestore → stocke le rôle "admin" ✅
↓
AuthController.signIn() lit le champ "role" dans Firestore
↓
_redirectByRole() → AdminDashboard 🔴
// File: lib/firebase_options.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform;
/*
📋 INSTRUCTIONS POUR LES ÉTUDIANTS:
1. Récupérez vos clés Firebase sur https://console.firebase.google.com/
2. Remplacez les valeurs ci-dessous par VOS propres clés
3. NE COMMITTEZ PAS ce fichier avec vos vraies clés sur Git!
4. Ajoutez-le dans .gitignore pour la sécurité
📍 Où trouver les valeurs:
• apiKey → google-services.json → client → api_key → current_key
• appId → google-services.json → client → client_info → mobilesdk_app_id
• messagingSenderId → google-services.json → project_info → project_number
• projectId → google-services.json → project_info → project_id
• storageBucket → google-services.json → project_info → storage_bucket
*/
/// CONFIGURATION FIREBASE POUR TOUTES LES PLATEFORMES
class DefaultFirebaseOptions {
/// CONFIGURATION ANDROID
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDMA991uRiFusn1DyxoZgzF0_HOf8Ab6fs',
appId: '1:210885415501:android:4758b89c6d137dc2018204',
messagingSenderId: '210885415501',
projectId: 'projet-livre-firestore',
storageBucket: 'projet-livre-firestore.firebasestorage.app',
);
/// CONFIGURATION iOS (À décommenter si nécessaire)
/*
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyA1B2C3d4E5f6G7H8I9J0K1L2M3N4O5P6Q',
appId: '1:835574812736:ios:abc123def456ghi789',
messagingSenderId: '835574812736',
projectId: 'gestion-bibliotheque-7ed2f',
storageBucket: 'gestion-bibliotheque-7ed2f.firebasestorage.app',
iosBundleId: 'com.example.gestionBibliotheque',
);
*/
/// CONFIGURATION WEB (À décommenter si nécessaire)
/*
static const FirebaseOptions web = FirebaseOptions(
apiKey: '...',
appId: '...',
messagingSenderId: '...',
projectId: '...',
authDomain: '...',
storageBucket: '...',
);
*/
/// 🔧 DÉTECTION AUTOMATIQUE DE LA PLATEFORME
static FirebaseOptions get currentPlatform {
// Si on est sur le web
if (kIsWeb) {
// return web; // Décommenter quand web est configuré
throw UnsupportedError(
'Configuration Web non encore configurée. '
'Veuillez décommenter et configurer la section web ci-dessus.',
);
}
// Si on est sur mobile
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
// return ios; // Décommenter quand iOS est configuré
throw UnsupportedError(
'Configuration iOS non encore configurée. '
'Veuillez décommenter et configurer la section iOS ci-dessus.',
);
default:
throw UnsupportedError(
'Plateforme non supportée: ${defaultTargetPlatform}. '
'Seul Android est configuré pour le moment.',
);
}
}
}
// File: lib/models/user_model.dart
/// 🎭 ENUM DES RÔLES DISPONIBLES
enum UserRole {
admin,
enseignant,
etudiant,
responsable_etudiant,
}
/// Extension pour convertir le rôle en/depuis String (Firestore)
extension UserRoleExtension on UserRole {
String get name {
switch (this) {
case UserRole.admin: return 'admin';
case UserRole.enseignant: return 'enseignant';
case UserRole.etudiant: return 'etudiant';
case UserRole.responsable_etudiant: return 'responsable_etudiant';
}
}
String get displayName {
switch (this) {
case UserRole.admin: return 'Administrateur';
case UserRole.enseignant: return 'Enseignant';
case UserRole.etudiant: return 'Étudiant';
case UserRole.responsable_etudiant: return 'Responsable Étudiant';
}
}
static UserRole fromString(String value) {
switch (value) {
case 'admin': return UserRole.admin;
case 'enseignant': return UserRole.enseignant;
case 'etudiant': return UserRole.etudiant;
case 'responsable_etudiant': return UserRole.responsable_etudiant;
default: throw Exception('Rôle inconnu: $value');
}
}
}
/// 👤 MODÈLE UTILISATEUR
class UserModel {
final String uid;
final String email;
final String nom;
final String prenom;
final UserRole role;
final DateTime createdAt;
UserModel({
required this.uid,
required this.email,
required this.nom,
required this.prenom,
required this.role,
required this.createdAt,
});
/// Depuis Firestore → UserModel
factory UserModel.fromMap(String uid, Map<String, dynamic> map) {
return UserModel(
uid: uid,
email: map['email'] ?? '',
nom: map['nom'] ?? '',
prenom: map['prenom'] ?? '',
role: UserRoleExtension.fromString(map['role'] ?? 'etudiant'),
createdAt: DateTime.now(),
);
}
/// UserModel → Firestore
Map<String, dynamic> toMap() {
return {
'email': email,
'nom': nom,
'prenom': prenom,
'role': role.name,
'createdAt': createdAt,
};
}
String get fullName => '$prenom $nom';
}
// File: lib/controllers/auth_controller.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/user_model.dart';
/// 🔐 CONTRÔLEUR D'AUTHENTIFICATION
class AuthController {
final FirebaseAuth _auth = FirebaseAuth.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// 🔑 CONNEXION avec email + mot de passe
Future<UserModel?> signIn(String email, String password) async {
try {
final credential = await _auth.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
if (credential.user == null) return null;
// Récupérer le profil + rôle depuis Firestore
return await getUserById(credential.user!.uid);
} on FirebaseAuthException catch (e) {
throw _mapAuthError(e);
}
}
/// 👤 Lire le profil utilisateur depuis Firestore
Future<UserModel?> getUserById(String uid) async {
final doc = await _firestore.collection('users').doc(uid).get();
if (!doc.exists) return null;
return UserModel.fromMap(doc.id, doc.data()!);
}
/// 🚪 DÉCONNEXION
Future<void> signOut() async => await _auth.signOut();
/// 🛠️ Convertir les erreurs Firebase en messages lisibles
String _mapAuthError(FirebaseAuthException e) {
switch (e.code) {
case 'invalid-credential': return 'Email ou mot de passe incorrect.';
case 'user-not-found': return 'Aucun compte trouvé avec cet email.';
case 'wrong-password': return 'Mot de passe incorrect.';
case 'user-disabled': return 'Ce compte a été désactivé.';
case 'too-many-requests': return 'Trop de tentatives. Réessayez plus tard.';
default: return 'Erreur: ${e.message}';
}
}
}
// File: lib/controllers/user_controller.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/user_model.dart';
/// 👥 CONTRÔLEUR UTILISATEURS
class UserController {
final FirebaseAuth _auth = FirebaseAuth.instance;
final FirebaseFirestore _db = FirebaseFirestore.instance;
// ── ADMIN : CRÉER UN COMPTE ──────────────────────────────────
Future<UserModel> createUser({
required String email,
required String password,
required String nom,
required String prenom,
required UserRole role,
}) async {
// 1. Créer dans Firebase Auth
final credential = await _auth.createUserWithEmailAndPassword(
email: email.trim(), password: password,
);
final uid = credential.user!.uid;
// 2. Sauvegarder le profil + rôle dans Firestore
final user = UserModel(
uid: uid, email: email.trim(),
nom: nom.trim(), prenom: prenom.trim(),
role: role, createdAt: DateTime.now(),
);
await _db.collection('users').doc(uid).set(user.toMap());
return user;
}
// ── LIRE TOUS LES UTILISATEURS (admin) ──────────────────────
Future<List<UserModel>> getAllUsers() async {
final snap = await _db.collection('users')
.orderBy('createdAt', descending: true).get();
return snap.docs
.map((d) => UserModel.fromMap(d.id, d.data()))
.toList();
}
// ── LIRE PAR RÔLE (responsable) ─────────────────────────────
Future<List<UserModel>> getUsersByRole(UserRole role) async {
final snap = await _db.collection('users')
.where('role', isEqualTo: role.name).get();
return snap.docs
.map((d) => UserModel.fromMap(d.id, d.data()))
.toList();
}
// ── SUPPRIMER UN UTILISATEUR (admin) ────────────────────────
Future<void> deleteUserFromFirestore(String uid) async {
await _db.collection('users').doc(uid).delete();
}
// ── CRÉER UN COURS (enseignant) ──────────────────────────────
Future<void> createCours({
required String enseignantId,
required String enseignantNom,
required String titre,
required String description,
required String matiere,
}) async {
await _db.collection('cours').add({
'titre': titre,
'description': description,
'matiere': matiere,
'enseignantId': enseignantId,
'enseignantNom': enseignantNom,
'nombreEtudiants': 0,
'createdAt': FieldValue.serverTimestamp(),
});
}
// ── COURS D'UN ENSEIGNANT ────────────────────────────────────
Future<List<Map<String, dynamic>>> getCoursParEnseignant(String id) async {
final snap = await _db.collection('cours')
.where('enseignantId', isEqualTo: id).get();
return snap.docs.map((d) => {'id': d.id, ...d.data()}).toList();
}
// ── TOUS LES COURS ───────────────────────────────────────────
Future<List<Map<String, dynamic>>> getTousCours() async {
final snap = await _db.collection('cours')
.orderBy('createdAt', descending: true).get();
return snap.docs.map((d) => {'id': d.id, ...d.data()}).toList();
}
Future<void> deleteCours(String id) async {
await _db.collection('cours').doc(id).delete();
}
}
// File: lib/main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'views/login_view.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 🔥 Initialisation Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Gestion des Rôles Firebase',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1A237E)),
useMaterial3: true,
fontFamily: 'Roboto',
),
home: const LoginView(),
);
}
}
// File: lib/views/login_view.dart
import 'package:flutter/material.dart';
import '../controllers/auth_controller.dart';
import '../models/user_model.dart';
import 'admin/admin_dashboard.dart';
import 'enseignant/enseignant_dashboard.dart';
import 'etudiant/etudiant_dashboard.dart';
import 'responsable/responsable_dashboard.dart';
class LoginView extends StatefulWidget {
const LoginView({super.key});
@override
State<LoginView> createState() => _LoginViewState();
}
class _LoginViewState extends State<LoginView> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _authController = AuthController();
bool _isLoading = false;
bool _obscurePass = true;
String? _errorMessage;
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
setState(() { _isLoading = true; _errorMessage = null; });
try {
final user = await _authController.signIn(
_emailController.text, _passwordController.text);
if (user == null) {
setState(() => _errorMessage = 'Utilisateur introuvable dans la base.');
return;
}
if (!mounted) return;
_redirectByRole(user);
} catch (e) {
setState(() => _errorMessage = e.toString());
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
/// 🔀 Redirection vers le bon dashboard selon le rôle
void _redirectByRole(UserModel user) {
Widget destination;
switch (user.role) {
case UserRole.admin:
destination = AdminDashboard(currentUser: user); break;
case UserRole.enseignant:
destination = EnseignantDashboard(currentUser: user); break;
case UserRole.etudiant:
destination = EtudiantDashboard(currentUser: user); break;
case UserRole.responsable_etudiant:
destination = ResponsableDashboard(currentUser: user); break;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => destination),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1A237E), Color(0xFF283593), Color(0xFF3949AB)],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
children: [
const Icon(Icons.school_rounded, size: 80, color: Colors.white),
const SizedBox(height: 16),
const Text('Gestion des Rôles',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold,
color: Colors.white)),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Adresse email',
prefixIcon: Icon(Icons.email_outlined),
filled: true, fillColor: Colors.white,
),
validator: (v) => v!.isEmpty ? 'Email requis' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePass,
decoration: InputDecoration(
labelText: 'Mot de passe',
prefixIcon: const Icon(Icons.lock_outline),
filled: true, fillColor: Colors.white,
suffixIcon: IconButton(
icon: Icon(_obscurePass
? Icons.visibility_off_outlined
: Icons.visibility_outlined),
onPressed: () =>
setState(() => _obscurePass = !_obscurePass),
),
),
validator: (v) =>
v!.length < 6 ? 'Minimum 6 caractères' : null,
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(_errorMessage!,
style: const TextStyle(color: Colors.redAccent)),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Se connecter'),
),
],
),
),
),
),
),
),
);
}
}
// File: lib/views/admin/admin_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/user_controller.dart';
import '../../models/user_model.dart';
class AdminDashboard extends StatefulWidget {
final UserModel currentUser;
const AdminDashboard({super.key, required this.currentUser});
@override
State<AdminDashboard> createState() => _AdminDashboardState();
}
class _AdminDashboardState extends State<AdminDashboard>
with SingleTickerProviderStateMixin {
final _userController = UserController();
final _authController = AuthController();
late TabController _tabController;
List<UserModel> _users = [];
// Champs du formulaire de création
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _nomCtrl = TextEditingController();
final _prenomCtrl = TextEditingController();
UserRole _selectedRole = UserRole.etudiant;
bool _creating = false;
String? _createError;
String? _createSuccess;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadUsers();
}
Future<void> _loadUsers() async {
final users = await _userController.getAllUsers();
setState(() => _users = users);
}
/// 🆕 L'admin crée un compte avec email, mot de passe et rôle
Future<void> _createUser() async {
if (!_formKey.currentState!.validate()) return;
setState(() { _creating = true; _createError = null; });
try {
await _userController.createUser(
email: _emailCtrl.text,
password: _passwordCtrl.text,
nom: _nomCtrl.text,
prenom: _prenomCtrl.text,
role: _selectedRole,
);
setState(() =>
_createSuccess = '✅ Compte créé : ${_prenomCtrl.text} (${_selectedRole.name})');
_loadUsers();
} catch (e) {
setState(() => _createError = e.toString());
} finally {
setState(() => _creating = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.red.shade700,
title: Text('Admin — ${widget.currentUser.fullName}',
style: const TextStyle(color: Colors.white)),
actions: [
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
await _authController.signOut();
Navigator.of(context).pushReplacementNamed('/');
},
)
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
tabs: const [
Tab(icon: Icon(Icons.person_add), text: 'Créer un compte'),
Tab(icon: Icon(Icons.people), text: 'Utilisateurs'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildCreateUserTab(),
_buildUsersListTab(),
],
),
);
}
Widget _buildCreateUserTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(controller: _prenomCtrl,
decoration: const InputDecoration(labelText: 'Prénom'),
validator: (v) => v!.isEmpty ? 'Requis' : null),
TextFormField(controller: _nomCtrl,
decoration: const InputDecoration(labelText: 'Nom'),
validator: (v) => v!.isEmpty ? 'Requis' : null),
TextFormField(controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'Email'),
validator: (v) => v!.isEmpty ? 'Requis' : null),
TextFormField(controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: 'Mot de passe'),
validator: (v) => v!.length < 6 ? 'Min. 6 caractères' : null),
const SizedBox(height: 16),
// Sélection du rôle via des chips colorées
Wrap(
spacing: 8,
children: UserRole.values.map((role) =>
ChoiceChip(
label: Text(role.name),
selected: _selectedRole == role,
onSelected: (_) => setState(() => _selectedRole = role),
),
).toList(),
),
if (_createError != null) Text(_createError!,
style: const TextStyle(color: Colors.red)),
if (_createSuccess != null) Text(_createSuccess!,
style: const TextStyle(color: Colors.green)),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.person_add),
label: Text(_creating ? 'Création...' : 'Créer le compte'),
onPressed: _creating ? null : _createUser,
),
],
),
),
);
}
Widget _buildUsersListTab() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _users.length,
itemBuilder: (_, i) {
final u = _users[i];
return ListTile(
leading: const Icon(Icons.account_circle),
title: Text(u.fullName),
subtitle: Text('${u.email} — ${u.role.name}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
await _userController.deleteUserFromFirestore(u.uid);
_loadUsers();
},
),
);
},
);
}
}
// File: lib/views/enseignant/enseignant_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/user_controller.dart';
import '../../models/user_model.dart';
class EnseignantDashboard extends StatefulWidget {
final UserModel currentUser;
const EnseignantDashboard({super.key, required this.currentUser});
@override
State<EnseignantDashboard> createState() => _EnseignantDashboardState();
}
class _EnseignantDashboardState extends State<EnseignantDashboard>
with SingleTickerProviderStateMixin {
final _userController = UserController();
final _authController = AuthController();
late TabController _tabController;
List<Map<String, dynamic>> _cours = [];
final _formKey = GlobalKey<FormState>();
final _titreCtrl = TextEditingController();
final _descCtrl = TextEditingController();
final _matCtrl = TextEditingController();
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadCours();
}
/// Chargement des cours de cet enseignant uniquement
Future<void> _loadCours() async {
final cours = await _userController
.getCoursParEnseignant(widget.currentUser.uid);
setState(() => _cours = cours);
}
/// Création d'un cours (enseignant = propriétaire)
Future<void> _createCours() async {
if (!_formKey.currentState!.validate()) return;
await _userController.createCours(
titre: _titreCtrl.text,
description: _descCtrl.text,
matiere: _matCtrl.text,
enseignantId: widget.currentUser.uid,
enseignantNom: widget.currentUser.fullName,
);
_loadCours();
_tabController.animateTo(1);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.green.shade700,
title: Text('Enseignant — ${widget.currentUser.fullName}',
style: const TextStyle(color: Colors.white)),
actions: [
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
await _authController.signOut();
Navigator.of(context).pushReplacementNamed('/');
},
)
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
tabs: const [
Tab(icon: Icon(Icons.add_circle_outline), text: 'Nouveau cours'),
Tab(icon: Icon(Icons.menu_book), text: 'Mes cours'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(children: [
TextFormField(controller: _titreCtrl,
decoration: const InputDecoration(labelText: 'Titre du cours'),
validator: (v) => v!.isEmpty ? 'Requis' : null),
TextFormField(controller: _matCtrl,
decoration: const InputDecoration(labelText: 'Matière'),
validator: (v) => v!.isEmpty ? 'Requis' : null),
TextFormField(controller: _descCtrl, maxLines: 3,
decoration: const InputDecoration(labelText: 'Description'),
validator: (v) => v!.isEmpty ? 'Requis' : null),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('Publier le cours'),
onPressed: _createCours,
),
]),
),
),
ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _cours.length,
itemBuilder: (_, i) {
final c = _cours[i];
return ListTile(
leading: const Icon(Icons.menu_book, color: Colors.green),
title: Text(c['titre'] ?? ''),
subtitle: Text(c['matiere'] ?? ''),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
await _userController.deleteCours(c['id']);
_loadCours();
},
),
);
},
),
],
),
);
}
}
// File: lib/views/etudiant/etudiant_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/user_controller.dart';
import '../../models/user_model.dart';
class EtudiantDashboard extends StatefulWidget {
final UserModel currentUser;
const EtudiantDashboard({super.key, required this.currentUser});
@override
State<EtudiantDashboard> createState() => _EtudiantDashboardState();
}
class _EtudiantDashboardState extends State<EtudiantDashboard> {
final _userController = UserController();
final _authController = AuthController();
List<Map<String, dynamic>> _cours = [];
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadCours();
}
Future<void> _loadCours() async {
final cours = await _userController.getTousCours();
setState(() => _cours = cours);
}
/// Filtrage des cours par recherche (titre, matière, enseignant)
List<Map<String, dynamic>> get _filteredCours {
if (_searchQuery.isEmpty) return _cours;
return _cours.where((c) {
final q = _searchQuery.toLowerCase();
return (c['titre'] ?? '').toLowerCase().contains(q)
|| (c['matiere'] ?? '').toLowerCase().contains(q)
|| (c['enseignantNom'] ?? '').toLowerCase().contains(q);
}).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.blue.shade700,
title: Text('Étudiant — ${widget.currentUser.fullName}',
style: const TextStyle(color: Colors.white)),
actions: [
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
await _authController.signOut();
Navigator.of(context).pushReplacementNamed('/');
},
)
],
),
body: Column(children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
onChanged: (v) => setState(() => _searchQuery = v),
decoration: const InputDecoration(
hintText: 'Rechercher un cours...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _filteredCours.length,
itemBuilder: (_, i) {
final c = _filteredCours[i];
return Card(
child: ListTile(
leading: const Icon(Icons.menu_book, color: Colors.blue),
title: Text(c['titre'] ?? ''),
subtitle: Text('${c['matiere'] ?? ''} — Prof. ${c['enseignantNom'] ?? ''}'),
trailing: OutlinedButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Inscrit à "${c['titre']}"!')),
),
child: const Text("S'inscrire"),
),
),
);
},
),
),
]),
);
}
}
// File: lib/views/responsable/responsable_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/user_controller.dart';
import '../../models/user_model.dart';
class ResponsableDashboard extends StatefulWidget {
final UserModel currentUser;
const ResponsableDashboard({super.key, required this.currentUser});
@override
State<ResponsableDashboard> createState() => _ResponsableDashboardState();
}
class _ResponsableDashboardState extends State<ResponsableDashboard>
with SingleTickerProviderStateMixin {
final _userController = UserController();
final _authController = AuthController();
late TabController _tabController;
List<UserModel> _etudiants = [];
List<Map<String, dynamic>> _cours = [];
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadEtudiants();
_loadCours();
}
Future<void> _loadEtudiants() async {
final users = await _userController.getUsersByRole(UserRole.etudiant);
setState(() => _etudiants = users);
}
Future<void> _loadCours() async {
final cours = await _userController.getTousCours();
setState(() => _cours = cours);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.orange.shade700,
title: Text('Responsable — ${widget.currentUser.fullName}',
style: const TextStyle(color: Colors.white)),
actions: [
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
await _authController.signOut();
Navigator.of(context).pushReplacementNamed('/');
},
)
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
tabs: const [
Tab(icon: Icon(Icons.people), text: 'Étudiants'),
Tab(icon: Icon(Icons.menu_book), text: 'Tous les cours'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
// Onglet 1 : liste des étudiants
ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _etudiants.length,
itemBuilder: (_, i) {
final e = _etudiants[i];
return ListTile(
leading: CircleAvatar(child: Text(e.prenom[0])),
title: Text(e.fullName),
subtitle: Text(e.email),
);
},
),
// Onglet 2 : tous les cours (lecture seule)
ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _cours.length,
itemBuilder: (_, i) {
final c = _cours[i];
return ListTile(
leading: const Icon(Icons.menu_book, color: Colors.orange),
title: Text(c['titre'] ?? ''),
subtitle: Text('${c['matiere'] ?? ''} — Prof. ${c['enseignantNom'] ?? ''}'),
);
},
),
],
),
);
}
}
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Helper : récupérer le rôle de l'utilisateur connecté
function getUserRole() {
return get(/databases/$(database)/documents/
users/$(request.auth.uid)).data.role;
}
function isAdmin() { return request.auth != null && getUserRole() == 'admin'; }
function isEnseignant() { return request.auth != null && getUserRole() == 'enseignant'; }
function isAuth() { return request.auth != null; }
// ── Collection users ──────────────────────────────────────
match /users/{userId} {
allow read: if isAuth() && (isAdmin() || request.auth.uid == userId);
allow write: if isAdmin();
allow create: if isAdmin();
allow delete: if isAdmin();
}
// ── Collection cours ──────────────────────────────────────
match /cours/{coursId} {
allow read: if isAuth();
allow create: if isEnseignant()
&& request.resource.data.enseignantId == request.auth.uid;
allow update, delete:
if isAdmin()
|| (isEnseignant()
&& resource.data.enseignantId == request.auth.uid);
}
}
}
matieres/
{matiereId}/
nom: "Mathématiques"
coefficient: 3
createdBy: "uid_responsable"
groupes/
{groupeId}/
nom: "G1"
niveau: "L2"
etudiants: ["uid1", "uid2", ...] ← liste des UIDs des étudiants
affectations/
{affectationId}/
enseignantId: "uid_enseignant"
enseignantNom: "Jean Dupont"
matiereId: "id_matiere"
matiereNom: "Mathématiques"
groupeId: "id_groupe"
groupeNom: "G1 — L2"
notes/
{noteId}/
etudiantId: "uid_etudiant"
etudiantNom: "Ali Ben Salem"
matiereId: "id_matiere"
matiereNom: "Mathématiques"
coefficient: 3
enseignantId: "uid_enseignant"
groupeId: "id_groupe"
note: 15.5
semestre: "S1"
createdAt: Timestamp
lib/
├── models/
│ ├── user_model.dart ← inchangé
│ ├── matiere_model.dart ← 🆕 Modèle matière
│ ├── groupe_model.dart ← 🆕 Modèle groupe
│ ├── affectation_model.dart ← 🆕 Modèle affectation
│ └── note_model.dart ← 🆕 Modèle note
│
├── controllers/
│ ├── auth_controller.dart ← inchangé
│ ├── user_controller.dart ← inchangé
│ └── scolarite_controller.dart ← 🆕 Matières, groupes, affectations, notes
│
└── views/
├── login_view.dart ← inchangé
├── admin/
│ └── admin_dashboard.dart ← 🔄 mis à jour (+ onglet affectations)
├── enseignant/
│ └── enseignant_dashboard.dart ← 🔄 mis à jour (affectations + saisie notes)
├── etudiant/
│ └── etudiant_dashboard.dart ← 🔄 mis à jour (profil + bulletin)
└── responsable/
└── responsable_dashboard.dart ← 🔄 mis à jour (matières, groupes, affectations)
// File: lib/models/matiere_model.dart
class MatiereModel {
final String id;
final String nom;
final double coefficient;
final String createdBy; // uid du responsable
MatiereModel({
required this.id,
required this.nom,
required this.coefficient,
required this.createdBy,
});
factory MatiereModel.fromMap(String id, Map<String, dynamic> map) {
return MatiereModel(
id: id,
nom: map['nom'] ?? '',
coefficient: (map['coefficient'] ?? 1).toDouble(),
createdBy: map['createdBy'] ?? '',
);
}
Map<String, dynamic> toMap() => {
'nom': nom,
'coefficient': coefficient,
'createdBy': createdBy,
};
}
// File: lib/models/groupe_model.dart
class GroupeModel {
final String id;
final String nom;
final String niveau;
final List<String> etudiants; // liste des UIDs
GroupeModel({
required this.id,
required this.nom,
required this.niveau,
required this.etudiants,
});
String get displayName => '$nom — $niveau';
factory GroupeModel.fromMap(String id, Map<String, dynamic> map) {
return GroupeModel(
id: id,
nom: map['nom'] ?? '',
niveau: map['niveau'] ?? '',
etudiants: List<String>.from(map['etudiants'] ?? []),
);
}
Map<String, dynamic> toMap() => {
'nom': nom,
'niveau': niveau,
'etudiants': etudiants,
};
}
// File: lib/models/affectation_model.dart
class AffectationModel {
final String id;
final String enseignantId;
final String enseignantNom;
final String matiereId;
final String matiereNom;
final String groupeId;
final String groupeNom;
final DateTime createdAt;
AffectationModel({
required this.id, required this.enseignantId,
required this.enseignantNom, required this.matiereId,
required this.matiereNom, required this.groupeId,
required this.groupeNom, required this.createdAt,
});
factory AffectationModel.fromMap(String id, Map<String, dynamic> map) {
return AffectationModel(
id: id,
enseignantId: map['enseignantId'] ?? '',
enseignantNom: map['enseignantNom'] ?? '',
matiereId: map['matiereId'] ?? '',
matiereNom: map['matiereNom'] ?? '',
groupeId: map['groupeId'] ?? '',
groupeNom: map['groupeNom'] ?? '',
createdAt: DateTime.now(),
);
}
Map<String, dynamic> toMap() => {
'enseignantId': enseignantId, 'enseignantNom': enseignantNom,
'matiereId': matiereId, 'matiereNom': matiereNom,
'groupeId': groupeId, 'groupeNom': groupeNom,
'createdAt': createdAt,
};
}
// File: lib/models/note_model.dart
class NoteModel {
final String id;
final String etudiantId;
final String etudiantNom;
final String matiereId;
final String matiereNom;
final double coefficient;
final String enseignantId;
final String groupeId;
final double note;
final String semestre; // "S1" ou "S2"
final DateTime createdAt;
NoteModel({
required this.id, required this.etudiantId,
required this.etudiantNom, required this.matiereId,
required this.matiereNom, required this.coefficient,
required this.enseignantId, required this.groupeId,
required this.note, required this.semestre,
required this.createdAt,
});
factory NoteModel.fromMap(String id, Map<String, dynamic> map) {
return NoteModel(
id: id,
etudiantId: map['etudiantId'] ?? '',
etudiantNom: map['etudiantNom'] ?? '',
matiereId: map['matiereId'] ?? '',
matiereNom: map['matiereNom'] ?? '',
coefficient: (map['coefficient'] ?? 1).toDouble(),
enseignantId: map['enseignantId'] ?? '',
groupeId: map['groupeId'] ?? '',
note: (map['note'] ?? 0).toDouble(),
semestre: map['semestre'] ?? 'S1',
createdAt: DateTime.now(),
);
}
Map<String, dynamic> toMap() => {
'etudiantId': etudiantId, 'etudiantNom': etudiantNom,
'matiereId': matiereId, 'matiereNom': matiereNom,
'coefficient': coefficient, 'enseignantId':enseignantId,
'groupeId': groupeId, 'note': note,
'semestre': semestre, 'createdAt': createdAt,
};
}
// File: lib/controllers/scolarite_controller.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/matiere_model.dart';
import '../models/groupe_model.dart';
import '../models/affectation_model.dart';
import '../models/note_model.dart';
import '../models/user_model.dart';
class ScolariteController {
final FirebaseFirestore _db = FirebaseFirestore.instance;
// ── MATIÈRES (créées par le responsable) ─────────────────────
Future<void> createMatiere({
required String nom, required double coefficient, required String createdBy,
}) async {
await _db.collection('matieres').add({
'nom': nom, 'coefficient': coefficient,
'createdBy': createdBy, 'createdAt': FieldValue.serverTimestamp(),
});
}
Future<List<MatiereModel>> getMatieres() async {
final snap = await _db.collection('matieres').orderBy('nom').get();
return snap.docs.map((d) => MatiereModel.fromMap(d.id, d.data())).toList();
}
Future<void> deleteMatiere(String id) async =>
await _db.collection('matieres').doc(id).delete();
// ── GROUPES (créés par le responsable) ───────────────────────
Future<void> createGroupe({
required String nom, required String niveau,
}) async {
await _db.collection('groupes').add({
'nom': nom, 'niveau': niveau, 'etudiants': [],
'createdAt': FieldValue.serverTimestamp(),
});
}
Future<List<GroupeModel>> getGroupes() async {
final snap = await _db.collection('groupes').orderBy('niveau').get();
return snap.docs.map((d) => GroupeModel.fromMap(d.id, d.data())).toList();
}
/// Ajouter un étudiant à un groupe
Future<void> ajouterEtudiantGroupe({
required String groupeId, required String etudiantId,
}) async {
await _db.collection('groupes').doc(groupeId).update({
'etudiants': FieldValue.arrayUnion([etudiantId]),
});
}
/// Trouver le groupe d'un étudiant
Future<GroupeModel?> getGroupeEtudiant(String etudiantId) async {
final snap = await _db.collection('groupes')
.where('etudiants', arrayContains: etudiantId).limit(1).get();
if (snap.docs.isEmpty) return null;
return GroupeModel.fromMap(snap.docs.first.id, snap.docs.first.data());
}
/// Récupérer les étudiants d'un groupe
Future<List<UserModel>> getEtudiantsGroupe(GroupeModel groupe) async {
if (groupe.etudiants.isEmpty) return [];
final snap = await _db.collection('users')
.where(FieldPath.documentId, whereIn: groupe.etudiants).get();
return snap.docs.map((d) => UserModel.fromMap(d.id, d.data())).toList();
}
// ── AFFECTATIONS (admin ou responsable) ──────────────────────
Future<void> createAffectation({
required String enseignantId, required String enseignantNom,
required String matiereId, required String matiereNom,
required String groupeId, required String groupeNom,
}) async {
await _db.collection('affectations').add({
'enseignantId': enseignantId, 'enseignantNom': enseignantNom,
'matiereId': matiereId, 'matiereNom': matiereNom,
'groupeId': groupeId, 'groupeNom': groupeNom,
'createdAt': FieldValue.serverTimestamp(),
});
}
Future<List<AffectationModel>> getAffectations() async {
final snap = await _db.collection('affectations').get();
return snap.docs
.map((d) => AffectationModel.fromMap(d.id, d.data())).toList();
}
Future<List<AffectationModel>> getAffectationsEnseignant(String uid) async {
final snap = await _db.collection('affectations')
.where('enseignantId', isEqualTo: uid).get();
return snap.docs
.map((d) => AffectationModel.fromMap(d.id, d.data())).toList();
}
Future<void> deleteAffectation(String id) async =>
await _db.collection('affectations').doc(id).delete();
// ── NOTES (saisies par les enseignants) ──────────────────────
Future<void> saisirNote({
required String etudiantId, required String etudiantNom,
required String matiereId, required String matiereNom,
required double coefficient, required String enseignantId,
required String groupeId, required double note,
required String semestre,
}) async {
// Vérifier si une note existe déjà pour cet étudiant/matière/semestre
final existing = await _db.collection('notes')
.where('etudiantId', isEqualTo: etudiantId)
.where('matiereId', isEqualTo: matiereId)
.where('semestre', isEqualTo: semestre)
.limit(1).get();
if (existing.docs.isNotEmpty) {
// Mettre à jour la note existante
await _db.collection('notes').doc(existing.docs.first.id)
.update({'note': note, 'updatedAt': FieldValue.serverTimestamp()});
} else {
// Créer une nouvelle note
await _db.collection('notes').add({
'etudiantId': etudiantId, 'etudiantNom': etudiantNom,
'matiereId': matiereId, 'matiereNom': matiereNom,
'coefficient': coefficient, 'enseignantId': enseignantId,
'groupeId': groupeId, 'note': note,
'semestre': semestre, 'createdAt': FieldValue.serverTimestamp(),
});
}
}
Future<List<NoteModel>> getNotesEtudiant(String etudiantId) async {
final snap = await _db.collection('notes')
.where('etudiantId', isEqualTo: etudiantId).get();
return snap.docs.map((d) => NoteModel.fromMap(d.id, d.data())).toList();
}
Future<List<NoteModel>> getNotesGroupe({
required String groupeId, required String matiereId, required String semestre,
}) async {
final snap = await _db.collection('notes')
.where('groupeId', isEqualTo: groupeId)
.where('matiereId', isEqualTo: matiereId)
.where('semestre', isEqualTo: semestre).get();
return snap.docs.map((d) => NoteModel.fromMap(d.id, d.data())).toList();
}
/// Calcul de la moyenne pondérée pour un semestre
double calculerMoyenne(List<NoteModel> notes, String semestre) {
final notesSemestre = notes.where((n) => n.semestre == semestre).toList();
if (notesSemestre.isEmpty) return 0;
double totalPoints = 0, totalCoeff = 0;
for (final n in notesSemestre) {
totalPoints += n.note * n.coefficient;
totalCoeff += n.coefficient;
}
return totalCoeff == 0 ? 0 : totalPoints / totalCoeff;
}
}
// File: lib/views/responsable/responsable_dashboard.dart (extrait)
// Chargement parallèle de toutes les données
Future<void> _loadAll() async {
final results = await Future.wait([
_scolariteCtrl.getMatieres(),
_scolariteCtrl.getGroupes(),
_scolariteCtrl.getAffectations(),
_userCtrl.getUsersByRole(UserRole.etudiant),
_userCtrl.getUsersByRole(UserRole.enseignant),
]);
setState(() {
_matieres = results[0] as List<MatiereModel>;
_groupes = results[1] as List<GroupeModel>;
_affectations = results[2] as List<AffectationModel>;
_etudiants = results[3] as List<UserModel>;
_enseignants = results[4] as List<UserModel>;
});
}
// Création d'une matière via dialog
Future<void> _dialogMatiere() async {
// dialog avec champs nom + coefficient
await _scolariteCtrl.createMatiere(
nom: nomCtrl.text.trim(),
coefficient: double.tryParse(coeffCtrl.text) ?? 1,
createdBy: widget.currentUser.uid,
);
}
// Ajout d'un étudiant dans un groupe
Future<void> _ajouterEtudiant(GroupeModel groupe, UserModel etudiant) async {
await _scolariteCtrl.ajouterEtudiantGroupe(
groupeId: groupe.id, etudiantId: etudiant.uid,
);
}
// File: lib/views/enseignant/enseignant_dashboard.dart (extrait)
// File: lib/views/enseignant/enseignant_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/scolarite_controller.dart';
import '../../models/user_model.dart';
import '../../models/affectation_model.dart';
import '../../models/note_model.dart';
import '../login_view.dart';
/// 🟢 DASHBOARD ENSEIGNANT
/// Fonctionnalités: Voir ses affectations, saisir les notes par groupe/matière
class EnseignantDashboard extends StatefulWidget {
final UserModel currentUser;
const EnseignantDashboard({super.key, required this.currentUser});
@override
State createState() => _EnseignantDashboardState();
}
class _EnseignantDashboardState extends State
with SingleTickerProviderStateMixin {
final _scolariteCtrl = ScolariteController();
final _authController = AuthController();
late TabController _tabController;
List _affectations = [];
bool _loading = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadAffectations();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future _loadAffectations() async {
setState(() => _loading = true);
try {
final aff = await _scolariteCtrl
.getAffectationsEnseignant(widget.currentUser.uid);
setState(() => _affectations = aff);
} finally {
setState(() => _loading = false);
}
}
void _logout() async {
await _authController.signOut();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginView()),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF3F4F6),
appBar: AppBar(
backgroundColor: Colors.green.shade700,
foregroundColor: Colors.white,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Espace Enseignant',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
Text('Prof. ${widget.currentUser.fullName}',
style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
),
leading: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(Icons.cast_for_education, color: Colors.white),
),
actions: [
IconButton(icon: const Icon(Icons.logout_rounded), onPressed: _logout),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
tabs: const [
Tab(icon: Icon(Icons.link), text: 'Mes affectations'),
Tab(icon: Icon(Icons.edit_note), text: 'Saisir notes'),
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
_buildAffectations(),
_buildSaisieNotes(),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 1 : MES AFFECTATIONS
// ════════════════════════════════════════════════
Widget _buildAffectations() {
if (_affectations.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.link_off, size: 64, color: Colors.grey.shade300),
const SizedBox(height: 12),
const Text('Aucune affectation pour le moment',
style: TextStyle(color: Colors.grey, fontSize: 16)),
const SizedBox(height: 8),
const Text('Le responsable doit vous affecter à une matière',
style: TextStyle(color: Colors.grey, fontSize: 13)),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadAffectations,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _affectations.length,
itemBuilder: (_, i) {
final a = _affectations[i];
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8, offset: const Offset(0, 3))],
),
child: Row(
children: [
Container(
width: 50, height: 50,
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.book, color: Colors.green.shade700, size: 26),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(a.matiereNom,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 15)),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.group, size: 14, color: Colors.grey.shade600),
const SizedBox(width: 4),
Text(a.groupeNom,
style: const TextStyle(
color: Colors.grey, fontSize: 12)),
],
),
],
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade700,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
onPressed: () {
_tabController.animateTo(1);
},
child: const Text('Notes', style: TextStyle(fontSize: 12)),
),
],
),
);
},
),
);
}
// ════════════════════════════════════════════════
// ONGLET 2 : SAISIE DES NOTES
// ════════════════════════════════════════════════
AffectationModel? _selectedAffectation;
String _selectedSemestre = 'S1';
List _etudiants = [];
List _notesExistantes = [];
final Map _noteControllers = {};
bool _loadingEtudiants = false;
bool _saving = false;
Widget _buildSaisieNotes() {
return Column(
children: [
// Sélection affectation + semestre
Container(
padding: const EdgeInsets.all(16),
color: Colors.green.shade50,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Choisir la matière et le groupe',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
DropdownButtonFormField(
decoration: InputDecoration(
filled: true, fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
hint: const Text('Matière — Groupe'),
value: _selectedAffectation,
items: _affectations.map((a) => DropdownMenuItem(
value: a,
child: Text('${a.matiereNom} — ${a.groupeNom}'),
)).toList(),
onChanged: (v) {
setState(() => _selectedAffectation = v);
if (v != null) _loadEtudiants(v);
},
),
const SizedBox(height: 10),
Row(
children: ['S1', 'S2'].map((s) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(s),
selected: _selectedSemestre == s,
selectedColor: Colors.green.shade700,
labelStyle: TextStyle(
color: _selectedSemestre == s
? Colors.white : Colors.black,
),
onSelected: (_) {
setState(() => _selectedSemestre = s);
if (_selectedAffectation != null) {
_loadEtudiants(_selectedAffectation!);
}
},
),
)).toList(),
),
],
),
),
// Liste des étudiants avec champs de notes
Expanded(
child: _loadingEtudiants
? const Center(child: CircularProgressIndicator())
: _selectedAffectation == null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.touch_app,
size: 64, color: Colors.grey.shade300),
const SizedBox(height: 12),
const Text('Sélectionnez une matière et un groupe',
style: TextStyle(color: Colors.grey)),
],
),
)
: _etudiants.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline,
size: 64, color: Colors.grey.shade300),
const SizedBox(height: 12),
const Text('Aucun étudiant dans ce groupe',
style: TextStyle(color: Colors.grey)),
],
),
)
: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _etudiants.length,
itemBuilder: (_, i) {
final e = _etudiants[i];
_noteControllers.putIfAbsent(
e.uid,
() {
final existing = _notesExistantes
.where((n) => n.etudiantId == e.uid)
.firstOrNull;
return TextEditingController(
text: existing != null
? existing.note.toString()
: '',
);
},
);
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 6,
offset: const Offset(0, 2))],
),
child: Row(
children: [
CircleAvatar(
backgroundColor:
Colors.green.shade100,
child: Text(e.prenom[0],
style: TextStyle(
color: Colors.green.shade700,
fontWeight:
FontWeight.bold)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(e.fullName,
style: const TextStyle(
fontWeight:
FontWeight.w600)),
Text(e.email,
style: const TextStyle(
color: Colors.grey,
fontSize: 11)),
],
),
),
SizedBox(
width: 80,
child: TextField(
controller:
_noteControllers[e.uid],
keyboardType:
const TextInputType
.numberWithOptions(
decimal: true),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '/20',
filled: true,
fillColor:
Colors.green.shade50,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors
.green.shade300),
),
contentPadding:
const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8),
),
),
),
],
),
);
},
),
),
// Bouton enregistrer
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
icon: _saving
? const SizedBox(
width: 18, height: 18,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2))
: const Icon(Icons.save),
label: Text(_saving
? 'Enregistrement...'
: 'Enregistrer toutes les notes'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade700,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(12)),
),
onPressed: _saving ? null : _saveNotes,
),
),
),
],
),
),
],
);
}
Future _loadEtudiants(AffectationModel affectation) async {
setState(() { _loadingEtudiants = true; _noteControllers.clear(); });
try {
final groupe = await _scolariteCtrl.getGroupes().then(
(list) => list.firstWhere((g) => g.id == affectation.groupeId),
);
final etudiants = await _scolariteCtrl.getEtudiantsGroupe(groupe);
final notes = await _scolariteCtrl.getNotesGroupe(
groupeId: affectation.groupeId,
matiereId: affectation.matiereId,
semestre: _selectedSemestre,
);
setState(() {
_etudiants = etudiants;
_notesExistantes = notes;
});
} finally {
setState(() => _loadingEtudiants = false);
}
}
Future _saveNotes() async {
if (_selectedAffectation == null) return;
setState(() => _saving = true);
try {
for (final etudiant in _etudiants) {
final ctrl = _noteControllers[etudiant.uid];
if (ctrl == null || ctrl.text.isEmpty) continue;
final noteVal = double.tryParse(ctrl.text);
if (noteVal == null || noteVal < 0 || noteVal > 20) continue;
await _scolariteCtrl.saisirNote(
etudiantId: etudiant.uid,
etudiantNom: etudiant.fullName,
matiereId: _selectedAffectation!.matiereId,
matiereNom: _selectedAffectation!.matiereNom,
coefficient: 1, // récupérable depuis matiere si besoin
enseignantId:widget.currentUser.uid,
groupeId: _selectedAffectation!.groupeId,
note: noteVal,
semestre: _selectedSemestre,
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('✅ Notes enregistrées avec succès !'),
backgroundColor: Colors.green.shade700,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'),
backgroundColor: Colors.red),
);
}
} finally {
setState(() => _saving = false);
}
}
}
// File: lib/views/etudiant/etudiant_dashboard.dart (extrait)
// Chargement des notes et du groupe de l'étudiant connecté
// File: lib/views/etudiant/etudiant_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/scolarite_controller.dart';
import '../../models/user_model.dart';
import '../../models/note_model.dart';
import '../../models/groupe_model.dart';
import '../login_view.dart';
/// 🔵 DASHBOARD ÉTUDIANT
/// Fonctionnalités: Voir son groupe, son bulletin par semestre
class EtudiantDashboard extends StatefulWidget {
final UserModel currentUser;
const EtudiantDashboard({super.key, required this.currentUser});
@override
State createState() => _EtudiantDashboardState();
}
class _EtudiantDashboardState extends State
with SingleTickerProviderStateMixin {
final _scolariteCtrl = ScolariteController();
final _authController = AuthController();
late TabController _tabController;
List _notes = [];
GroupeModel? _groupe;
bool _loading = true;
String _semestre = 'S1';
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadData();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future _loadData() async {
setState(() => _loading = true);
try {
final results = await Future.wait([
_scolariteCtrl.getNotesEtudiant(widget.currentUser.uid),
_scolariteCtrl.getGroupeEtudiant(widget.currentUser.uid),
]);
setState(() {
_notes = results[0] as List;
_groupe = results[1] as GroupeModel?;
});
} finally {
setState(() => _loading = false);
}
}
void _logout() async {
await _authController.signOut();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginView()),
);
}
List get _notesSemestre =>
_notes.where((n) => n.semestre == _semestre).toList();
double get _moyenne =>
_scolariteCtrl.calculerMoyenne(_notes, _semestre);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF3F4F6),
appBar: AppBar(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Espace Étudiant',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
Text(widget.currentUser.fullName,
style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
),
leading: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(Icons.school, color: Colors.white),
),
actions: [
IconButton(icon: const Icon(Icons.logout_rounded), onPressed: _logout),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
tabs: const [
Tab(icon: Icon(Icons.info_outline), text: 'Mon profil'),
Tab(icon: Icon(Icons.grade_outlined), text: 'Mon bulletin'),
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
_buildProfil(),
_buildBulletin(),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 1 : MON PROFIL
// ════════════════════════════════════════════════
Widget _buildProfil() {
return RefreshIndicator(
onRefresh: _loadData,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
// Carte identité
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade700, Colors.blue.shade500],
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
CircleAvatar(
radius: 32,
backgroundColor: Colors.white.withOpacity(0.2),
child: Text(
widget.currentUser.prenom[0],
style: const TextStyle(
fontSize: 28, color: Colors.white,
fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.currentUser.fullName,
style: const TextStyle(
color: Colors.white, fontSize: 20,
fontWeight: FontWeight.bold)),
Text(widget.currentUser.email,
style: const TextStyle(
color: Colors.white70, fontSize: 13)),
],
),
),
],
),
),
const SizedBox(height: 20),
// Groupe
_infoTile(
icon: Icons.group,
color: Colors.deepPurple,
label: 'Groupe',
value: _groupe != null
? '${_groupe!.nom} — ${_groupe!.niveau}'
: 'Non affecté à un groupe',
),
_infoTile(
icon: Icons.school,
color: Colors.blue,
label: 'Statut',
value: 'Étudiant',
),
_infoTile(
icon: Icons.grade,
color: Colors.orange,
label: 'Notes enregistrées',
value: '${_notes.length} note(s)',
),
],
),
);
}
Widget _infoTile({
required IconData icon,
required Color color,
required String label,
required String value,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6, offset: const Offset(0, 2))],
),
child: Row(
children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
color: Colors.grey, fontSize: 12)),
Text(value,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14)),
],
),
),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 2 : BULLETIN
// ════════════════════════════════════════════════
Widget _buildBulletin() {
return Column(
children: [
// Sélection semestre + carte moyenne
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade700,
),
child: Column(
children: [
// Toggle semestre
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: ['S1', 'S2'].map((s) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(s,
style: TextStyle(
color: _semestre == s
? Colors.blue.shade700
: Colors.white,
fontWeight: FontWeight.bold,
)),
selected: _semestre == s,
selectedColor: Colors.white,
backgroundColor: Colors.blue.shade500,
onSelected: (_) =>
setState(() => _semestre = s),
),
)).toList(),
),
const SizedBox(height: 12),
// Carte moyenne
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(
children: [
Text(
_moyenne.toStringAsFixed(2),
style: TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
const Text('Moyenne générale',
style: TextStyle(
color: Colors.white70, fontSize: 12)),
],
),
Container(
width: 1, height: 50,
color: Colors.white30,
),
Column(
children: [
Text(
'${_notesSemestre.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold),
),
const Text('Matières notées',
style: TextStyle(
color: Colors.white70, fontSize: 12)),
],
),
Container(
width: 1, height: 50,
color: Colors.white30,
),
Column(
children: [
Text(
_moyenne >= 10 ? '✅' : '❌',
style: const TextStyle(fontSize: 30),
),
Text(
_moyenne >= 10 ? 'Admis' : 'Ajourné',
style: const TextStyle(
color: Colors.white70, fontSize: 12),
),
],
),
],
),
),
],
),
),
// Liste des notes
Expanded(
child: _notesSemestre.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.grade_outlined,
size: 64, color: Colors.grey.shade300),
const SizedBox(height: 12),
Text(
'Aucune note pour le semestre $_semestre',
style: const TextStyle(
color: Colors.grey, fontSize: 16),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _notesSemestre.length,
itemBuilder: (_, i) {
final n = _notesSemestre[i];
final noteColor = n.note >= 10
? Colors.green.shade700
: n.note >= 8
? Colors.orange.shade700
: Colors.red.shade700;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2))],
),
child: Row(
children: [
Container(
width: 50, height: 50,
decoration: BoxDecoration(
color: noteColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
n.note.toStringAsFixed(1),
style: TextStyle(
color: noteColor,
fontWeight: FontWeight.bold,
fontSize: 16),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(n.matiereNom,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14)),
Text('Coeff. ${n.coefficient}',
style: const TextStyle(
color: Colors.grey,
fontSize: 12)),
],
),
),
// Barre de progression
SizedBox(
width: 80,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${n.note}/20',
style: TextStyle(
color: noteColor,
fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
LinearProgressIndicator(
value: n.note / 20,
backgroundColor: Colors.grey.shade200,
valueColor:
AlwaysStoppedAnimation(noteColor),
borderRadius: BorderRadius.circular(4),
),
],
),
),
],
),
);
},
),
),
),
],
);
}
}
// File: lib/views/admin/admin_dashboard.dart (extrait)
// File: lib/views/admin/admin_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/user_controller.dart';
import '../../controllers/scolarite_controller.dart';
import '../../models/user_model.dart';
import '../../models/affectation_model.dart';
import '../../models/matiere_model.dart';
import '../../models/groupe_model.dart';
import '../login_view.dart';
/// 🔴 DASHBOARD ADMINISTRATEUR
/// Fonctionnalités: Créer comptes, voir utilisateurs, affecter enseignants
class AdminDashboard extends StatefulWidget {
final UserModel currentUser;
const AdminDashboard({super.key, required this.currentUser});
@override
State createState() => _AdminDashboardState();
}
class _AdminDashboardState extends State
with SingleTickerProviderStateMixin {
final _userController = UserController();
final _authController = AuthController();
final _scolariteCtrl = ScolariteController();
late TabController _tabController;
List _users = [];
List _affectations = [];
List _matieres = [];
List _groupes = [];
List _enseignants = [];
bool _loadingUsers = true;
// Formulaire création compte
final _formKey = GlobalKey();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _nomCtrl = TextEditingController();
final _prenomCtrl = TextEditingController();
UserRole _selectedRole = UserRole.etudiant;
bool _creating = false;
String? _createError;
String? _createSuccess;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_loadAll();
}
@override
void dispose() {
_tabController.dispose();
_emailCtrl.dispose();
_passwordCtrl.dispose();
_nomCtrl.dispose();
_prenomCtrl.dispose();
super.dispose();
}
Future _loadAll() async {
setState(() => _loadingUsers = true);
try {
final results = await Future.wait([
_userController.getAllUsers(),
_scolariteCtrl.getAffectations(),
_scolariteCtrl.getMatieres(),
_scolariteCtrl.getGroupes(),
_userController.getUsersByRole(UserRole.enseignant),
]);
setState(() {
_users = results[0] as List;
_affectations = results[1] as List;
_matieres = results[2] as List;
_groupes = results[3] as List;
_enseignants = results[4] as List;
});
} finally {
setState(() => _loadingUsers = false);
}
}
Future _createUser() async {
if (!_formKey.currentState!.validate()) return;
setState(() { _creating = true; _createError = null; _createSuccess = null; });
try {
await _userController.createUser(
email: _emailCtrl.text,
password: _passwordCtrl.text,
nom: _nomCtrl.text,
prenom: _prenomCtrl.text,
role: _selectedRole,
);
setState(() => _createSuccess =
'✅ Compte créé : ${_prenomCtrl.text} ${_nomCtrl.text} (${_selectedRole.displayName})');
_formKey.currentState!.reset();
_emailCtrl.clear(); _passwordCtrl.clear();
_nomCtrl.clear(); _prenomCtrl.clear();
_loadAll();
} catch (e) {
setState(() => _createError = e.toString());
} finally {
setState(() => _creating = false);
}
}
void _logout() async {
await _authController.signOut();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginView()),
);
}
Color _roleColor(UserRole role) {
switch (role) {
case UserRole.admin: return Colors.red;
case UserRole.enseignant: return Colors.green;
case UserRole.etudiant: return Colors.blue;
case UserRole.responsable_etudiant:return Colors.orange;
}
}
IconData _roleIcon(UserRole role) {
switch (role) {
case UserRole.admin: return Icons.admin_panel_settings;
case UserRole.enseignant: return Icons.cast_for_education;
case UserRole.etudiant: return Icons.school;
case UserRole.responsable_etudiant:return Icons.supervisor_account;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF3F4F6),
appBar: AppBar(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Dashboard Admin',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
Text('Bonjour, ${widget.currentUser.fullName}',
style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
),
leading: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(Icons.admin_panel_settings, color: Colors.white),
),
actions: [
IconButton(icon: const Icon(Icons.logout_rounded), onPressed: _logout),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
tabs: const [
Tab(icon: Icon(Icons.person_add), text: 'Créer compte'),
Tab(icon: Icon(Icons.people), text: 'Utilisateurs'),
Tab(icon: Icon(Icons.link), text: 'Affectations'),
],
),
),
body: _loadingUsers
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
_buildCreateUserTab(),
_buildUsersListTab(),
_buildAffectationsTab(),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 1 : CRÉER UN COMPTE
// ════════════════════════════════════════════════
Widget _buildCreateUserTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Colors.red),
SizedBox(width: 10),
Expanded(
child: Text(
'En tant qu\'admin, vous créez tous les comptes avec email, mot de passe et rôle.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 10, offset: const Offset(0, 4))],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Nouveau compte',
style: TextStyle(fontWeight: FontWeight.bold,
fontSize: 18, color: Color(0xFF1A237E))),
const SizedBox(height: 20),
Row(
children: [
Expanded(child: _field(_prenomCtrl, 'Prénom', Icons.person_outline)),
const SizedBox(width: 12),
Expanded(child: _field(_nomCtrl, 'Nom', Icons.person)),
],
),
const SizedBox(height: 14),
_field(_emailCtrl, 'Email', Icons.email_outlined,
keyboardType: TextInputType.emailAddress),
const SizedBox(height: 14),
_field(_passwordCtrl, 'Mot de passe', Icons.lock_outline,
obscure: true, hint: 'Min. 6 caractères'),
const SizedBox(height: 14),
const Text('Rôle attribué',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 8),
Wrap(
spacing: 8, runSpacing: 8,
children: UserRole.values.map((role) {
final selected = _selectedRole == role;
return GestureDetector(
onTap: () => setState(() => _selectedRole = role),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: selected
? _roleColor(role).withOpacity(0.15)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected ? _roleColor(role) : Colors.grey.shade300,
width: selected ? 2 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_roleIcon(role),
color: selected ? _roleColor(role) : Colors.grey,
size: 18),
const SizedBox(width: 6),
Text(role.displayName,
style: TextStyle(
color: selected ? _roleColor(role) : Colors.grey.shade700,
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
fontSize: 13,
)),
],
),
),
);
}).toList(),
),
const SizedBox(height: 20),
if (_createError != null) _feedbackBox(_createError!, isError: true),
if (_createSuccess != null) _feedbackBox(_createSuccess!, isError: false),
const SizedBox(height: 12),
SizedBox(
width: double.infinity, height: 50,
child: ElevatedButton.icon(
onPressed: _creating ? null : _createUser,
icon: _creating
? const SizedBox(width: 18, height: 18,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2))
: const Icon(Icons.person_add),
label: Text(_creating ? 'Création...' : 'Créer le compte'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 2 : LISTE DES UTILISATEURS
// ════════════════════════════════════════════════
Widget _buildUsersListTab() {
final stats = {};
for (final u in _users) {
stats[u.role] = (stats[u.role] ?? 0) + 1;
}
return RefreshIndicator(
onRefresh: _loadAll,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: UserRole.values.map((role) => Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: _roleColor(role).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _roleColor(role).withOpacity(0.3)),
),
child: Column(
children: [
Icon(_roleIcon(role), color: _roleColor(role), size: 20),
const SizedBox(height: 4),
Text('${stats[role] ?? 0}',
style: TextStyle(fontWeight: FontWeight.bold,
fontSize: 18, color: _roleColor(role))),
Text(
role == UserRole.responsable_etudiant ? 'Resp.' : role.displayName,
style: const TextStyle(fontSize: 9, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
)).toList(),
),
const SizedBox(height: 16),
..._users.map((user) => Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6, offset: const Offset(0, 2))],
),
child: Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: _roleColor(user.role).withOpacity(0.15),
shape: BoxShape.circle,
),
child: Icon(_roleIcon(user.role),
color: _roleColor(user.role), size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.fullName,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14)),
Text(user.email,
style: const TextStyle(
color: Colors.grey, fontSize: 12)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _roleColor(user.role).withOpacity(0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text(user.role.displayName,
style: TextStyle(
color: _roleColor(user.role),
fontSize: 11,
fontWeight: FontWeight.w600)),
),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
await _userController.deleteUserFromFirestore(user.uid);
_loadAll();
},
),
],
),
)),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 3 : AFFECTATIONS (admin aussi)
// ════════════════════════════════════════════════
Widget _buildAffectationsTab() {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Nouvelle affectation'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
onPressed: () => _dialogAffectation(),
),
),
Expanded(
child: _affectations.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.link_off, size: 64, color: Colors.grey.shade300),
const SizedBox(height: 12),
const Text('Aucune affectation',
style: TextStyle(color: Colors.grey)),
],
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _affectations.length,
itemBuilder: (_, i) {
final a = _affectations[i];
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6, offset: const Offset(0, 2))],
),
child: Row(
children: [
Container(
width: 42, height: 42,
decoration: BoxDecoration(
color: Colors.teal.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.link,
color: Colors.teal, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${a.matiereNom} → ${a.enseignantNom}',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14)),
Text('Groupe : ${a.groupeNom}',
style: const TextStyle(
color: Colors.grey, fontSize: 12)),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline,
color: Colors.red),
onPressed: () async {
await _scolariteCtrl.deleteAffectation(a.id);
_loadAll();
},
),
],
),
);
},
),
),
],
);
}
Future _dialogAffectation() async {
UserModel? selectedEnseignant;
MatiereModel? selectedMatiere;
GroupeModel? selectedGroupe;
await showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setStateDialog) => AlertDialog(
title: const Text('Nouvelle affectation'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Enseignant',
style: TextStyle(fontWeight: FontWeight.bold)),
DropdownButton(
isExpanded: true,
hint: const Text('Choisir un enseignant'),
value: selectedEnseignant,
items: _enseignants.map((e) => DropdownMenuItem(
value: e, child: Text(e.fullName),
)).toList(),
onChanged: (v) =>
setStateDialog(() => selectedEnseignant = v),
),
const SizedBox(height: 12),
const Text('Matière',
style: TextStyle(fontWeight: FontWeight.bold)),
DropdownButton(
isExpanded: true,
hint: const Text('Choisir une matière'),
value: selectedMatiere,
items: _matieres.map((m) => DropdownMenuItem(
value: m, child: Text(m.nom),
)).toList(),
onChanged: (v) =>
setStateDialog(() => selectedMatiere = v),
),
const SizedBox(height: 12),
const Text('Groupe',
style: TextStyle(fontWeight: FontWeight.bold)),
DropdownButton(
isExpanded: true,
hint: const Text('Choisir un groupe'),
value: selectedGroupe,
items: _groupes.map((g) => DropdownMenuItem(
value: g, child: Text(g.displayName),
)).toList(),
onChanged: (v) =>
setStateDialog(() => selectedGroupe = v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700),
onPressed: () async {
if (selectedEnseignant == null ||
selectedMatiere == null ||
selectedGroupe == null) return;
await _scolariteCtrl.createAffectation(
enseignantId: selectedEnseignant!.uid,
enseignantNom: selectedEnseignant!.fullName,
matiereId: selectedMatiere!.id,
matiereNom: selectedMatiere!.nom,
groupeId: selectedGroupe!.id,
groupeNom: selectedGroupe!.displayName,
);
if (ctx.mounted) Navigator.pop(ctx);
_loadAll();
},
child: const Text('Affecter',
style: TextStyle(color: Colors.white)),
),
],
),
),
);
}
// ════════════════════════════════════════════════
// WIDGETS HELPERS
// ════════════════════════════════════════════════
TextFormField _field(
TextEditingController ctrl, String label, IconData icon, {
bool obscure = false, String? hint, TextInputType? keyboardType,
}) {
return TextFormField(
controller: ctrl,
obscureText: obscure,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label, hintText: hint,
prefixIcon: Icon(icon, size: 20),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
),
validator: (v) {
if (v == null || v.isEmpty) return '$label requis';
if (label == 'Email' && !v.contains('@')) return 'Email invalide';
if (label == 'Mot de passe' && v.length < 6) return 'Min. 6 caractères';
return null;
},
);
}
Widget _feedbackBox(String msg, {required bool isError}) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isError ? Colors.red.shade50 : Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isError ? Colors.red.shade200 : Colors.green.shade200),
),
child: Text(msg,
style: TextStyle(
color: isError ? Colors.red : Colors.green, fontSize: 13)),
);
}
}
// Ajoutez cet import en haut de chaque dashboard
import '../login_view.dart';
// ✅ Méthode _logout() correcte dans chaque dashboard
void _logout() async {
await _authCtrl.signOut();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginView()),
);
}
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// ════════════════════════════════════════════════════════════
// 🔧 FONCTIONS HELPER — réutilisées dans toutes les règles
// ════════════════════════════════════════════════════════════
// Lit le champ "role" du document Firestore de l'utilisateur connecté
// Exemple : si uid = "abc123", lit users/abc123 → data.role
function getUserRole() {
return get(/databases/$(database)/documents/
users/$(request.auth.uid)).data.role;
}
// Vérifie que l'utilisateur est connecté ET que son rôle est "admin"
function isAdmin() {
return request.auth != null && getUserRole() == 'admin';
}
// Vérifie que l'utilisateur est connecté ET que son rôle est "enseignant"
function isEnseignant() {
return request.auth != null && getUserRole() == 'enseignant';
}
// Vérifie que l'utilisateur est connecté ET que son rôle est "responsable_etudiant"
function isResponsable() {
return request.auth != null && getUserRole() == 'responsable_etudiant';
}
// Vérifie simplement que l'utilisateur est connecté (peu importe le rôle)
function isAuth() {
return request.auth != null;
}
// ════════════════════════════════════════════════════════════
// 👤 COLLECTION : users
// Contient les profils et rôles de tous les utilisateurs
// ════════════════════════════════════════════════════════════
match /users/{userId} {
// Lecture autorisée si :
// - l'utilisateur est admin (peut voir tous les profils)
// - OU l'utilisateur lit son propre document (request.auth.uid == userId)
allow read: if isAuth() && (isAdmin() || request.auth.uid == userId);
// Écriture (create, update, delete) : admin uniquement
// Seul l'admin peut créer, modifier ou supprimer des comptes
allow write: if isAdmin();
}
// ════════════════════════════════════════════════════════════
// 📚 COLLECTION : matieres
// Contient les matières créées par le responsable étudiant
// ════════════════════════════════════════════════════════════
match /matieres/{id} {
// Lecture : tout utilisateur connecté peut voir les matières
// (enseignants, étudiants, responsable, admin)
allow read: if isAuth();
// Écriture : uniquement le responsable étudiant ou l'admin
// Les enseignants et étudiants ne peuvent PAS modifier les matières
allow write: if isAdmin() || isResponsable();
}
// ════════════════════════════════════════════════════════════
// 👥 COLLECTION : groupes
// Contient les groupes d'étudiants (nom, niveau, liste UIDs)
// ════════════════════════════════════════════════════════════
match /groupes/{id} {
// Lecture : tout utilisateur connecté peut voir les groupes
// (nécessaire pour l'enseignant qui charge son groupe)
allow read: if isAuth();
// Écriture : uniquement le responsable ou l'admin
// Cela inclut l'ajout/retrait d'un étudiant dans un groupe
allow write: if isAdmin() || isResponsable();
}
// ════════════════════════════════════════════════════════════
// 🔗 COLLECTION : affectations
// Lie un enseignant à une matière et un groupe
// ════════════════════════════════════════════════════════════
match /affectations/{id} {
// Lecture : tout utilisateur connecté
// L'enseignant doit pouvoir lire ses propres affectations
allow read: if isAuth();
// Écriture : admin ou responsable étudiant
// Les deux peuvent créer ou supprimer des affectations
allow write: if isAdmin() || isResponsable();
}
// ════════════════════════════════════════════════════════════
// COLLECTION : notes
// Contient les notes saisies par les enseignants
// ════════════════════════════════════════════════════════════
match /notes/{id} {
// Lecture selon le rôle :
// - Admin → voit toutes les notes
// - Responsable → voit toutes les notes (supervision)
// - Enseignant → voit uniquement les notes qu'il a saisies
// - Étudiant → voit UNIQUEMENT ses propres notes (etudiantId == uid)
allow read: if isAdmin()
|| isResponsable()
|| (isEnseignant()
&& resource.data.enseignantId == request.auth.uid)
|| (isAuth()
&& resource.data.etudiantId == request.auth.uid);
// Écriture : enseignant ou admin uniquement
// Un étudiant ne peut PAS modifier ses propres notes
// Un responsable ne peut PAS saisir de notes
allow write: if isAdmin() || isEnseignant();
}
}
}
// Restriction avancée : l'étudiant ne voit que ses propres notes
match /notes/{id} {
allow read: if isAdmin() || isEnseignant() || isResponsable()
|| (isAuth() && resource.data.etudiantId == request.auth.uid);
allow write: if isAdmin() || isEnseignant();
}
// File: lib/views/responsable/responsable_dashboard.dart
import 'package:flutter/material.dart';
import '../../controllers/auth_controller.dart';
import '../../controllers/user_controller.dart';
import '../../controllers/scolarite_controller.dart';
import '../../models/user_model.dart';
import '../../models/matiere_model.dart';
import '../../models/groupe_model.dart';
import '../../models/affectation_model.dart';
import '../login_view.dart';
/// 🟠 DASHBOARD RESPONSABLE ÉTUDIANT
/// Fonctionnalités: Matières, Groupes, Affectations, Étudiants
class ResponsableDashboard extends StatefulWidget {
final UserModel currentUser;
const ResponsableDashboard({super.key, required this.currentUser});
@override
State createState() => _ResponsableDashboardState();
}
class _ResponsableDashboardState extends State
with SingleTickerProviderStateMixin {
final _scolariteCtrl = ScolariteController();
final _userCtrl = UserController();
final _authController = AuthController();
late TabController _tabController;
List _matieres = [];
List _groupes = [];
List_affectations = [];
List _etudiants = [];
List _enseignants = [];
bool _loading = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_loadAll();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future _loadAll() async {
setState(() => _loading = true);
try {
final results = await Future.wait([
_scolariteCtrl.getMatieres(),
_scolariteCtrl.getGroupes(),
_scolariteCtrl.getAffectations(),
_userCtrl.getUsersByRole(UserRole.etudiant),
_userCtrl.getUsersByRole(UserRole.enseignant),
]);
setState(() {
_matieres = results[0] as List;
_groupes = results[1] as List;
_affectations = results[2] as List;
_etudiants = results[3] as List;
_enseignants = results[4] as List;
});
} finally {
setState(() => _loading = false);
}
}
void _logout() async {
await _authController.signOut();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginView()),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF3F4F6),
appBar: AppBar(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Responsable Étudiant',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
Text(widget.currentUser.fullName,
style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
),
leading: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(Icons.supervisor_account, color: Colors.white),
),
actions: [
IconButton(icon: const Icon(Icons.logout_rounded), onPressed: _logout),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
isScrollable: true,
tabs: const [
Tab(icon: Icon(Icons.book), text: 'Matières'),
Tab(icon: Icon(Icons.group), text: 'Groupes'),
Tab(icon: Icon(Icons.link), text: 'Affectations'),
Tab(icon: Icon(Icons.people), text: 'Étudiants'),
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
_buildMatieres(),
_buildGroupes(),
_buildAffectations(),
_buildEtudiants(),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 1 : MATIÈRES
// ════════════════════════════════════════════════
Widget _buildMatieres() {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Ajouter une matière'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () => _dialogMatiere(),
),
),
Expanded(
child: _matieres.isEmpty
? _empty('Aucune matière créée', Icons.book_outlined)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _matieres.length,
itemBuilder: (_, i) {
final m = _matieres[i];
return _card(
icon: Icons.book,
color: Colors.orange,
title: m.nom,
subtitle: 'Coefficient : ${m.coefficient}',
onDelete: () async {
await _scolariteCtrl.deleteMatiere(m.id);
_loadAll();
},
);
},
),
),
],
);
}
Future _dialogMatiere() async {
final nomCtrl = TextEditingController();
final coeffCtrl = TextEditingController(text: '1');
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Nouvelle matière'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nomCtrl,
decoration: const InputDecoration(labelText: 'Nom de la matière')),
TextField(controller: coeffCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Coefficient')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange.shade700),
onPressed: () async {
if (nomCtrl.text.isEmpty) return;
await _scolariteCtrl.createMatiere(
nom: nomCtrl.text.trim(),
coefficient: double.tryParse(coeffCtrl.text) ?? 1,
createdBy: widget.currentUser.uid,
);
if (ctx.mounted) Navigator.pop(ctx);
_loadAll();
},
child: const Text('Créer', style: TextStyle(color: Colors.white)),
),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 2 : GROUPES
// ════════════════════════════════════════════════
Widget _buildGroupes() {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Créer un groupe'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () => _dialogGroupe(),
),
),
Expanded(
child: _groupes.isEmpty
? _empty('Aucun groupe créé', Icons.group_outlined)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _groupes.length,
itemBuilder: (_, i) {
final g = _groupes[i];
return _card(
icon: Icons.group,
color: Colors.deepPurple,
title: g.nom,
subtitle: 'Niveau : ${g.niveau} — ${g.etudiants.length} étudiant(s)',
trailing: IconButton(
icon: const Icon(Icons.person_add, color: Colors.deepPurple),
tooltip: 'Affecter étudiant',
onPressed: () => _dialogAffecterEtudiant(g),
),
onDelete: () async {
await _scolariteCtrl.deleteGroupe(g.id);
_loadAll();
},
);
},
),
),
],
);
}
Future _dialogGroupe() async {
final nomCtrl = TextEditingController();
final niveauCtrl = TextEditingController();
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Nouveau groupe'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nomCtrl,
decoration: const InputDecoration(labelText: 'Nom du groupe (ex: G1)')),
TextField(controller: niveauCtrl,
decoration: const InputDecoration(labelText: 'Niveau (ex: L2, M1)')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange.shade700),
onPressed: () async {
if (nomCtrl.text.isEmpty || niveauCtrl.text.isEmpty) return;
await _scolariteCtrl.createGroupe(
nom: nomCtrl.text.trim(),
niveau: niveauCtrl.text.trim(),
);
if (ctx.mounted) Navigator.pop(ctx);
_loadAll();
},
child: const Text('Créer', style: TextStyle(color: Colors.white)),
),
],
),
);
}
Future _dialogAffecterEtudiant(GroupeModel groupe) async {
// Étudiants pas encore dans ce groupe
final disponibles = _etudiants
.where((e) => !groupe.etudiants.contains(e.uid))
.toList();
if (disponibles.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tous les étudiants sont déjà dans un groupe')),
);
return;
}
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Affecter au groupe ${groupe.nom}'),
content: SizedBox(
width: 300,
height: 300,
child: ListView.builder(
itemCount: disponibles.length,
itemBuilder: (_, i) {
final e = disponibles[i];
return ListTile(
leading: CircleAvatar(child: Text(e.prenom[0])),
title: Text(e.fullName),
subtitle: Text(e.email),
onTap: () async {
await _scolariteCtrl.ajouterEtudiantGroupe(
groupeId: groupe.id,
etudiantId: e.uid,
);
if (ctx.mounted) Navigator.pop(ctx);
_loadAll();
},
);
},
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Fermer')),
],
),
);
}
// ════════════════════════════════════════════════
// ONGLET 3 : AFFECTATIONS
// ════════════════════════════════════════════════
Widget _buildAffectations() {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Nouvelle affectation'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () => _dialogAffectation(),
),
),
Expanded(
child: _affectations.isEmpty
? _empty('Aucune affectation', Icons.link_off)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _affectations.length,
itemBuilder: (_, i) {
final a = _affectations[i];
return _card(
icon: Icons.link,
color: Colors.teal,
title: '${a.matiereNom} → ${a.enseignantNom}',
subtitle: 'Groupe : ${a.groupeNom}',
onDelete: () async {
await _scolariteCtrl.deleteAffectation(a.id);
_loadAll();
},
);
},
),
),
],
);
}
Future _dialogAffectation() async {
UserModel? selectedEnseignant;
MatiereModel? selectedMatiere;
GroupeModel? selectedGroupe;
await showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setStateDialog) => AlertDialog(
title: const Text('Nouvelle affectation'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Enseignant', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownButton(
isExpanded: true,
hint: const Text('Choisir un enseignant'),
value: selectedEnseignant,
items: _enseignants.map((e) => DropdownMenuItem(
value: e,
child: Text(e.fullName),
)).toList(),
onChanged: (v) => setStateDialog(() => selectedEnseignant = v),
),
const SizedBox(height: 12),
const Text('Matière', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownButton(
isExpanded: true,
hint: const Text('Choisir une matière'),
value: selectedMatiere,
items: _matieres.map((m) => DropdownMenuItem(
value: m,
child: Text(m.nom),
)).toList(),
onChanged: (v) => setStateDialog(() => selectedMatiere = v),
),
const SizedBox(height: 12),
const Text('Groupe', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownButton(
isExpanded: true,
hint: const Text('Choisir un groupe'),
value: selectedGroupe,
items: _groupes.map((g) => DropdownMenuItem(
value: g,
child: Text(g.displayName),
)).toList(),
onChanged: (v) => setStateDialog(() => selectedGroupe = v),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange.shade700),
onPressed: () async {
if (selectedEnseignant == null ||
selectedMatiere == null ||
selectedGroupe == null) return;
await _scolariteCtrl.createAffectation(
enseignantId: selectedEnseignant!.uid,
enseignantNom: selectedEnseignant!.fullName,
matiereId: selectedMatiere!.id,
matiereNom: selectedMatiere!.nom,
groupeId: selectedGroupe!.id,
groupeNom: selectedGroupe!.displayName,
);
if (ctx.mounted) Navigator.pop(ctx);
_loadAll();
},
child: const Text('Affecter', style: TextStyle(color: Colors.white)),
),
],
),
),
);
}
// ════════════════════════════════════════════════
// ONGLET 4 : ÉTUDIANTS
// ════════════════════════════════════════════════
Widget _buildEtudiants() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Résumé
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.orange.shade700, Colors.orange.shade500],
),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
const Icon(Icons.people, color: Colors.white, size: 36),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${_etudiants.length}',
style: const TextStyle(
color: Colors.white, fontSize: 28,
fontWeight: FontWeight.bold)),
const Text('Étudiants inscrits',
style: TextStyle(color: Colors.white70)),
],
),
],
),
),
const SizedBox(height: 16),
..._etudiants.map((e) {
// Trouver son groupe
final groupe = _groupes.firstWhere(
(g) => g.etudiants.contains(e.uid),
orElse: () => GroupeModel(id: '', nom: 'Non affecté',
niveau: '', etudiants: []),
);
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6, offset: const Offset(0, 2))],
),
child: Row(
children: [
CircleAvatar(
backgroundColor: Colors.orange.shade100,
child: Text(e.prenom[0],
style: TextStyle(color: Colors.orange.shade700,
fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(e.fullName,
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(e.email,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: groupe.id.isEmpty
? Colors.grey.shade100
: Colors.deepPurple.shade50,
borderRadius: BorderRadius.circular(20),
),
child: Text(
groupe.id.isEmpty
? '⚠️ Non affecté'
: '${groupe.nom} — ${groupe.niveau}',
style: TextStyle(
fontSize: 11,
color: groupe.id.isEmpty
? Colors.grey
: Colors.deepPurple,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
);
}),
],
);
}
// ════════════════════════════════════════════════
// WIDGETS RÉUTILISABLES
// ════════════════════════════════════════════════
Widget _card({
required IconData icon,
required Color color,
required String title,
required String subtitle,
required VoidCallback onDelete,
Widget? trailing,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6, offset: const Offset(0, 2))],
),
child: Row(
children: [
Container(
width: 42, height: 42,
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14)),
Text(subtitle,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
if (trailing != null) trailing,
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: onDelete,
),
],
),
);
}
Widget _empty(String msg, IconData icon) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey.shade300),
const SizedBox(height: 12),
Text(msg, style: const TextStyle(color: Colors.grey, fontSize: 16)),
],
),
);
}
}
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false; // ← TOUT EST BLOQUÉ
}
}
}
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true; // ← TOUT EST OUVERT
}
}
}
