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:
ElPoyo
2026-01-15 23:15:25 +01:00
parent 60d0e1c6c4
commit beaabceda4
78 changed files with 4990 additions and 511 deletions

View 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,
),
);
}
}
}
}

View File

@@ -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();

View File

@@ -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(),
),
],
),
),

View 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;
}
}

View File

@@ -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),

View 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');
}
}

View 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,
),
);
}
}
}
}