feat: Intégration d'un système complet d'alertes et de notifications par email
Cette mise à jour majeure introduit un système de notifications robuste, centré sur la création d'alertes et l'envoi d'emails via des Cloud Functions. Elle inclut la gestion des préférences utilisateur, la création automatique d'alertes lors d'événements critiques et une nouvelle interface dédiée.
**Backend (Cloud Functions) :**
- **Nouveau service d'alerting (`createAlert`, `processEquipmentValidation`) :**
- `createAlert` : Nouvelle fonction pour créer une alerte. Elle détermine les utilisateurs à notifier (admins, workforce d'événement) et gère la persistance dans Firestore.
- `processEquipmentValidation` : Endpoint appelé lors de la validation du matériel (chargement/déchargement). Il analyse l'état de l'équipement (`LOST`, `MISSING`, `DAMAGED`) et crée automatiquement les alertes correspondantes.
- **Système d'envoi d'emails (`sendAlertEmail`, `sendDailyDigest`) :**
- `sendAlertEmail` : Cloud Function `onCall` pour envoyer un email d'alerte individuel. Elle respecte les préférences de notification de l'utilisateur (canal email, type d'alerte).
- `sendDailyDigest` : Tâche planifiée (tous les jours à 8h) qui envoie un email récapitulatif des alertes non lues des dernières 24 heures aux utilisateurs concernés.
- Ajout de templates HTML (`base-template`, `alert-individual`, `alert-digest`) avec `Handlebars` pour des emails riches.
- Configuration centralisée du SMTP via des variables d'environnement (`.env`).
- **Triggers Firestore (`onEventCreated`, `onEventUpdated`) :**
- Des triggers créent désormais des alertes d'information lorsqu'un événement est créé ou que de nouveaux membres sont ajoutés à la workforce.
- **Règles Firestore :**
- Mises à jour pour autoriser les utilisateurs authentifiés à créer et modifier leurs propres alertes (marquer comme lue, supprimer), tout en sécurisant les accès.
**Frontend (Flutter) :**
- **Nouvel `AlertService` et `EmailService` :**
- `AlertService` : Centralise la logique de création, lecture et gestion des alertes côté client en appelant les nouvelles Cloud Functions.
- `EmailService` : Service pour déclencher l'envoi d'emails via la fonction `sendAlertEmail`. Il contient la logique pour déterminer si une notification doit être immédiate (critique) ou différée (digest).
- **Nouvelle page de Notifications (`/alerts`) :**
- Interface dédiée pour lister toutes les alertes de l'utilisateur, avec des onglets pour filtrer par catégorie (Toutes, Événement, Maintenance, Équipement).
- Permet de marquer les alertes comme lues, de les supprimer et de tout marquer comme lu.
- **Intégration dans l'UI :**
- Ajout d'un badge de notification dans la `CustomAppBar` affichant le nombre d'alertes non lues en temps réel.
- Le `AutoLoginWrapper` gère désormais la redirection vers des routes profondes (ex: `/alerts`) depuis une URL.
- **Gestion des Préférences de Notification :**
- Ajout d'un widget `NotificationPreferencesWidget` dans la page "Mon Compte".
- Les utilisateurs peuvent désormais activer/désactiver les notifications par email, ainsi que filtrer par type d'alerte (événements, maintenance, etc.).
- Le `UserModel` et `LocalUserProvider` ont été étendus pour gérer ce nouveau modèle de préférences.
- **Création d'alertes contextuelles :**
- Le service `EventFormService` crée maintenant automatiquement une alerte lorsqu'un événement est créé ou modifié.
- La page de préparation d'événement (`EventPreparationPage`) appelle `processEquipmentValidation` à la fin de chaque étape pour une détection automatisée des anomalies.
**Dépendances et CI/CD :**
- Ajout des dépendances `cloud_functions` et `timeago` (Flutter), et `nodemailer`, `handlebars`, `dotenv` (Node.js).
- Ajout de scripts de déploiement PowerShell (`deploy_functions.ps1`, `deploy_firestore_rules.ps1`) pour simplifier les mises en production.
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||
import 'package:em2rp/providers/alert_provider.dart';
|
||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||
import 'package:em2rp/views/alerts_page.dart';
|
||||
import 'package:em2rp/views/calendar_page.dart';
|
||||
import 'package:em2rp/views/login_page.dart';
|
||||
import 'package:em2rp/views/equipment_management_page.dart';
|
||||
@@ -131,9 +132,11 @@ class MyApp extends StatelessWidget {
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: const AutoLoginWrapper(),
|
||||
initialRoute: '/',
|
||||
routes: {
|
||||
'/': (context) => const AutoLoginWrapper(),
|
||||
'/login': (context) => const LoginPage(),
|
||||
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
|
||||
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
|
||||
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
||||
'/user_management': (context) => const AuthGuard(
|
||||
@@ -214,7 +217,22 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
||||
await localAuthProvider.loadUserData();
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
||||
// En Flutter Web, on peut vérifier window.location.hash
|
||||
final currentUri = Uri.base;
|
||||
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
|
||||
|
||||
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
||||
|
||||
// Si une route spécifique est demandée (autre que / ou vide)
|
||||
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
||||
Navigator.of(context).pushReplacementNamed(fragment);
|
||||
} else {
|
||||
// Route par défaut : calendrier
|
||||
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Auto login failed: $e');
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
/// Type d'alerte
|
||||
enum AlertType {
|
||||
lowStock, // Stock faible
|
||||
maintenanceDue, // Maintenance à venir
|
||||
conflict, // Conflit disponibilité
|
||||
lost // Équipement perdu
|
||||
lowStock, // Stock faible
|
||||
maintenanceDue, // Maintenance à venir
|
||||
conflict, // Conflit disponibilité
|
||||
lost, // Équipement perdu
|
||||
eventCreated, // Événement créé
|
||||
eventModified, // Événement modifié
|
||||
eventCancelled, // Événement annulé
|
||||
eventAssigned, // Assigné à un événement
|
||||
maintenanceReminder, // Rappel maintenance périodique
|
||||
equipmentMissing, // Équipement manquant à une étape
|
||||
quantityMismatch, // Quantité incorrecte
|
||||
damaged, // Équipement endommagé
|
||||
workforceAdded, // Ajouté à la workforce d'un événement
|
||||
}
|
||||
|
||||
/// Gravité de l'alerte
|
||||
enum AlertSeverity {
|
||||
info, // Information (bleu)
|
||||
warning, // Avertissement (orange)
|
||||
critical, // Critique (rouge)
|
||||
}
|
||||
|
||||
String alertTypeToString(AlertType type) {
|
||||
@@ -17,6 +34,24 @@ String alertTypeToString(AlertType type) {
|
||||
return 'CONFLICT';
|
||||
case AlertType.lost:
|
||||
return 'LOST';
|
||||
case AlertType.eventCreated:
|
||||
return 'EVENT_CREATED';
|
||||
case AlertType.eventModified:
|
||||
return 'EVENT_MODIFIED';
|
||||
case AlertType.eventCancelled:
|
||||
return 'EVENT_CANCELLED';
|
||||
case AlertType.eventAssigned:
|
||||
return 'EVENT_ASSIGNED';
|
||||
case AlertType.maintenanceReminder:
|
||||
return 'MAINTENANCE_REMINDER';
|
||||
case AlertType.equipmentMissing:
|
||||
return 'EQUIPMENT_MISSING';
|
||||
case AlertType.quantityMismatch:
|
||||
return 'QUANTITY_MISMATCH';
|
||||
case AlertType.damaged:
|
||||
return 'DAMAGED';
|
||||
case AlertType.workforceAdded:
|
||||
return 'WORKFORCE_ADDED';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,26 +65,88 @@ AlertType alertTypeFromString(String? type) {
|
||||
return AlertType.conflict;
|
||||
case 'LOST':
|
||||
return AlertType.lost;
|
||||
case 'EVENT_CREATED':
|
||||
return AlertType.eventCreated;
|
||||
case 'EVENT_MODIFIED':
|
||||
return AlertType.eventModified;
|
||||
case 'EVENT_CANCELLED':
|
||||
return AlertType.eventCancelled;
|
||||
case 'EVENT_ASSIGNED':
|
||||
return AlertType.eventAssigned;
|
||||
case 'MAINTENANCE_REMINDER':
|
||||
return AlertType.maintenanceReminder;
|
||||
case 'EQUIPMENT_MISSING':
|
||||
return AlertType.equipmentMissing;
|
||||
case 'QUANTITY_MISMATCH':
|
||||
return AlertType.quantityMismatch;
|
||||
case 'DAMAGED':
|
||||
return AlertType.damaged;
|
||||
case 'WORKFORCE_ADDED':
|
||||
return AlertType.workforceAdded;
|
||||
default:
|
||||
return AlertType.conflict;
|
||||
}
|
||||
}
|
||||
|
||||
String alertSeverityToString(AlertSeverity severity) {
|
||||
switch (severity) {
|
||||
case AlertSeverity.info:
|
||||
return 'INFO';
|
||||
case AlertSeverity.warning:
|
||||
return 'WARNING';
|
||||
case AlertSeverity.critical:
|
||||
return 'CRITICAL';
|
||||
}
|
||||
}
|
||||
|
||||
AlertSeverity alertSeverityFromString(String? severity) {
|
||||
switch (severity) {
|
||||
case 'INFO':
|
||||
return AlertSeverity.info;
|
||||
case 'WARNING':
|
||||
return AlertSeverity.warning;
|
||||
case 'CRITICAL':
|
||||
return AlertSeverity.critical;
|
||||
default:
|
||||
return AlertSeverity.info;
|
||||
}
|
||||
}
|
||||
|
||||
class AlertModel {
|
||||
final String id; // ID généré automatiquement
|
||||
final AlertType type; // Type d'alerte
|
||||
final String message; // Message de l'alerte
|
||||
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
||||
final DateTime createdAt; // Date de création
|
||||
final bool isRead; // Statut lu/non lu
|
||||
final String id; // ID généré automatiquement
|
||||
final AlertType type; // Type d'alerte
|
||||
final AlertSeverity severity; // Gravité de l'alerte
|
||||
final String message; // Message de l'alerte
|
||||
final List<String> assignedToUserIds; // Utilisateurs concernés
|
||||
final String? eventId; // ID de l'événement concerné (optionnel)
|
||||
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
||||
final String? createdByUserId; // Qui a déclenché l'alerte
|
||||
final DateTime createdAt; // Date de création
|
||||
final DateTime? dueDate; // Date d'échéance (pour maintenance)
|
||||
final String? actionUrl; // URL de redirection (deep link)
|
||||
final bool isRead; // Statut lu/non lu
|
||||
final bool isResolved; // Résolue ou non
|
||||
final String? resolution; // Message de résolution
|
||||
final DateTime? resolvedAt; // Date de résolution
|
||||
final String? resolvedByUserId; // Qui a résolu
|
||||
|
||||
AlertModel({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.severity = AlertSeverity.info,
|
||||
required this.message,
|
||||
this.assignedToUserIds = const [],
|
||||
this.eventId,
|
||||
this.equipmentId,
|
||||
this.createdByUserId,
|
||||
required this.createdAt,
|
||||
this.dueDate,
|
||||
this.actionUrl,
|
||||
this.isRead = false,
|
||||
this.isResolved = false,
|
||||
this.resolution,
|
||||
this.resolvedAt,
|
||||
this.resolvedByUserId,
|
||||
});
|
||||
|
||||
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
@@ -61,42 +158,116 @@ class AlertModel {
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
// Parser les assignedToUserIds (peut être List ou null)
|
||||
List<String> parseUserIds(dynamic value) {
|
||||
if (value == null) return [];
|
||||
if (value is List) return value.map((e) => e.toString()).toList();
|
||||
return [];
|
||||
}
|
||||
|
||||
return AlertModel(
|
||||
id: id,
|
||||
type: alertTypeFromString(map['type']),
|
||||
severity: alertSeverityFromString(map['severity']),
|
||||
message: map['message'] ?? '',
|
||||
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
|
||||
eventId: map['eventId'],
|
||||
equipmentId: map['equipmentId'],
|
||||
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||
createdAt: _parseDate(map['createdAt']),
|
||||
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
||||
actionUrl: map['actionUrl'],
|
||||
isRead: map['isRead'] ?? false,
|
||||
isResolved: map['isResolved'] ?? false,
|
||||
resolution: map['resolution'],
|
||||
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
||||
resolvedByUserId: map['resolvedByUserId'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Factory depuis un document Firestore
|
||||
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Document vide: ${doc.id}');
|
||||
}
|
||||
return AlertModel.fromMap(data, doc.id);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'type': alertTypeToString(type),
|
||||
'severity': alertSeverityToString(severity),
|
||||
'message': message,
|
||||
'equipmentId': equipmentId,
|
||||
'assignedToUserIds': assignedToUserIds,
|
||||
if (eventId != null) 'eventId': eventId,
|
||||
if (equipmentId != null) 'equipmentId': equipmentId,
|
||||
if (createdByUserId != null) 'createdByUserId': createdByUserId,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
|
||||
if (actionUrl != null) 'actionUrl': actionUrl,
|
||||
'isRead': isRead,
|
||||
'isResolved': isResolved,
|
||||
if (resolution != null) 'resolution': resolution,
|
||||
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
|
||||
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
|
||||
};
|
||||
}
|
||||
|
||||
AlertModel copyWith({
|
||||
String? id,
|
||||
AlertType? type,
|
||||
AlertSeverity? severity,
|
||||
String? message,
|
||||
List<String>? assignedToUserIds,
|
||||
String? eventId,
|
||||
String? equipmentId,
|
||||
String? createdByUserId,
|
||||
DateTime? createdAt,
|
||||
DateTime? dueDate,
|
||||
String? actionUrl,
|
||||
bool? isRead,
|
||||
bool? isResolved,
|
||||
String? resolution,
|
||||
DateTime? resolvedAt,
|
||||
String? resolvedByUserId,
|
||||
}) {
|
||||
return AlertModel(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
severity: severity ?? this.severity,
|
||||
message: message ?? this.message,
|
||||
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
|
||||
eventId: eventId ?? this.eventId,
|
||||
equipmentId: equipmentId ?? this.equipmentId,
|
||||
createdByUserId: createdByUserId ?? this.createdByUserId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
actionUrl: actionUrl ?? this.actionUrl,
|
||||
isRead: isRead ?? this.isRead,
|
||||
isResolved: isResolved ?? this.isResolved,
|
||||
resolution: resolution ?? this.resolution,
|
||||
resolvedAt: resolvedAt ?? this.resolvedAt,
|
||||
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour un événement
|
||||
bool get isEventAlert =>
|
||||
type == AlertType.eventCreated ||
|
||||
type == AlertType.eventModified ||
|
||||
type == AlertType.eventCancelled ||
|
||||
type == AlertType.eventAssigned;
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour la maintenance
|
||||
bool get isMaintenanceAlert =>
|
||||
type == AlertType.maintenanceDue ||
|
||||
type == AlertType.maintenanceReminder;
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour un équipement
|
||||
bool get isEquipmentAlert =>
|
||||
type == AlertType.lost ||
|
||||
type == AlertType.equipmentMissing ||
|
||||
type == AlertType.lowStock;
|
||||
}
|
||||
|
||||
|
||||
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
/// Préférences de notifications pour un utilisateur
|
||||
class NotificationPreferences {
|
||||
final bool emailEnabled; // Recevoir emails
|
||||
final bool pushEnabled; // Recevoir notifications push
|
||||
final bool inAppEnabled; // Recevoir alertes in-app
|
||||
|
||||
// Préférences par type d'alerte
|
||||
final bool eventsNotifications; // Alertes événements
|
||||
final bool maintenanceNotifications; // Alertes maintenance
|
||||
final bool stockNotifications; // Alertes stock
|
||||
final bool equipmentNotifications; // Alertes équipement
|
||||
|
||||
// Token FCM (pour push)
|
||||
final String? fcmToken;
|
||||
|
||||
const NotificationPreferences({
|
||||
this.emailEnabled = true, // ✓ Activé par défaut
|
||||
this.pushEnabled = false,
|
||||
this.inAppEnabled = true,
|
||||
this.eventsNotifications = true,
|
||||
this.maintenanceNotifications = true,
|
||||
this.stockNotifications = true,
|
||||
this.equipmentNotifications = true,
|
||||
this.fcmToken,
|
||||
});
|
||||
|
||||
/// Valeurs par défaut pour un nouvel utilisateur
|
||||
factory NotificationPreferences.defaults() {
|
||||
return const NotificationPreferences(
|
||||
emailEnabled: true, // ✓ Activé par défaut
|
||||
pushEnabled: false,
|
||||
inAppEnabled: true,
|
||||
eventsNotifications: true,
|
||||
maintenanceNotifications: true,
|
||||
stockNotifications: true,
|
||||
equipmentNotifications: true,
|
||||
);
|
||||
}
|
||||
|
||||
factory NotificationPreferences.fromMap(Map<String, dynamic> map) {
|
||||
return NotificationPreferences(
|
||||
emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut
|
||||
pushEnabled: map['pushEnabled'] ?? false,
|
||||
inAppEnabled: map['inAppEnabled'] ?? true,
|
||||
eventsNotifications: map['eventsNotifications'] ?? true,
|
||||
maintenanceNotifications: map['maintenanceNotifications'] ?? true,
|
||||
stockNotifications: map['stockNotifications'] ?? true,
|
||||
equipmentNotifications: map['equipmentNotifications'] ?? true,
|
||||
fcmToken: map['fcmToken'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'emailEnabled': emailEnabled,
|
||||
'pushEnabled': pushEnabled,
|
||||
'inAppEnabled': inAppEnabled,
|
||||
'eventsNotifications': eventsNotifications,
|
||||
'maintenanceNotifications': maintenanceNotifications,
|
||||
'stockNotifications': stockNotifications,
|
||||
'equipmentNotifications': equipmentNotifications,
|
||||
if (fcmToken != null) 'fcmToken': fcmToken,
|
||||
};
|
||||
}
|
||||
|
||||
NotificationPreferences copyWith({
|
||||
bool? emailEnabled,
|
||||
bool? pushEnabled,
|
||||
bool? inAppEnabled,
|
||||
bool? eventsNotifications,
|
||||
bool? maintenanceNotifications,
|
||||
bool? stockNotifications,
|
||||
bool? equipmentNotifications,
|
||||
String? fcmToken,
|
||||
}) {
|
||||
return NotificationPreferences(
|
||||
emailEnabled: emailEnabled ?? this.emailEnabled,
|
||||
pushEnabled: pushEnabled ?? this.pushEnabled,
|
||||
inAppEnabled: inAppEnabled ?? this.inAppEnabled,
|
||||
eventsNotifications: eventsNotifications ?? this.eventsNotifications,
|
||||
maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications,
|
||||
stockNotifications: stockNotifications ?? this.stockNotifications,
|
||||
equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications,
|
||||
fcmToken: fcmToken ?? this.fcmToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/notification_preferences_model.dart';
|
||||
|
||||
class UserModel {
|
||||
final String uid;
|
||||
@@ -8,6 +9,7 @@ class UserModel {
|
||||
final String profilePhotoUrl;
|
||||
final String email;
|
||||
final String phoneNumber;
|
||||
final NotificationPreferences? notificationPreferences;
|
||||
|
||||
UserModel({
|
||||
required this.uid,
|
||||
@@ -17,6 +19,7 @@ class UserModel {
|
||||
required this.profilePhotoUrl,
|
||||
required this.email,
|
||||
required this.phoneNumber,
|
||||
this.notificationPreferences,
|
||||
});
|
||||
|
||||
// Convertit une Map (Firestore) en UserModel
|
||||
@@ -57,6 +60,9 @@ class UserModel {
|
||||
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
|
||||
email: data['email'] ?? '',
|
||||
phoneNumber: data['phoneNumber'] ?? '',
|
||||
notificationPreferences: data['notificationPreferences'] != null
|
||||
? NotificationPreferences.fromMap(data['notificationPreferences'] as Map<String, dynamic>)
|
||||
: NotificationPreferences.defaults(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +75,8 @@ class UserModel {
|
||||
'profilePhotoUrl': profilePhotoUrl,
|
||||
'email': email,
|
||||
'phoneNumber': phoneNumber,
|
||||
if (notificationPreferences != null)
|
||||
'notificationPreferences': notificationPreferences!.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +87,7 @@ class UserModel {
|
||||
String? profilePhotoUrl,
|
||||
String? email,
|
||||
String? phoneNumber,
|
||||
NotificationPreferences? notificationPreferences,
|
||||
}) {
|
||||
return UserModel(
|
||||
uid: uid, // L'UID ne change pas
|
||||
@@ -88,6 +97,7 @@ class UserModel {
|
||||
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
|
||||
email: email ?? this.email,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
notificationPreferences: notificationPreferences ?? this.notificationPreferences,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../models/role_model.dart';
|
||||
import '../models/notification_preferences_model.dart';
|
||||
import '../utils/firebase_storage_manager.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/data_service.dart';
|
||||
@@ -107,6 +108,25 @@ class LocalUserProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mise à jour des préférences de notifications
|
||||
Future<void> updateNotificationPreferences(NotificationPreferences preferences) async {
|
||||
if (_currentUser == null) return;
|
||||
try {
|
||||
await _dataService.updateUser(
|
||||
_currentUser!.uid,
|
||||
{
|
||||
'notificationPreferences': preferences.toMap(),
|
||||
},
|
||||
);
|
||||
|
||||
_currentUser = _currentUser!.copyWith(notificationPreferences: preferences);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur mise à jour préférences notifications : $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Changement de photo de profil
|
||||
Future<void> changeProfilePicture(XFile image) async {
|
||||
if (_currentUser == null) return;
|
||||
|
||||
255
em2rp/lib/services/alert_service.dart
Normal file
255
em2rp/lib/services/alert_service.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import '../models/alert_model.dart';
|
||||
import '../utils/debug_log.dart';
|
||||
import 'api_service.dart' show FirebaseFunctionsApiService;
|
||||
/// Service de gestion des alertes
|
||||
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions
|
||||
/// Toute la logique métier est gérée côté backend
|
||||
class AlertService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
|
||||
/// Stream des alertes pour l'utilisateur connecté
|
||||
Stream<List<AlertModel>> getAlertsStream() {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
DebugLog.info('[AlertService] Pas d\'utilisateur connecté');
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
DebugLog.info('[AlertService] Stream alertes pour utilisateur: ${user.uid}');
|
||||
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
final alerts = snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
|
||||
DebugLog.info('[AlertService] ${alerts.length} alertes actives');
|
||||
return alerts;
|
||||
});
|
||||
}
|
||||
|
||||
/// Récupère les alertes non lues
|
||||
Future<List<AlertModel>> getUnreadAlerts() async {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) return [];
|
||||
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.get();
|
||||
|
||||
return snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque une alerte comme lue
|
||||
Future<void> markAsRead(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).update({
|
||||
'isRead': true,
|
||||
'readAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
DebugLog.info('[AlertService] Alerte $alertId marquée comme lue');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur marquage alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque toutes les alertes comme lues
|
||||
Future<void> markAllAsRead() async {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.get();
|
||||
|
||||
final batch = _firestore.batch();
|
||||
for (var doc in snapshot.docs) {
|
||||
batch.update(doc.reference, {
|
||||
'isRead': true,
|
||||
'readAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
DebugLog.info('[AlertService] ${snapshot.docs.length} alertes marquées comme lues');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur marquage alertes', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive une alerte
|
||||
Future<void> archiveAlert(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).update({
|
||||
'status': 'ARCHIVED',
|
||||
'archivedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
DebugLog.info('[AlertService] Alerte $alertId archivée');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur archivage alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une alerte manuelle (appelée par l'utilisateur)
|
||||
/// Cette méthode appelle la Cloud Function createAlert
|
||||
Future<String> createManualAlert({
|
||||
required AlertType type,
|
||||
required AlertSeverity severity,
|
||||
required String message,
|
||||
String? title,
|
||||
String? equipmentId,
|
||||
String? eventId,
|
||||
String? actionUrl,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
DebugLog.info('[AlertService] === CRÉATION ALERTE MANUELLE ===');
|
||||
DebugLog.info('[AlertService] Type: $type');
|
||||
DebugLog.info('[AlertService] Severity: $severity');
|
||||
|
||||
final apiService = FirebaseFunctionsApiService();
|
||||
final result = await apiService.call(
|
||||
'createAlert',
|
||||
{
|
||||
'type': alertTypeToString(type),
|
||||
'severity': severity.name.toUpperCase(),
|
||||
'title': title,
|
||||
'message': message,
|
||||
'equipmentId': equipmentId,
|
||||
'eventId': eventId,
|
||||
'actionUrl': actionUrl,
|
||||
'metadata': metadata ?? {},
|
||||
},
|
||||
);
|
||||
|
||||
final alertId = result['alertId'] as String;
|
||||
DebugLog.info('[AlertService] ✓ Alerte créée: $alertId');
|
||||
|
||||
return alertId;
|
||||
} catch (e, stackTrace) {
|
||||
DebugLog.error('[AlertService] ❌ Erreur création alerte', e);
|
||||
DebugLog.error('[AlertService] Stack', stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream des alertes pour un utilisateur spécifique
|
||||
Stream<List<AlertModel>> alertsStreamForUser(String userId) {
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) => snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList());
|
||||
}
|
||||
|
||||
/// Récupère les alertes pour un utilisateur
|
||||
Future<List<AlertModel>> getAlertsForUser(String userId) async {
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.get();
|
||||
|
||||
return snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream du nombre d'alertes non lues pour un utilisateur
|
||||
Stream<int> unreadCountStreamForUser(String userId) {
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.snapshots()
|
||||
.map((snapshot) => snapshot.docs.length);
|
||||
}
|
||||
|
||||
/// Supprime une alerte
|
||||
Future<void> deleteAlert(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).delete();
|
||||
DebugLog.info('[AlertService] Alerte $alertId supprimée');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur suppression alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une alerte de création d'événement
|
||||
Future<void> createEventCreatedAlert({
|
||||
required String eventId,
|
||||
required String eventName,
|
||||
required DateTime eventDate,
|
||||
}) async {
|
||||
await createManualAlert(
|
||||
type: AlertType.eventCreated,
|
||||
severity: AlertSeverity.info,
|
||||
message: 'Nouvel événement créé: "$eventName" le ${_formatDate(eventDate)}',
|
||||
eventId: eventId,
|
||||
metadata: {
|
||||
'eventName': eventName,
|
||||
'eventDate': eventDate.toIso8601String(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une alerte de modification d'événement
|
||||
Future<void> createEventModifiedAlert({
|
||||
required String eventId,
|
||||
required String eventName,
|
||||
required String modification,
|
||||
}) async {
|
||||
await createManualAlert(
|
||||
type: AlertType.eventModified,
|
||||
severity: AlertSeverity.info,
|
||||
message: 'Événement "$eventName" modifié: $modification',
|
||||
eventId: eventId,
|
||||
metadata: {
|
||||
'eventName': eventName,
|
||||
'modification': modification,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
150
em2rp/lib/services/email_service.dart
Normal file
150
em2rp/lib/services/email_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:cloud_functions/cloud_functions.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
/// Service d'envoi d'emails via Cloud Functions
|
||||
class EmailService {
|
||||
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1');
|
||||
|
||||
/// Envoie un email d'alerte à un utilisateur
|
||||
///
|
||||
/// [alert] : L'alerte à envoyer
|
||||
/// [userId] : ID de l'utilisateur destinataire
|
||||
/// [templateType] : Type de template à utiliser (par défaut: 'alert-individual')
|
||||
Future<bool> sendAlertEmail({
|
||||
required AlertModel alert,
|
||||
required String userId,
|
||||
String templateType = 'alert-individual',
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier que l'utilisateur est authentifié
|
||||
final currentUser = FirebaseAuth.instance.currentUser;
|
||||
if (currentUser == null) {
|
||||
DebugLog.error('[EmailService] Utilisateur non authentifié');
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugLog.info('[EmailService] Envoi email alerte ${alert.id} à $userId');
|
||||
|
||||
final result = await _functions.httpsCallable('sendAlertEmail').call({
|
||||
'alertId': alert.id,
|
||||
'userId': userId,
|
||||
'templateType': templateType,
|
||||
});
|
||||
|
||||
final data = result.data as Map<String, dynamic>;
|
||||
final success = data['success'] as bool? ?? false;
|
||||
final skipped = data['skipped'] as bool? ?? false;
|
||||
|
||||
if (skipped) {
|
||||
final reason = data['reason'] as String? ?? 'unknown';
|
||||
DebugLog.info('[EmailService] Email non envoyé: $reason');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
DebugLog.info('[EmailService] Email envoyé avec succès');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
DebugLog.error('[EmailService] Erreur envoi email', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un email d'alerte à plusieurs utilisateurs
|
||||
///
|
||||
/// [alert] : L'alerte à envoyer
|
||||
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||
Future<Map<String, bool>> sendAlertEmailToMultipleUsers({
|
||||
required AlertModel alert,
|
||||
required List<String> userIds,
|
||||
String templateType = 'alert-individual',
|
||||
}) async {
|
||||
final results = <String, bool>{};
|
||||
|
||||
DebugLog.info('[EmailService] Envoi emails à ${userIds.length} utilisateurs');
|
||||
|
||||
// Envoyer en parallèle (max 5 à la fois pour éviter surcharge)
|
||||
final batches = <List<String>>[];
|
||||
for (var i = 0; i < userIds.length; i += 5) {
|
||||
batches.add(userIds.sublist(
|
||||
i,
|
||||
i + 5 > userIds.length ? userIds.length : i + 5,
|
||||
));
|
||||
}
|
||||
|
||||
for (final batch in batches) {
|
||||
final futures = batch.map((userId) => sendAlertEmail(
|
||||
alert: alert,
|
||||
userId: userId,
|
||||
templateType: templateType,
|
||||
));
|
||||
|
||||
final batchResults = await Future.wait(futures);
|
||||
|
||||
for (var i = 0; i < batch.length; i++) {
|
||||
results[batch[i]] = batchResults[i];
|
||||
}
|
||||
}
|
||||
|
||||
final successCount = results.values.where((v) => v).length;
|
||||
DebugLog.info('[EmailService] $successCount/${ userIds.length} emails envoyés');
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Détermine si une alerte doit être envoyée immédiatement ou en digest
|
||||
///
|
||||
/// [alert] : L'alerte à vérifier
|
||||
/// Returns: true si immédiat, false si digest
|
||||
bool shouldSendImmediate(AlertModel alert) {
|
||||
// Les alertes critiques sont envoyées immédiatement
|
||||
if (alert.severity == AlertSeverity.critical) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Types d'alertes toujours immédiates
|
||||
const immediateTypes = [
|
||||
AlertType.lost, // Équipement perdu
|
||||
AlertType.eventCancelled, // Événement annulé
|
||||
];
|
||||
|
||||
return immediateTypes.contains(alert.type);
|
||||
}
|
||||
|
||||
/// Envoie un email d'alerte en tenant compte des préférences
|
||||
///
|
||||
/// [alert] : L'alerte à envoyer
|
||||
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||
Future<void> sendAlertWithPreferences({
|
||||
required AlertModel alert,
|
||||
required List<String> userIds,
|
||||
}) async {
|
||||
if (userIds.isEmpty) {
|
||||
DebugLog.warning('[EmailService] Aucun utilisateur à notifier');
|
||||
return;
|
||||
}
|
||||
|
||||
final immediate = shouldSendImmediate(alert);
|
||||
|
||||
if (immediate) {
|
||||
DebugLog.info('[EmailService] Envoi immédiat (alerte critique)');
|
||||
await sendAlertEmailToMultipleUsers(
|
||||
alert: alert,
|
||||
userIds: userIds,
|
||||
templateType: 'alert-individual',
|
||||
);
|
||||
} else {
|
||||
DebugLog.info('[EmailService] Ajout au digest (alerte non critique)');
|
||||
// Les alertes non critiques seront envoyées dans le digest quotidien
|
||||
// La Cloud Function sendDailyDigest s'en occupera
|
||||
// Rien à faire ici, les alertes sont déjà dans Firestore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:em2rp/models/event_type_model.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/alert_service.dart';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
class EventFormService {
|
||||
@@ -109,7 +110,24 @@ class EventFormService {
|
||||
static Future<String> createEvent(EventModel event) async {
|
||||
try {
|
||||
final result = await _apiService.call('createEvent', event.toMap());
|
||||
return result['id'] as String;
|
||||
final eventId = result['id'] as String;
|
||||
|
||||
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||
try {
|
||||
await AlertService().createEventCreatedAlert(
|
||||
eventId: eventId,
|
||||
eventName: event.name,
|
||||
eventDate: event.startDateTime,
|
||||
);
|
||||
developer.log('Alert created for new event: $eventId', name: 'EventFormService');
|
||||
} catch (alertError) {
|
||||
// Ne pas bloquer la création de l'événement si l'alerte échoue
|
||||
developer.log('Warning: Could not create alert for event',
|
||||
name: 'EventFormService',
|
||||
error: alertError);
|
||||
}
|
||||
|
||||
return eventId;
|
||||
} catch (e) {
|
||||
developer.log('Error creating event', name: 'EventFormService', error: e);
|
||||
rethrow;
|
||||
@@ -129,6 +147,24 @@ class EventFormService {
|
||||
await _apiService.call('updateEvent', eventData);
|
||||
|
||||
developer.log('Event updated successfully', name: 'EventFormService');
|
||||
|
||||
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||
try {
|
||||
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||
if (currentUserId != null) {
|
||||
await AlertService().createEventModifiedAlert(
|
||||
eventId: event.id,
|
||||
eventName: event.name,
|
||||
modification: 'Informations modifiées',
|
||||
);
|
||||
developer.log('Alert created for modified event: ${event.id}', name: 'EventFormService');
|
||||
}
|
||||
} catch (alertError) {
|
||||
// Ne pas bloquer la modification de l'événement si l'alerte échoue
|
||||
developer.log('Warning: Could not create alert for event modification',
|
||||
name: 'EventFormService',
|
||||
error: alertError);
|
||||
}
|
||||
} catch (e) {
|
||||
developer.log('Error updating event', name: 'EventFormService', error: e);
|
||||
rethrow;
|
||||
|
||||
@@ -17,14 +17,19 @@ class AuthGuard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
||||
|
||||
// Log pour débug
|
||||
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
|
||||
|
||||
// Si l'utilisateur n'est pas connecté
|
||||
if (localAuthProvider.currentUser == null) {
|
||||
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
|
||||
return const LoginPage();
|
||||
}
|
||||
|
||||
// Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas
|
||||
if (requiredPermission != null &&
|
||||
!localAuthProvider.hasPermission(requiredPermission!)) {
|
||||
print('[AuthGuard] Permission "$requiredPermission" refusée');
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Accès refusé")),
|
||||
body: const Center(
|
||||
@@ -34,6 +39,7 @@ class AuthGuard extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Sinon, afficher la page demandée
|
||||
print('[AuthGuard] Accès autorisé, affichage de la page');
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
296
em2rp/lib/views/alerts_page.dart
Normal file
296
em2rp/lib/views/alerts_page.dart
Normal file
@@ -0,0 +1,296 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
import 'package:em2rp/services/alert_service.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/views/widgets/alert_item.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Page listant toutes les alertes de l'utilisateur
|
||||
class AlertsPage extends StatefulWidget {
|
||||
const AlertsPage({super.key});
|
||||
|
||||
@override
|
||||
State<AlertsPage> createState() => _AlertsPageState();
|
||||
}
|
||||
|
||||
class _AlertsPageState extends State<AlertsPage> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final AlertService _alertService = AlertService();
|
||||
AlertType? _filter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
setState(() {
|
||||
_filter = _getFilterForTab(_tabController.index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
AlertType? _getFilterForTab(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return null; // Toutes
|
||||
case 1:
|
||||
return AlertType.eventCreated; // Événements (on filtrera manuellement)
|
||||
case 2:
|
||||
return AlertType.maintenanceDue; // Maintenance
|
||||
case 3:
|
||||
return AlertType.lost; // Équipement
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localUserProvider = context.watch<LocalUserProvider>();
|
||||
final userId = localUserProvider.currentUser?.uid;
|
||||
|
||||
if (userId == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notifications'),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text('Veuillez vous connecter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notifications'),
|
||||
backgroundColor: AppColors.rouge,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.done_all),
|
||||
onPressed: () => _markAllAsRead(userId),
|
||||
tooltip: 'Tout marquer comme lu',
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: Colors.white,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white70,
|
||||
tabs: const [
|
||||
Tab(text: 'Toutes'),
|
||||
Tab(text: 'Événements'),
|
||||
Tab(text: 'Maintenance'),
|
||||
Tab(text: 'Équipement'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _buildAlertsList(userId),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlertsList(String userId) {
|
||||
return StreamBuilder<List<AlertModel>>(
|
||||
stream: _alertService.alertsStreamForUser(userId),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
// Log détaillé de l'erreur
|
||||
print('[AlertsPage] ERREUR Stream: ${snapshot.error}');
|
||||
print('[AlertsPage] StackTrace: ${snapshot.stackTrace}');
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('Erreur de chargement des alertes'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
snapshot.error.toString(),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => setState(() {}),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allAlerts = snapshot.data ?? [];
|
||||
|
||||
// Filtrer selon l'onglet sélectionné
|
||||
final filteredAlerts = _filterAlerts(allAlerts);
|
||||
|
||||
if (filteredAlerts.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
setState(() {});
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: filteredAlerts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final alert = filteredAlerts[index];
|
||||
return AlertItem(
|
||||
alert: alert,
|
||||
onTap: () => _handleAlertTap(alert),
|
||||
onMarkAsRead: () => _markAsRead(alert.id),
|
||||
onDelete: () => _deleteAlert(alert.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<AlertModel> _filterAlerts(List<AlertModel> alerts) {
|
||||
if (_filter == null) {
|
||||
return alerts; // Toutes
|
||||
}
|
||||
|
||||
switch (_tabController.index) {
|
||||
case 1: // Événements
|
||||
return alerts.where((a) => a.isEventAlert).toList();
|
||||
case 2: // Maintenance
|
||||
return alerts.where((a) => a.isMaintenanceAlert).toList();
|
||||
case 3: // Équipement
|
||||
return alerts.where((a) => a.isEquipmentAlert).toList();
|
||||
default:
|
||||
return alerts;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
String message;
|
||||
IconData icon;
|
||||
|
||||
switch (_tabController.index) {
|
||||
case 1:
|
||||
message = 'Aucune alerte d\'événement';
|
||||
icon = Icons.event;
|
||||
break;
|
||||
case 2:
|
||||
message = 'Aucune alerte de maintenance';
|
||||
icon = Icons.build;
|
||||
break;
|
||||
case 3:
|
||||
message = 'Aucune alerte d\'équipement';
|
||||
icon = Icons.inventory_2;
|
||||
break;
|
||||
default:
|
||||
message = 'Aucune notification';
|
||||
icon = Icons.notifications_none;
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAlertTap(AlertModel alert) async {
|
||||
// Marquer comme lu si pas déjà lu
|
||||
if (!alert.isRead) {
|
||||
await _markAsRead(alert.id);
|
||||
}
|
||||
|
||||
// Redirection selon actionUrl (pour l'instant, juste rester sur la page)
|
||||
// TODO: Implémenter navigation vers événement/équipement si besoin
|
||||
}
|
||||
|
||||
Future<void> _markAsRead(String alertId) async {
|
||||
try {
|
||||
await _alertService.markAsRead(alertId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur : $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAlert(String alertId) async {
|
||||
try {
|
||||
await _alertService.deleteAlert(alertId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Alerte supprimée'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur : $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllAsRead(String userId) async {
|
||||
try {
|
||||
final alerts = await _alertService.getAlertsForUser(userId);
|
||||
for (final alert in alerts.where((a) => !a.isRead)) {
|
||||
await _alertService.markAsRead(alert.id);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Toutes les alertes ont été marquées comme lues'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur : $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cloud_functions/cloud_functions.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
@@ -321,38 +322,40 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// Si on est à la dernière étape (retour), vérifier les équipements LOST
|
||||
if (_currentStep == PreparationStep.return_) {
|
||||
await _checkAndMarkLostEquipment(updatedEquipment);
|
||||
}
|
||||
|
||||
// Mettre à jour Firestore selon l'étape
|
||||
final updateData = <String, dynamic>{
|
||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
||||
};
|
||||
|
||||
// Ajouter les statuts selon l'étape et la checkbox
|
||||
String validationType = 'CHECK';
|
||||
switch (_currentStep) {
|
||||
case PreparationStep.preparation:
|
||||
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed);
|
||||
validationType = 'CHECK_OUT';
|
||||
if (_loadSimultaneously) {
|
||||
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
||||
validationType = 'LOADING';
|
||||
}
|
||||
break;
|
||||
|
||||
case PreparationStep.loadingOutbound:
|
||||
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
||||
validationType = 'LOADING';
|
||||
break;
|
||||
|
||||
case PreparationStep.unloadingReturn:
|
||||
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed);
|
||||
validationType = 'UNLOADING';
|
||||
if (_loadSimultaneously) {
|
||||
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
||||
validationType = 'CHECK_IN';
|
||||
}
|
||||
break;
|
||||
|
||||
case PreparationStep.return_:
|
||||
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
||||
validationType = 'CHECK_IN';
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -372,6 +375,41 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
await _updateEquipmentStatuses(updatedEquipment);
|
||||
}
|
||||
|
||||
// NOUVEAU: Appeler la Cloud Function pour traiter la validation
|
||||
// et créer les alertes automatiquement
|
||||
try {
|
||||
DebugLog.info('[EventPreparationPage] Appel processEquipmentValidation');
|
||||
|
||||
final equipmentList = updatedEquipment.map((eq) {
|
||||
final equipment = _equipmentCache[eq.equipmentId];
|
||||
return {
|
||||
'equipmentId': eq.equipmentId,
|
||||
'name': equipment?.name ?? 'Équipement inconnu',
|
||||
'status': _determineEquipmentStatus(eq),
|
||||
'quantity': _getQuantityForStep(eq),
|
||||
'expectedQuantity': eq.quantity,
|
||||
'isMissingAtPreparation': eq.isMissingAtPreparation,
|
||||
'isMissingAtReturn': eq.isMissingAtReturn,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final result = await FirebaseFunctions.instanceFor(region: 'us-central1')
|
||||
.httpsCallable('processEquipmentValidation')
|
||||
.call({
|
||||
'eventId': _currentEvent.id,
|
||||
'equipmentList': equipmentList,
|
||||
'validationType': validationType,
|
||||
});
|
||||
|
||||
final alertsCreated = result.data['alertsCreated'] ?? 0;
|
||||
if (alertsCreated > 0) {
|
||||
DebugLog.info('[EventPreparationPage] $alertsCreated alertes créées automatiquement');
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EventPreparationPage] Erreur appel processEquipmentValidation', e);
|
||||
// Ne pas bloquer la validation si les alertes échouent
|
||||
}
|
||||
|
||||
// Recharger l'événement depuis le provider
|
||||
final eventProvider = context.read<EventProvider>();
|
||||
// Recharger la liste des événements pour rafraîchir les données
|
||||
@@ -667,38 +705,68 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// Vérifier et marquer les équipements LOST (logique intelligente)
|
||||
Future<void> _checkAndMarkLostEquipment(List<EventEquipment> updatedEquipment) async {
|
||||
for (final eq in updatedEquipment) {
|
||||
final isMissingNow = eq.isMissingAtReturn;
|
||||
/// Détermine le statut d'un équipement selon l'étape actuelle
|
||||
String _determineEquipmentStatus(EventEquipment eq) {
|
||||
// Vérifier d'abord si l'équipement est perdu (LOST)
|
||||
if (_shouldMarkAsLost(eq)) {
|
||||
return 'LOST';
|
||||
}
|
||||
|
||||
if (isMissingNow) {
|
||||
// Vérifier si c'était manquant dès la préparation (étape 0)
|
||||
final wasMissingAtPreparation = eq.isMissingAtPreparation;
|
||||
// Vérifier si manquant à l'étape actuelle
|
||||
if (_isMissingAtCurrentStep(eq)) {
|
||||
return 'MISSING';
|
||||
}
|
||||
|
||||
if (!wasMissingAtPreparation) {
|
||||
// Était présent au départ mais manquant maintenant = LOST
|
||||
try {
|
||||
await _dataService.updateEquipmentStatusOnly(
|
||||
equipmentId: eq.equipmentId,
|
||||
status: EquipmentStatus.lost.toString(),
|
||||
);
|
||||
// Vérifier les quantités
|
||||
final currentQty = _getQuantityForStep(eq);
|
||||
if (currentQty != null && currentQty < eq.quantity) {
|
||||
return 'QUANTITY_MISMATCH';
|
||||
}
|
||||
|
||||
DebugLog.info('[EventPreparationPage] Équipement ${eq.equipmentId} marqué comme LOST');
|
||||
return 'AVAILABLE';
|
||||
}
|
||||
|
||||
// TODO: Créer une alerte "Équipement perdu"
|
||||
// await _createLostEquipmentAlert(eq.equipmentId);
|
||||
} catch (e) {
|
||||
DebugLog.error('[EventPreparationPage] Erreur marquage LOST ${eq.equipmentId}', e);
|
||||
}
|
||||
} else {
|
||||
// Manquant dès le début = PAS lost, juste manquant
|
||||
DebugLog.info('[EventPreparationPage] Équipement ${eq.equipmentId} manquant depuis le début (pas LOST)');
|
||||
}
|
||||
}
|
||||
/// Vérifie si un équipement doit être marqué comme LOST
|
||||
bool _shouldMarkAsLost(EventEquipment eq) {
|
||||
// Seulement aux étapes de retour
|
||||
if (_currentStep != PreparationStep.return_ &&
|
||||
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Si manquant maintenant mais PAS manquant à la préparation = LOST
|
||||
return eq.isMissingAtReturn && !eq.isMissingAtPreparation;
|
||||
}
|
||||
|
||||
/// Vérifie si un équipement est manquant à l'étape actuelle
|
||||
bool _isMissingAtCurrentStep(EventEquipment eq) {
|
||||
switch (_currentStep) {
|
||||
case PreparationStep.preparation:
|
||||
return eq.isMissingAtPreparation;
|
||||
case PreparationStep.loadingOutbound:
|
||||
return eq.isMissingAtLoading;
|
||||
case PreparationStep.unloadingReturn:
|
||||
return eq.isMissingAtUnloading;
|
||||
case PreparationStep.return_:
|
||||
return eq.isMissingAtReturn;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la quantité pour l'étape actuelle
|
||||
int? _getQuantityForStep(EventEquipment eq) {
|
||||
switch (_currentStep) {
|
||||
case PreparationStep.preparation:
|
||||
return eq.quantityAtPreparation;
|
||||
case PreparationStep.loadingOutbound:
|
||||
return eq.quantityAtLoading;
|
||||
case PreparationStep.unloadingReturn:
|
||||
return eq.quantityAtUnloading;
|
||||
case PreparationStep.return_:
|
||||
return eq.quantityAtReturn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allValidated = _isStepCompleted();
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
|
||||
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/views/widgets/notification_preferences_widget.dart';
|
||||
|
||||
class MyAccountPage extends StatelessWidget {
|
||||
const MyAccountPage({super.key});
|
||||
@@ -86,6 +87,13 @@ class MyAccountPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Préférences de notifications
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: const NotificationPreferencesWidget(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
234
em2rp/lib/views/widgets/alert_item.dart
Normal file
234
em2rp/lib/views/widgets/alert_item.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
// import 'package:timeago/timeago.dart' as timeago; // TODO: Ajouter dépendance dans pubspec.yaml
|
||||
|
||||
/// Widget pour afficher une alerte individuelle
|
||||
class AlertItem extends StatelessWidget {
|
||||
final AlertModel alert;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onMarkAsRead;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const AlertItem({
|
||||
super.key,
|
||||
required this.alert,
|
||||
this.onTap,
|
||||
this.onMarkAsRead,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dismissible(
|
||||
key: Key(alert.id),
|
||||
background: _buildSwipeBackground(
|
||||
Colors.blue,
|
||||
Icons.check,
|
||||
Alignment.centerLeft,
|
||||
),
|
||||
secondaryBackground: _buildSwipeBackground(
|
||||
Colors.red,
|
||||
Icons.delete,
|
||||
Alignment.centerRight,
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
// Swipe vers la droite = marquer comme lu
|
||||
onMarkAsRead?.call();
|
||||
return false; // Ne pas supprimer le widget
|
||||
} else {
|
||||
// Swipe vers la gauche = supprimer
|
||||
return await _confirmDelete(context);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
color: alert.isRead ? Colors.white : Colors.blue.shade50,
|
||||
elevation: alert.isRead ? 1 : 2,
|
||||
child: ListTile(
|
||||
leading: _buildIcon(),
|
||||
title: Text(
|
||||
alert.message,
|
||||
style: TextStyle(
|
||||
fontWeight: alert.isRead ? FontWeight.normal : FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(alert.createdAt),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
if (alert.isResolved) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 14, color: Colors.green),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Résolu',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: !alert.isRead
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getSeverityColor(alert.severity),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Nouveau',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwipeBackground(Color color, IconData icon, Alignment alignment) {
|
||||
return Container(
|
||||
color: color,
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Icon(icon, color: Colors.white, size: 28),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIcon() {
|
||||
IconData iconData;
|
||||
Color iconColor;
|
||||
|
||||
switch (alert.type) {
|
||||
case AlertType.eventCreated:
|
||||
case AlertType.eventModified:
|
||||
case AlertType.eventAssigned:
|
||||
iconData = Icons.event;
|
||||
iconColor = Colors.blue;
|
||||
break;
|
||||
case AlertType.workforceAdded:
|
||||
iconData = Icons.group_add;
|
||||
iconColor = Colors.green;
|
||||
break;
|
||||
case AlertType.eventCancelled:
|
||||
iconData = Icons.event_busy;
|
||||
iconColor = Colors.red;
|
||||
break;
|
||||
case AlertType.maintenanceDue:
|
||||
case AlertType.maintenanceReminder:
|
||||
iconData = Icons.build;
|
||||
iconColor = Colors.orange;
|
||||
break;
|
||||
case AlertType.lost:
|
||||
iconData = Icons.error;
|
||||
iconColor = Colors.red;
|
||||
break;
|
||||
case AlertType.equipmentMissing:
|
||||
iconData = Icons.warning;
|
||||
iconColor = Colors.orange;
|
||||
break;
|
||||
case AlertType.lowStock:
|
||||
iconData = Icons.inventory_2;
|
||||
iconColor = Colors.orange;
|
||||
break;
|
||||
case AlertType.conflict:
|
||||
iconData = Icons.error_outline;
|
||||
iconColor = Colors.red;
|
||||
break;
|
||||
case AlertType.quantityMismatch:
|
||||
iconData = Icons.compare_arrows;
|
||||
iconColor = Colors.orange;
|
||||
break;
|
||||
case AlertType.damaged:
|
||||
iconData = Icons.broken_image;
|
||||
iconColor = Colors.red;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(iconData, color: iconColor, size: 24),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getSeverityColor(AlertSeverity severity) {
|
||||
switch (severity) {
|
||||
case AlertSeverity.info:
|
||||
return Colors.blue;
|
||||
case AlertSeverity.warning:
|
||||
return Colors.orange;
|
||||
case AlertSeverity.critical:
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
// TODO: Utiliser timeago une fois la dépendance ajoutée
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'À l\'instant';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return 'Il y a ${difference.inMinutes} min';
|
||||
} else if (difference.inHours < 24) {
|
||||
return 'Il y a ${difference.inHours}h';
|
||||
} else if (difference.inDays < 7) {
|
||||
return 'Il y a ${difference.inDays}j';
|
||||
} else {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirmDelete(BuildContext context) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer l\'alerte ?'),
|
||||
content: const Text('Cette action est irréversible.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
onDelete?.call();
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
import '../notification_badge.dart' show NotificationBadge;
|
||||
|
||||
class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
@@ -29,6 +31,7 @@ class _CustomAppBarState extends State<CustomAppBar> {
|
||||
title: Text(widget.title),
|
||||
backgroundColor: AppColors.rouge,
|
||||
actions: [
|
||||
NotificationBadge(),
|
||||
if (widget.showLogoutButton)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout, color: AppColors.blanc),
|
||||
|
||||
43
em2rp/lib/views/widgets/notification_badge.dart
Normal file
43
em2rp/lib/views/widgets/notification_badge.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/services/alert_service.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Badge de notifications dans l'AppBar
|
||||
class NotificationBadge extends StatelessWidget {
|
||||
const NotificationBadge({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localUserProvider = context.watch<LocalUserProvider>();
|
||||
final userId = localUserProvider.currentUser?.uid;
|
||||
|
||||
if (userId == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return StreamBuilder<int>(
|
||||
stream: AlertService().unreadCountStreamForUser(userId),
|
||||
builder: (context, snapshot) {
|
||||
final count = snapshot.data ?? 0;
|
||||
|
||||
return Badge(
|
||||
label: Text('$count'),
|
||||
isLabelVisible: count > 0,
|
||||
backgroundColor: Colors.red,
|
||||
textColor: Colors.white,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.notifications),
|
||||
onPressed: () => _openAlertsPage(context),
|
||||
tooltip: 'Notifications',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openAlertsPage(BuildContext context) {
|
||||
Navigator.of(context).pushNamed('/alerts');
|
||||
}
|
||||
}
|
||||
|
||||
264
em2rp/lib/views/widgets/notification_preferences_widget.dart
Normal file
264
em2rp/lib/views/widgets/notification_preferences_widget.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/notification_preferences_model.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Widget pour afficher et modifier les préférences de notifications
|
||||
class NotificationPreferencesWidget extends StatefulWidget {
|
||||
const NotificationPreferencesWidget({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationPreferencesWidget> createState() => _NotificationPreferencesWidgetState();
|
||||
}
|
||||
|
||||
class _NotificationPreferencesWidgetState extends State<NotificationPreferencesWidget> {
|
||||
// État local pour feedback immédiat
|
||||
NotificationPreferences? _localPrefs;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<LocalUserProvider>(
|
||||
builder: (context, userProvider, _) {
|
||||
final user = userProvider.currentUser;
|
||||
if (user == null) return const SizedBox.shrink();
|
||||
|
||||
// Utiliser les prefs locales si disponibles, sinon les prefs du user
|
||||
final prefs = _localPrefs ?? user.notificationPreferences ?? NotificationPreferences.defaults();
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre section
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.notifications, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Préférences de notifications',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
if (_isSaving) ...[
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choisissez comment vous souhaitez être notifié',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
|
||||
// Canaux de notification
|
||||
Text(
|
||||
'Canaux de notification',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_buildSwitchTile(
|
||||
context,
|
||||
title: 'Notifications in-app',
|
||||
subtitle: 'Alertes dans l\'application',
|
||||
value: prefs.inAppEnabled,
|
||||
icon: Icons.app_settings_alt,
|
||||
onChanged: (value) => _updatePrefs(
|
||||
context,
|
||||
prefs.copyWith(inAppEnabled: value),
|
||||
),
|
||||
),
|
||||
|
||||
_buildSwitchTile(
|
||||
context,
|
||||
title: 'Notifications email',
|
||||
subtitle: 'Recevoir des emails',
|
||||
value: prefs.emailEnabled,
|
||||
icon: Icons.email,
|
||||
onChanged: (value) => _updatePrefs(
|
||||
context,
|
||||
prefs.copyWith(emailEnabled: value),
|
||||
),
|
||||
),
|
||||
|
||||
_buildSwitchTile(
|
||||
context,
|
||||
title: 'Notifications push',
|
||||
subtitle: 'Notifications navigateur',
|
||||
value: prefs.pushEnabled,
|
||||
icon: Icons.notifications_active,
|
||||
onChanged: (value) => _updatePrefs(
|
||||
context,
|
||||
prefs.copyWith(pushEnabled: value),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 24),
|
||||
|
||||
// Types de notifications
|
||||
Text(
|
||||
'Types de notifications',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_buildSwitchTile(
|
||||
context,
|
||||
title: 'Événements',
|
||||
subtitle: 'Création, modification, assignations',
|
||||
value: prefs.eventsNotifications,
|
||||
icon: Icons.event,
|
||||
onChanged: (value) => _updatePrefs(
|
||||
context,
|
||||
prefs.copyWith(eventsNotifications: value),
|
||||
),
|
||||
),
|
||||
|
||||
_buildSwitchTile(
|
||||
context,
|
||||
title: 'Maintenance',
|
||||
subtitle: 'Rappels de maintenance',
|
||||
value: prefs.maintenanceNotifications,
|
||||
icon: Icons.build,
|
||||
onChanged: (value) => _updatePrefs(
|
||||
context,
|
||||
prefs.copyWith(maintenanceNotifications: value),
|
||||
),
|
||||
),
|
||||
|
||||
_buildSwitchTile(
|
||||
context,
|
||||
title: 'Stock',
|
||||
subtitle: 'Stock faible, quantités',
|
||||
value: prefs.stockNotifications,
|
||||
icon: Icons.inventory_2,
|
||||
onChanged: (value) => _updatePrefs(
|
||||
context,
|
||||
prefs.copyWith(stockNotifications: value),
|
||||
),
|
||||
),
|
||||
|
||||
_buildSwitchTile(
|
||||
context,
|
||||
title: 'Équipement',
|
||||
subtitle: 'Perdu, manquant, conflits',
|
||||
value: prefs.equipmentNotifications,
|
||||
icon: Icons.warning,
|
||||
onChanged: (value) => _updatePrefs(
|
||||
context,
|
||||
prefs.copyWith(equipmentNotifications: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwitchTile(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required IconData icon,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return SwitchListTile(
|
||||
secondary: Icon(icon, color: value ? Theme.of(context).primaryColor : Colors.grey),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
value: value,
|
||||
onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF
|
||||
inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updatePrefs(BuildContext context, NotificationPreferences newPrefs) async {
|
||||
// Mise à jour locale immédiate pour feedback visuel
|
||||
setState(() {
|
||||
_localPrefs = newPrefs;
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
final userProvider = context.read<LocalUserProvider>();
|
||||
|
||||
try {
|
||||
await userProvider.updateNotificationPreferences(newPrefs);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
_localPrefs = null; // Revenir aux prefs du provider
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Préférences enregistrées'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
_localPrefs = null; // Rollback en cas d'erreur
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur : $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user