8 Commits

Author SHA1 Message Date
ElPoyo 0bbc77ffc8 feat: mise à jour de la version de l'application à 1.2.1 et ajout d'un assistant IA pour la gestion des équipements 2026-05-25 23:55:59 +02:00
ElPoyo 19d3dcef69 fix: correction du merge de equipment_selection_dialog.dart (structure invalide) 2026-05-25 23:42:00 +02:00
ElPoyo 32a279e0ae feat: merge branche IA (beta) - Intégration assistant IA logisticien Gemini 2026-05-25 23:35:40 +02:00
ElPoyo af5ecaeee1 feat: optimisation du démarrage de l'application et de la gestion de l'authentification
- **Refonte du démarrage** : Mise en place d'un `AppInitializer` pour gérer l'initialisation asynchrone de Firebase et du cache en arrière-plan, réduisant le travail synchrone au lancement.
- **Sécurisation de l'authentification** :
    - Création d'un `AppStartGate` pour gérer proprement la restauration de la session Firebase Auth et les erreurs potentielles sur le Web.
    - Amélioration du `LocalUserProvider` avec un "bootstrap léger" permettant de rendre l'UID disponible immédiatement avant le chargement complet du profil.
    - Ajout de protections contre les erreurs d'accès à `FirebaseAuth.instance` (notamment pour les problèmes d'interop JS sur le Web).
- **Optimisation de l'UI** :
    - Remplacement du `AutoLoginWrapper` par une gestion plus robuste de la navigation post-authentification.
    - Amélioration de l'`AuthGuard` pour permettre l'affichage de certains écrans (comme le calendrier) pendant le chargement des données utilisateur (`allowWhileLoading`).
    - Ajout d'un écran de splash screen uniformisé (`StartupSplashScreen`).
- **Services & Cache** :
    - Introduction de `CacheService` utilisant `shared_preferences` pour le stockage local léger.
    - Refactoring des services (`AlertService`, `EmailService`, `FirebaseStorageManager`) pour accéder aux instances Firebase de manière plus flexible via des getters.
    - Mise à jour des dépendances dans `pubspec.yaml` pour inclure `shared_preferences`.
- **Calendrier** : Ajout d'une logique de chargement initial différé des événements (`_scheduleInitialEventsLoad`) pour éviter les appels redondants au démarrage.
- **Maintenance** : Mise à jour de la version de l'application à `1.1.23` et nettoyage des fichiers de cache de déploiement.
2026-05-05 12:25:45 +02:00
ElPoyo eac103491f feat: recherche d'événements et gestion avancée de la suppression d'équipement
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
    - Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
    - Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
    - Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
    - Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
    - Refonte de l'interface mobile pour intégrer la barre de recherche.
    - Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
    - Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
    - Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
    - Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
2026-04-22 12:25:37 +02:00
ElPoyo 0551f0b9c1 feat: Mise à jour à la version 1.1.20 et amélioration de la recherche d'équipements
- Mise à jour de la version de l'application à `1.1.20` dans `app_version.dart`, `version.json` et `CHANGELOG.md`.
- Optimisation de la fonction Cloud `getEquipmentsPaginated` pour supporter la recherche par ID exact (document ID ou ID legacy) et améliorer la recherche textuelle avec filtrage par lots.
- Amélioration de la gestion des alertes dans `processEquipmentValidation.js` :
    - Ajout d'un statut `NOT_TAKEN` pour éviter les fausses alertes d'équipements perdus s'ils n'ont jamais été emportés.
    - Refonte complète du parsing des dates Firestore pour une meilleure robustesse dans les alertes.
    - Correction de la validation des quantités (vérification du type `number`).
- Ajout de méthodes statiques dans `EventPreparationService` (`shouldMarkEquipmentAsLost`, `isEquipmentNotTakenToEvent`) pour centraliser la logique de détermination du statut des équipements au retour.
- Mise à jour de `EventPreparationPage` pour intégrer le nouveau statut `NOT_TAKEN` et utiliser la logique centralisée du service de préparation.
- Mise à jour des fichiers de cache Firebase Hosting.
2026-03-30 17:12:48 +02:00
ElPoyo cf13b4a986 .env gitignore 2026-03-28 21:38:56 +01:00
ElPoyo 3f80d9318b feat: Mise à jour à la version 1.1.19 et amélioration du cache/pagination pour la sélection d'équipements
- Mise à jour de la version de l'application à `1.1.19` dans `app_version.dart` et `version.json`.
- Correction d'un bug de cache dans `EquipmentSelectionDialog` qui empêchait l'affichage de certains équipements lors de la sélection.
- Introduction d'une fonction utilitaire `shouldAutoLoadNextPage` et de tests unitaires associés pour fiabiliser le chargement automatique des données.
- Ajout d'une gestion de préchargement automatique dans `EquipmentSelectionDialog` lorsque la liste n'est pas assez longue pour activer le défilement (évite les vues tronquées).
- Amélioration de `ContainerFormPage` pour forcer le rechargement complet de la liste des équipements, évitant ainsi les conflits avec les états de pagination d'autres écrans.
- Optimisation du chargement des conflits de disponibilité et des quantités via un chargement par lots (batch).
- Nettoyage du code et amélioration de la lisibilité des fichiers `container_form_page.dart` et `equipment_selection_dialog.dart`.
2026-03-24 12:18:00 +01:00
34 changed files with 2399 additions and 808 deletions
+13 -13
View File
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1773346314557,fda0011c81b6890abb52de8e160b96b7fa61bd4fbb8c45af2fbecb29d5df708d
index.html,1773346319918,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1773346397863,2f92f9c59bdab08ddbc8274db4459302bd6134e3987b0decdb26323a257b0ab7
assets/FontManifest.json,1773346394287,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
flutter_bootstrap.js,1773346319903,1a83667573bf9cf4a4a90e3d1631fbc55b97cebfb14c643ddf9d3468bde748ec
assets/AssetManifest.json,1773346394287,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1773346394287,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1773346394287,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773346397053,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1773346394513,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1773346397057,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
assets/NOTICES,1773346394289,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
main.dart.js,1773346393292,a9b20044339caf5878c0d72b7a45df204e67eab3d4c288b5964d852059c88bdd
version.json,1779745850580,c83e8cef9f09921b50bea3e26017c353fb516d339f57fbd0a8d3696f1ffc0e42
index.html,1779745856220,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_bootstrap.js,1779745856203,79bfcfd09b63ba083702fd55c660d283686d9571b49febd8dcab49abbdf6f683
flutter_service_worker.js,1779745934512,3d18931ea97b2eeeba61c4fe7c0c8d736cc42ef9b8c2a6e4ec21e83e14e351ae
assets/FontManifest.json,1779745931038,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.bin.json,1779745931038,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1779745931038,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/AssetManifest.json,1779745931038,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/shaders/ink_sparkle.frag,1779745931235,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779745933681,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/fonts/MaterialIcons-Regular.otf,1779745933686,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
assets/NOTICES,1779745931041,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
main.dart.js,1779745928953,60d92269024a5be234c7da2ebb889584e20c66a262b28f6d531a3f90c83767b3
+2
View File
@@ -45,4 +45,6 @@ app.*.map.json
# Environment configuration with credentials
lib/config/env.dev.dart
functions/.env
.env
env.dart
functions/.env.local
+16
View File
@@ -1,6 +1,22 @@
# Changelog - EM2RP
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
## 25/05/2026
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
## 04/05/2026
Optimisation du lancement de l'application et amélioration de la gestion du cache.
## 22/04/2026
Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement
## 30/03/2026
Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.
## 24/03/2026
Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.
## 12/03/2026bis
Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.
+1 -1
View File
@@ -7,4 +7,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
# URL de l'application
APP_URL="https://app.em2events.fr"
GEMINI_API_KEY="AIzaSyBdBdjFLma2pLenmFBlqZHArS4GVF-mclo"
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
+323 -42
View File
@@ -214,30 +214,52 @@ exports.deleteEquipment = onRequest(httpOptions, withCors(async (req, res) => {
return;
}
const { equipmentId } = req.body.data;
const { equipmentId, forceDelete = false } = req.body.data;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Vérifier si l'équipement est utilisé dans des événements actifs
// Vérifier si l'équipement est utilisé dans des événements à venir
const eventsSnapshot = await db.collection('events')
.where('status', '!=', 'CANCELLED')
.get();
const now = new Date();
const upcomingEvents = [];
for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
if (!assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
continue;
}
let eventStart = null;
if (eventData.StartDateTime) {
eventStart = eventData.StartDateTime.toDate
? eventData.StartDateTime.toDate()
: new Date(eventData.StartDateTime);
}
if (eventStart && eventStart > now) {
upcomingEvents.push({
eventId: eventDoc.id,
eventName: eventData.Name || '',
startDate: eventStart.toISOString(),
});
}
}
if (upcomingEvents.length > 0 && !forceDelete) {
res.status(409).json({
error: 'Cannot delete equipment: it is assigned to active events',
eventId: eventDoc.id
error: 'FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events',
upcomingEvents,
});
return;
}
}
await db.collection('equipments').doc(equipmentId).delete();
@@ -1875,6 +1897,116 @@ exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => {
}
}));
const normalizeSearchText = (value) => {
return (value || '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
};
const getEventStartDate = (eventData) => {
const startValue = eventData.StartDateTime;
if (!startValue) {
return null;
}
if (startValue.toDate) {
return startValue.toDate();
}
const parsedDate = new Date(startValue);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
};
const getEventWorkforceUids = (eventData) => {
if (!eventData.workforce || !Array.isArray(eventData.workforce)) {
return [];
}
return eventData.workforce
.map((userRef) => {
if (userRef && userRef.id) {
return userRef.id;
}
if (typeof userRef === 'string' && userRef.startsWith('users/')) {
return userRef.split('/')[1];
}
return null;
})
.filter((uid) => uid !== null);
};
const serializeEventSearchResult = (doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data),
workforce: getEventWorkforceUids(data),
};
};
// ============================================================================
// EVENTS - Search
// ============================================================================
exports.searchEvents = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { userId, query, limit = 20 } = req.body.data || {};
const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20;
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
res.status(200).json({ events: [] });
return;
}
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
let eventsSnapshot;
if (canViewAll) {
eventsSnapshot = await db.collection('events').get();
} else {
const userRef = db.collection('users').doc(userId || decodedToken.uid);
eventsSnapshot = await db.collection('events')
.where('workforce', 'array-contains', userRef)
.get();
}
const matchingEvents = eventsSnapshot.docs
.filter((doc) => {
const eventData = doc.data();
const startDate = getEventStartDate(eventData);
const searchableText = normalizeSearchText([
eventData.Name,
eventData.Description,
eventData.Address,
startDate ? startDate.toLocaleString('fr-FR') : '',
startDate ? startDate.toISOString() : '',
].join(' '));
return searchableText.includes(normalizedQuery);
})
.sort((a, b) => {
const startA = getEventStartDate(a.data()) || new Date(0);
const startB = getEventStartDate(b.data()) || new Date(0);
return startA.getTime() - startB.getTime();
})
.slice(0, maxResults)
.map((doc) => serializeEventSearchResult(doc));
res.status(200).json({ events: matchingEvents });
} catch (error) {
logger.error('Error searching events:', error);
res.status(500).json({ error: error.message });
}
}));
/**
* Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
* Optimisé pour la page de préparation et l'affichage détaillé
@@ -3836,18 +3968,97 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
// Convertir en majuscules pour correspondre au format Firestore
const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null;
const rawSearchQuery = typeof params.searchQuery === 'string' ? params.searchQuery.trim() : '';
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, '') : null;
const sortBy = params.sortBy || 'id';
const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc';
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
// Fast-path pour une recherche d'ID exact: évite le cap queryLimit lors d'une recherche précise.
if (searchQuery && !startAfterId) {
const exactIdCandidates = Array.from(new Set([
rawSearchQuery,
rawSearchQuery.toUpperCase(),
rawSearchQuery.toLowerCase()
].filter(Boolean)));
for (const candidateId of exactIdCandidates) {
const exactDoc = await db.collection('equipments').doc(candidateId).get();
if (!exactDoc.exists) {
continue;
}
const exactData = exactDoc.data() || {};
const matchesCategory = !category || exactData.category === category;
const matchesStatus = !status || exactData.status === status;
if (!matchesCategory || !matchesStatus) {
continue;
}
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
id: exactDoc.id
};
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1
});
return;
}
// Compatibilité legacy: certains documents peuvent stocker un ancien champ `id` différent du document ID.
for (const legacyId of exactIdCandidates) {
let legacyIdQuery = db.collection('equipments').where('id', '==', legacyId);
if (category) {
legacyIdQuery = legacyIdQuery.where('category', '==', category);
}
if (status) {
legacyIdQuery = legacyIdQuery.where('status', '==', status);
}
const legacySnapshot = await legacyIdQuery.limit(1).get();
if (legacySnapshot.empty) {
continue;
}
const exactDoc = legacySnapshot.docs[0];
const exactData = exactDoc.data() || {};
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
id: exactDoc.id
};
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1
});
return;
}
}
// Construire la requête Firestore
let query = db.collection('equipments');
// Si recherche textuelle, on augmente la limite pour filtrer ensuite
const queryLimit = searchQuery ? Math.min(limit * 10, 200) : limit;
// Appliquer les filtres
if (category) {
query = query.where('category', '==', category);
@@ -3872,20 +4083,10 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
}
}
// Limiter les résultats
query = query.limit(queryLimit + 1);
const timestampFields = ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'];
const snapshot = await query.get();
// Déterminer hasMore basé sur le nombre de documents Firestore
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
let equipments = docsToProcess.map(doc => {
const data = doc.data();
const mapEquipmentDoc = (doc) => {
const data = {...(doc.data() || {})};
// Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) {
@@ -3893,32 +4094,50 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
delete data.rentalPrice;
}
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
const legacyId = typeof data.id === 'string' ? data.id : '';
// Filtrage textuel côté serveur
if (searchQuery) {
equipments = equipments.filter(eq => {
return {
...helpers.serializeTimestamps(data, timestampFields),
id: doc.id,
_legacyId: legacyId
};
};
const matchesSearchQuery = (equipment) => {
const searchableText = [
eq.name || '',
eq.id || '',
eq.model || '',
eq.brand || '',
eq.subCategory || ''
equipment.name || '',
equipment.id || '',
equipment._legacyId || '',
equipment.model || '',
equipment.brand || '',
equipment.subCategory || ''
].join(' ').toLowerCase();
return searchableText.includes(searchQuery);
});
if (searchableText.includes(searchQuery)) {
return true;
}
// Pour la limite finale après filtrage textuel
const limitedEquipments = equipments.slice(0, limit);
if (!compactSearchQuery) {
return false;
}
const compactSearchableText = searchableText.replace(/[\s_-]+/g, '');
return compactSearchableText.includes(compactSearchQuery);
};
if (!searchQuery) {
const snapshot = await query.limit(limit + 1).get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > limit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
const limitedEquipments = docsToProcess
.map(mapEquipmentDoc)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
// hasMore reste basé sur le nombre de docs Firestore, pas sur le filtrage textuel
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
@@ -3926,6 +4145,68 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
lastVisible,
total: limitedEquipments.length
});
return;
}
// En mode recherche, scanner la collection par lots jusqu'à obtenir `limit + 1` matchs
// afin de garantir des résultats même si les documents pertinents sont loin dans l'ordre de tri.
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
const matchedEquipments = [];
let scannedDocuments = 0;
let searchQueryRef = query;
let hasMoreMatches = false;
let hasMoreDocsToScan = true;
while (hasMoreDocsToScan && !hasMoreMatches) {
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
if (snapshot.empty) {
hasMoreDocsToScan = false;
break;
}
scannedDocuments += snapshot.docs.length;
for (const doc of snapshot.docs) {
const equipment = mapEquipmentDoc(doc);
if (!matchesSearchQuery(equipment)) {
continue;
}
matchedEquipments.push(equipment);
if (matchedEquipments.length > limit) {
hasMoreMatches = true;
break;
}
}
if (hasMoreMatches) {
break;
}
if (snapshot.docs.length < searchBatchSize) {
hasMoreDocsToScan = false;
break;
}
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
searchQueryRef = query.startAfter(lastDocInBatch);
}
const limitedEquipments = matchedEquipments
.slice(0, limit)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreMatches,
lastVisible,
total: limitedEquipments.length
});
} catch (error) {
logger.error("Error fetching paginated equipments:", error);
+51 -6
View File
@@ -50,6 +50,11 @@ exports.processEquipmentValidation = onCall({
for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
if (status === 'NOT_TAKEN') {
continue;
}
// Cas 1: Équipement PERDU
if (status === 'LOST') {
const alertData = await createAlertInFirestore({
@@ -91,7 +96,9 @@ exports.processEquipmentValidation = onCall({
}
// Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) {
const hasExpectedQuantity = typeof expectedQuantity === 'number';
const hasActualQuantity = typeof quantity === 'number';
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH',
severity: 'INFO',
@@ -409,10 +416,48 @@ async function sendAlertEmails(alert, userIds) {
* Formate la date d'un événement
*/
function formatEventDate(event) {
if (event.startDate) {
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
}
return 'Date inconnue';
const rawDate =
event?.StartDateTime ||
event?.startDateTime ||
event?.startDate ||
event?.eventDate;
const parsedDate = parseFirestoreDate(rawDate);
const safeDate = parsedDate || new Date();
return safeDate.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'numeric',
year: 'numeric',
});
}
function parseFirestoreDate(value) {
if (!value) {
return null;
}
if (typeof value.toDate === 'function') {
return value.toDate();
}
if (value instanceof Date) {
return value;
}
if (typeof value === 'string' || typeof value === 'number') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof value === 'object' && typeof value.seconds === 'number') {
return new Date(value.seconds * 1000);
}
if (typeof value === 'object' && typeof value._seconds === 'number') {
return new Date(value._seconds * 1000);
}
return null;
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// Configuration de la version de l'application
class AppVersion {
static const String version = '1.1.18';
static const String version = '1.2.1';
/// Retourne la version complète de l'application
static String get fullVersion => 'v$version';
+81 -173
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
@@ -19,10 +21,8 @@ import 'package:em2rp/views/event_statistics_page.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'package:em2rp/services/app_initializer.dart';
import 'utils/colors.dart';
import 'views/my_account_page.dart';
import 'views/user_management_page.dart';
@@ -30,35 +30,21 @@ import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart';
import 'views/reset_password_page.dart';
import 'config/env.dart';
import 'services/update_service.dart';
import 'views/widgets/common/update_dialog.dart';
import 'config/api_config.dart';
import 'utils/app_start_gate.dart';
import 'views/widgets/common/startup_splash_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
void main() async {
void main() {
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configuration des émulateurs en mode développement
if (ApiConfig.isDevelopment) {
print('🔧 Mode développement activé - Utilisation des émulateurs');
// Configurer l'émulateur Auth
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
print('✓ Auth émulateur configuré: localhost:9199');
// Configurer l'émulateur Firestore
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
print('✓ Firestore émulateur configuré: localhost:8088');
}
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
runApp(
MultiProvider(
providers: [
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
ChangeNotifierProvider<AppInitializer>(
create: (_) => AppInitializer(),
),
// LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()),
@@ -96,11 +82,67 @@ void main() async {
);
}
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<void> _startupFuture;
@override
void initState() {
super.initState();
_startupFuture = _bootstrapApp();
}
Future<void> _bootstrapApp() async {
final initializer = context.read<AppInitializer>();
final localAuthProvider = context.read<LocalUserProvider>();
await initializer.initialize();
// Attendre la première valeur d'authentification avant toute décision
// de navigation, afin d'éviter un flash de la page login.
await FirebaseAuth.instance.authStateChanges().first;
if (FirebaseAuth.instance.currentUser != null) {
unawaited(
localAuthProvider.loadUserData().catchError((e) {
print('User data bootstrap failed: $e');
}),
);
return;
}
// En développement, on garde la connexion automatique existante.
if (Env.isDevelopment) {
await localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
);
unawaited(
localAuthProvider.loadUserData().catchError((e) {
print('Dev user bootstrap failed: $e');
}),
);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _startupFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: StartupSplashScreen(),
);
}
return MaterialApp(
title: 'EM2 Hub',
theme: ThemeData(
@@ -137,15 +179,15 @@ class MyApp extends StatelessWidget {
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
initialRoute: '/',
routes: {
'/': (context) => const AutoLoginWrapper(),
'/login': (context) => const LoginPage(),
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
'/calendar': (context) => const AuthGuard(
allowWhileLoading: true, child: CalendarPage()),
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/user_management': (context) => const AuthGuard(
requiredPermission: "view_all_users", child: UserManagementPage()),
requiredPermission: "view_all_users",
child: UserManagementPage()),
'/reset_password': (context) {
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, dynamic>;
@@ -173,14 +215,16 @@ class MyApp extends StatelessWidget {
);
},
'/container_detail': (context) {
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
final container = ModalRoute.of(context)!.settings.arguments
as ContainerModel;
return AuthGuard(
requiredPermission: "view_equipment",
child: ContainerDetailPage(container: container),
);
},
'/event_preparation': (context) {
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, dynamic>;
final event = args['event'] as EventModel;
return AuthGuard(
child: EventPreparationPage(
@@ -189,148 +233,12 @@ class MyApp extends StatelessWidget {
);
},
'/event_statistics': (context) => const AuthGuard(
requiredPermission: 'generate_reports', child: EventStatisticsPage()),
requiredPermission: 'generate_reports',
child: EventStatisticsPage()),
},
home: const AppStartGate(),
);
},
);
}
}
class AutoLoginWrapper extends StatefulWidget {
const AutoLoginWrapper({super.key});
@override
State<AutoLoginWrapper> createState() => _AutoLoginWrapperState();
}
class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
void initState() {
super.initState();
// Attendre la fin du premier build avant de naviguer
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoLogin();
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
_checkForUpdateDelayed();
});
}
/// Vérifie les mises à jour après un délai
Future<void> _checkForUpdateDelayed() async {
try {
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
await Future.delayed(const Duration(seconds: 3));
if (!mounted) return;
final updateInfo = await UpdateService.checkForUpdate();
if (updateInfo != null && mounted) {
// Attendre encore un peu pour être sûr que le bon contexte est disponible
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
showDialog(
context: context,
barrierDismissible: !updateInfo.forceUpdate,
builder: (context) => UpdateDialog(updateInfo: updateInfo),
);
}
}
} catch (e) {
print('[AutoLoginWrapper] Error checking for update: $e');
}
}
Future<void> _autoLogin() async {
PerformanceMonitor.start('App.autoLogin');
try {
final localAuthProvider =
Provider.of<LocalUserProvider>(context, listen: false);
// Vérifier si l'utilisateur est déjà connecté
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
PerformanceMonitor.start('App.signIn');
// Connexion automatique en mode développement
await localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
);
PerformanceMonitor.end('App.signIn');
}
if (mounted) {
// 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');
// Navigation immédiate sans attendre le chargement des données
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');
}
PerformanceMonitor.end('App.autoLogin');
PerformanceMonitor.printSummary();
// Charger les données utilisateur en arrière-plan
localAuthProvider.loadUserData().catchError((e) {
print('Error loading user data: $e');
});
}
} catch (e) {
print('Auto login failed: $e');
PerformanceMonitor.end('App.autoLogin');
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo de l'application
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 200,
height: 200,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 80,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 40),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 20),
const Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w400,
),
),
],
),
),
);
}
}
+2 -2
View File
@@ -433,9 +433,9 @@ class EquipmentProvider extends ChangeNotifier {
}
/// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async {
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
try {
await _dataService.deleteEquipment(equipmentId);
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
if (_usePagination) {
await reload();
} else {
+75 -29
View File
@@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier {
bool _lastCanViewAll = false;
// Nouveau: Cache par mois pour le lazy loading
final Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
final Map<String, List<EventModel>> _eventsByMonth =
{}; // "2026-02" => [events]
String? _currentMonth; // Mois actuellement affiché
List<EventModel> get events => _events;
@@ -28,7 +29,8 @@ class EventProvider with ChangeNotifier {
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
bool _shouldReload(String userId, bool canViewAllEvents) {
if (_lastLoadTime == null) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents)
return true;
final now = DateTime.now();
final difference = now.difference(_lastLoadTime!);
@@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier {
}
/// Charger les événements d'un utilisateur via l'API
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
Future<void> loadUserEvents(String userId,
{bool canViewAllEvents = false, bool forceReload = false}) async {
PerformanceMonitor.start('EventProvider.loadUserEvents');
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
print(
'Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
PerformanceMonitor.end('EventProvider.loadUserEvents');
return;
}
@@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier {
notifyListeners();
try {
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
print(
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
PerformanceMonitor.start('EventProvider.getEvents_API');
// Charger via l'API - les permissions sont vérifiées côté serveur
@@ -61,9 +66,8 @@ class EventProvider with ChangeNotifier {
final usersData = result['users'] as Map<String, dynamic>;
// Stocker les utilisateurs dans le cache
_usersCache = usersData.map((key, value) =>
MapEntry(key, value as Map<String, dynamic>)
);
_usersCache = usersData
.map((key, value) => MapEntry(key, value as Map<String, dynamic>));
print('Found ${eventsData.length} events from API');
@@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier {
// Parser chaque événement
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
final event =
EventModel.fromMap(eventData, eventData['id'] as String);
allEvents.add(event);
} catch (e) {
print('Failed to parse event ${eventData['id']}: $e');
@@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('Successfully loaded ${_events.length} events ($failedCount failed)');
print(
'Successfully loaded ${_events.length} events ($failedCount failed)');
_isLoading = false;
notifyListeners();
@@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier {
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
Future<void> loadMonthEvents(String userId, int year, int month,
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
{bool canViewAllEvents = false,
bool forceReload = false,
bool silent = false}) async {
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
// Vérifier le cache
@@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier {
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
final result = await _dataService.getEventsByMonth(
userId: userId,
year: year,
month: month
);
userId: userId, year: year, month: month);
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>;
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
_usersCache.addAll(
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
);
_usersCache.addAll(usersData
.map((key, value) => MapEntry(key, value as Map<String, dynamic>)));
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
@@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier {
// Parser les événements
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
final event =
EventModel.fromMap(eventData, eventData['id'] as String);
monthEvents.add(event);
} catch (e) {
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
@@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
print(
'[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
if (!silent) {
_isLoading = false;
@@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier {
/// Précharger les mois adjacents en arrière-plan
void preloadAdjacentMonths(String userId, int year, int month,
{bool canViewAllEvents = false}) {
// Mois précédent
final prevMonth = month == 1 ? 12 : month - 1;
final prevYear = month == 1 ? year - 1 : year;
@@ -230,8 +234,10 @@ class EventProvider with ChangeNotifier {
}
/// Recharger les événements (utilise le dernier userId)
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
Future<void> refreshEvents(String userId,
{bool canViewAllEvents = false}) async {
await loadUserEvents(userId,
canViewAllEvents: canViewAllEvents, forceReload: true);
}
/// Récupérer un événement spécifique par ID
@@ -243,6 +249,41 @@ class EventProvider with ChangeNotifier {
}
}
/// Recherche des événements accessibles à l'utilisateur.
Future<List<EventModel>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) {
return [];
}
final result = await _dataService.searchEvents(
userId: userId,
query: trimmedQuery,
limit: limit,
);
final events = <EventModel>[];
for (final eventData in result) {
try {
final eventId = eventData['id'] as String?;
if (eventId == null || eventId.isEmpty) {
continue;
}
events.add(EventModel.fromMap(eventData, eventId));
} catch (e) {
print('Failed to parse searched event ${eventData['id']}: $e');
}
}
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
return events;
}
/// Ajouter un nouvel événement
Future<void> addEvent(EventModel event) async {
try {
@@ -250,7 +291,8 @@ class EventProvider with ChangeNotifier {
_events.add(event);
// Ajouter dans le cache par mois
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
final monthKey =
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
if (_eventsByMonth.containsKey(monthKey)) {
_eventsByMonth[monthKey]!.add(event);
}
@@ -272,8 +314,10 @@ class EventProvider with ChangeNotifier {
_events[index] = event;
// Mettre à jour dans le cache par mois
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
final oldMonthKey =
'${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
final newMonthKey =
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
if (oldMonthKey != newMonthKey) {
@@ -286,7 +330,8 @@ class EventProvider with ChangeNotifier {
} else {
// Même mois, juste mettre à jour
if (_eventsByMonth.containsKey(newMonthKey)) {
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
final monthIndex = _eventsByMonth[newMonthKey]!
.indexWhere((e) => e.id == event.id);
if (monthIndex != -1) {
_eventsByMonth[newMonthKey]![monthIndex] = event;
}
@@ -308,7 +353,8 @@ class EventProvider with ChangeNotifier {
// Trouver l'événement pour obtenir sa date avant de le supprimer
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
final monthKey =
'${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
// Supprimer de _events
_events.removeWhere((event) => event.id == eventId);
+51 -5
View File
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser;
RoleModel? _currentRole;
final FirebaseAuth _auth = FirebaseAuth.instance;
FirebaseAuth? _auth;
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService);
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
/// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData({bool forceReload = false}) async {
if (_auth.currentUser == null) {
// Si FirebaseAuth n'est pas encore disponible
final FirebaseAuth auth;
try {
auth = _getAuthInstance();
} catch (e) {
print('Auth instance not ready in loadUserData: $e');
return;
}
if (auth.currentUser == null) {
print('No current user in Auth');
return;
}
// Bootstrap léger : rendre l'UID disponible tout de suite pour les écrans
// qui en ont besoin, même si le profil complet n'est pas encore chargé.
if (_currentUser == null) {
final firebaseUser = auth.currentUser!;
_currentUser = UserModel(
uid: firebaseUser.uid,
email: firebaseUser.email ?? '',
firstName: '',
lastName: '',
role: 'USER',
phoneNumber: '',
profilePhotoUrl: firebaseUser.photoURL ?? '',
);
_currentRole = RoleModel(
id: 'USER',
name: '',
permissions: const [],
);
notifyListeners();
}
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReloadUserData()) {
print('Using cached user data');
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
_isLoadingUserData = true;
PerformanceMonitor.start('LocalUserProvider.loadUserData');
print('Loading user data for: ${_auth.currentUser!.uid}');
print('Loading user data for: ${_auth!.currentUser!.uid}');
try {
// Utiliser la Cloud Function getCurrentUser
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
Future<UserCredential> signInWithEmailAndPassword(
String email, String password) async {
try {
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
final auth = _getAuthInstance();
UserCredential userCredential = await auth.signInWithEmailAndPassword(
email: email, password: password);
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
// pour ne pas bloquer la navigation
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
/// Déconnexion
Future<void> signOut() async {
await _auth.signOut();
try {
final auth = _getAuthInstance();
await auth.signOut();
} catch (e) {
debugPrint('Error during signOut: $e');
}
clearUser();
}
FirebaseAuth _getAuthInstance() {
try {
_auth ??= FirebaseAuth.instance;
return _auth!;
} catch (e, st) {
debugPrint('[LocalUserProvider] FirebaseAuth.instance access error: $e\n$st');
throw Exception('FirebaseAuth not available');
}
}
/// Vérifie si l'utilisateur a une permission spécifique
bool hasPermission(String permission) {
return _currentRole?.permissions.contains(permission) ?? false;
+2 -2
View File
@@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService;
/// 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;
FirebaseFirestore get _firestore => FirebaseFirestore.instance;
FirebaseAuth get _auth => FirebaseAuth.instance;
/// Stream des alertes pour l'utilisateur connecté
Stream<List<AlertModel>> getAlertsStream() {
+2
View File
@@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService {
statusCode: response.statusCode,
);
}
} on ApiException {
rethrow;
} catch (e) {
DebugLog.error('[API] Error during request: $functionName', e);
throw ApiException(
+79
View File
@@ -0,0 +1,79 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../firebase_options.dart';
import '../config/api_config.dart';
import 'cache_service.dart';
/// Service responsable des initialisations lourdes en tâche de fond.
///
/// Objectif : réduire au maximum le travail synchrone dans main(),
/// afficher immédiatement une UI minimale, puis effectuer l'init asynchrone.
class AppInitializer with ChangeNotifier {
bool _isInitialized = false;
bool _isInitializing = false;
bool get isInitialized => _isInitialized;
bool get isInitializing => _isInitializing;
final CacheService cacheService = CacheService();
/// Démarre l'initialisation asynchrone. Idempotent.
Future<void> initialize() async {
if (_isInitialized || _isInitializing) return;
_isInitializing = true;
notifyListeners();
try {
// Initialiser Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configurer les émulateurs en dev si demandé
if (ApiConfig.isDevelopment) {
try {
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
} catch (e) {
// Ignorer si non supporté
if (kDebugMode) print('Emulator setup failed: $e');
}
}
// Initialiser le cache local sans bloquer l'écran de démarrage.
unawaited(cacheService.init());
// Précharger des assets critiques de façon asynchrone
unawaited(_preloadAssets());
// TODO: lancer ici d'autres initialisations non bloquantes
_isInitialized = true;
_isInitializing = false;
notifyListeners();
} catch (e, st) {
if (kDebugMode) print('AppInitializer failed: $e\n$st');
_isInitializing = false;
// Ne rethrow pas pour éviter de planter l'app; laisser l'UI gérer les erreurs.
notifyListeners();
}
}
Future<void> _preloadAssets() async {
try {
// Charger quelques assets en mémoire pour rendre l'affichage initial fluide
await rootBundle.load('assets/logos/RectangleLogoBlack.png');
await rootBundle.load('assets/logos/SquareLogoWhite.png');
} catch (e) {
if (kDebugMode) print('Preload assets failed: $e');
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service simple de cache local basé sur SharedPreferences.
///
/// Fonctionne sur mobile et sur Flutter Web pour conserver des données
/// locales légères quand cela apporte une vraie valeur.
class CacheService {
SharedPreferences? _prefs;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
bool ready() => _prefs != null;
Future<void> setJson(String key, Map<String, dynamic> value) async {
if (_prefs == null) return;
await _prefs!.setString(key, jsonEncode(value));
}
Map<String, dynamic>? getJson(String key) {
if (_prefs == null) return null;
final s = _prefs!.getString(key);
if (s == null) return null;
try {
return jsonDecode(s) as Map<String, dynamic>;
} catch (e) {
if (kDebugMode) print('CacheService getJson error: $e');
return null;
}
}
Future<void> setString(String key, String value) async {
if (_prefs == null) return;
await _prefs!.setString(key, value);
}
String? getString(String key) => _prefs?.getString(key);
}
+32 -2
View File
@@ -139,9 +139,15 @@ class DataService {
}
/// Supprime un équipement
Future<void> deleteEquipment(String equipmentId) async {
Future<void> deleteEquipment(String equipmentId,
{bool forceDelete = false}) async {
try {
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId});
await _apiService.call('deleteEquipment', {
'equipmentId': equipmentId,
'forceDelete': forceDelete,
});
} on ApiException {
rethrow;
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
}
@@ -295,6 +301,30 @@ class DataService {
}
}
/// Recherche des événements accessibles à l'utilisateur.
Future<List<Map<String, dynamic>>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
try {
final result = await _apiService.call('searchEvents', {
'userId': userId,
'query': query,
'limit': limit,
});
final events = result['events'] as List<dynamic>?;
if (events == null) {
return [];
}
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la recherche d\'événements: $e');
}
}
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
try {
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions
class EmailService {
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
FirebaseFunctions get _functions => FirebaseFunctions.instanceFor(region: 'europe-west9');
/// Envoie un email d'alerte à un utilisateur
///
@@ -4,6 +4,44 @@ import 'package:em2rp/services/api_service.dart';
class EventPreparationService {
final ApiService _apiService = apiService;
/// Retourne true si l'équipement était absent du flux événementiel.
///
/// Cas typique: matériel jamais emporté au départ, donc absent au retour,
/// mais qui ne doit jamais être classé en [LOST].
static bool isEquipmentNotTakenToEvent({
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isMissingAtReturn) {
return false;
}
final loadedQuantity = quantityAtLoading ?? 0;
return !isLoaded || isMissingAtLoading || loadedQuantity <= 0;
}
/// Retourne true uniquement si l'équipement doit être classé perdu.
static bool shouldMarkEquipmentAsLost({
required bool isReturnValidationStep,
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isReturnValidationStep || !isMissingAtReturn) {
return false;
}
return !isEquipmentNotTakenToEvent(
isMissingAtReturn: isMissingAtReturn,
isLoaded: isLoaded,
isMissingAtLoading: isMissingAtLoading,
quantityAtLoading: quantityAtLoading,
);
}
// === PRÉPARATION ===
/// Valider un équipement individuel en préparation
+134
View File
@@ -0,0 +1,134 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import '../views/login_page.dart';
import '../utils/colors.dart';
/// Gate de démarrage qui attend la restauration Firebase Auth avant
/// d'afficher soit le contenu connecté, soit la page de connexion.
class AppStartGate extends StatelessWidget {
const AppStartGate({super.key});
@override
Widget build(BuildContext context) {
// Sur le web, certaines erreurs natives (ex: cookies tiers bloqués)
// peuvent faire remonter une FirebaseException sur le stream d'auth.
// Pour éviter que StreamBuilder reçoive une erreur qui casse le build
// (TypeError JS interop), on "handleError" et on transforme l'erreur
// en une valeur nulle (pas d'utilisateur) afin de garder l'app stable.
// Accès protégé à `FirebaseAuth.instance` sur le web certaines erreurs
// d'interop JS peuvent produire des TypeError non compatibles. Nous
// attrapons toute exception lors de l'accès et fournissons un stream
// neutre (pas d'utilisateur) afin de garder l'UI stable.
late final Stream<User?> safeAuthStream;
try {
safeAuthStream = FirebaseAuth.instance
.authStateChanges()
.handleError((error, stack) {
// Log pour debug ; ne rethrow pas
debugPrint('[AppStartGate] authStateChanges error: $error');
});
} catch (e, st) {
// Sur certaines configurations web l'accès à FirebaseAuth.instance
// peut échouer au niveau JS interop. On log puis on fournit un stream
// qui émet une seule valeur nulle pour indiquer "pas d'utilisateur".
debugPrint('[AppStartGate] FirebaseAuth.instance access error: $e\n$st');
safeAuthStream = Stream<User?>.value(null);
}
return StreamBuilder<User?>(
stream: safeAuthStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _StartupSplashScreen();
}
if (snapshot.hasError) {
// En théorie handleError évite d'arriver ici, mais on garde
// une protection supplémentaire.
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
return const _StartupSplashScreen(message: 'Erreur de connexion');
}
if (snapshot.data != null) {
return const _AuthenticatedBootstrap();
}
return const LoginPage();
},
);
}
}
class _AuthenticatedBootstrap extends StatefulWidget {
const _AuthenticatedBootstrap();
@override
State<_AuthenticatedBootstrap> createState() =>
_AuthenticatedBootstrapState();
}
class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_redirectAfterAuth();
});
}
Future<void> _redirectAfterAuth() async {
final fragment = Uri.base.fragment;
if (!mounted) return;
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
Navigator.of(context).pushReplacementNamed(fragment);
} else {
Navigator.of(context).pushReplacementNamed('/calendar');
}
}
@override
Widget build(BuildContext context) {
return const _StartupSplashScreen();
}
}
class _StartupSplashScreen extends StatelessWidget {
final String message;
const _StartupSplashScreen({this.message = 'Démarrage...'});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 72,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 24),
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message),
],
),
),
);
}
}
+23 -2
View File
@@ -1,27 +1,48 @@
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/login_page.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/views/login_page.dart';
class AuthGuard extends StatelessWidget {
final Widget child;
final String? requiredPermission;
final bool allowWhileLoading;
const AuthGuard({
super.key,
required this.child,
this.requiredPermission,
this.allowWhileLoading = false,
});
@override
Widget build(BuildContext context) {
final localAuthProvider = Provider.of<LocalUserProvider>(context);
final firebaseUser = FirebaseAuth.instance.currentUser;
// Log pour débug
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
// Si Firebase n'a pas encore restauré la session ou si le profil charge,
// afficher un écran neutre plutôt que la page de connexion.
if (firebaseUser != null &&
(localAuthProvider.currentUser == null ||
localAuthProvider.isLoadingUserData)) {
if (allowWhileLoading) {
return child;
}
return const Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
),
);
}
// Si l'utilisateur n'est pas connecté
if (localAuthProvider.currentUser == null) {
if (firebaseUser == null || localAuthProvider.currentUser == null) {
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
return const LoginPage();
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:em2rp/services/api_service.dart';
import 'package:flutter/material.dart';
/// Utilitaires partages pour la suppression d'equipement avec forcage.
class EquipmentDeleteUtils {
static const String _legacyConflictToken = 'future_event_assignment';
static const List<String> _conflictMessageTokens = [
'cannot delete equipment because it is assigned to upcoming events',
'cannot delete equipment because it is assigned to future events',
'assigned to upcoming events',
'assigned to future events',
];
static const String deleteDialogTitle = 'Confirmer la suppression';
static const String deleteDialogCancelLabel = 'Annuler';
static const String deleteDialogConfirmLabel = 'Supprimer';
static const String deleteSuccessMessage = 'Équipement supprimé avec succès';
/// Retourne [name] si renseigne, sinon [id].
static String resolveEquipmentLabel({required String id, String? name}) {
final trimmedName = name?.trim();
if (trimmedName == null || trimmedName.isEmpty) {
return id;
}
return trimmedName;
}
/// Construit le message de confirmation de suppression d'un equipement.
static String buildSingleDeleteConfirmationMessage(String equipmentLabel) {
return 'Voulez-vous vraiment supprimer "$equipmentLabel" ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de confirmation de suppression multiple.
static String buildBulkDeleteConfirmationMessage(int selectedCount) {
return 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de succes de suppression multiple.
static String buildBulkDeleteSuccessMessage(int deletedCount) {
return '$deletedCount équipement(s) supprimé(s) avec succès';
}
/// Construit un message d'erreur de suppression homogene.
static String buildDeleteErrorMessage(Object error) {
return 'Erreur lors de la suppression : $error';
}
/// Indique si l'erreur correspond a un conflit de suppression 409.
static bool isFutureAssignmentDeleteConflict(Object error) {
if (error is ApiException && !error.isConflict) {
return false;
}
final normalizedMessage = _normalizeErrorMessage(error);
if (normalizedMessage.contains(_legacyConflictToken)) {
return true;
}
return _conflictMessageTokens.any(normalizedMessage.contains);
}
/// Affiche la confirmation de suppression forcee.
static Future<bool> showForceDeleteDialog(
BuildContext context, {
required String equipmentLabel,
}) async {
final shouldForceDelete = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Équipement utilisé dans un événement à venir'),
content: Text(
'"$equipmentLabel" est assigné à au moins un événement à venir.\n\n'
'Voulez-vous forcer la suppression ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Forcer la suppression'),
),
],
),
);
return shouldForceDelete == true;
}
/// Execute une suppression, puis propose un forcage en cas de conflit 409.
static Future<bool> deleteWithFutureAssignmentCheck({
required BuildContext context,
required String equipmentLabel,
required Future<void> Function({bool forceDelete}) deleteEquipment,
}) async {
try {
await deleteEquipment(forceDelete: false);
return true;
} catch (error) {
if (!isFutureAssignmentDeleteConflict(error)) {
rethrow;
}
if (!context.mounted) {
return false;
}
final shouldForceDelete = await showForceDeleteDialog(
context,
equipmentLabel: equipmentLabel,
);
if (!shouldForceDelete) {
return false;
}
await deleteEquipment(forceDelete: true);
return true;
}
}
static String _normalizeErrorMessage(Object error) {
if (error is ApiException) {
return error.message.toLowerCase();
}
return error.toString().toLowerCase();
}
}
@@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class FirebaseStorageManager {
final FirebaseStorage _storage = FirebaseStorage.instance;
final DataService _dataService = DataService(FirebaseFunctionsApiService());
FirebaseStorage get _storage => FirebaseStorage.instance;
final DataService _dataService = DataService(apiService);
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
/// Pour le Web, on fixe l'extension .jpg.
+8 -11
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../providers/local_user_provider.dart';
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
passwordController.text,
);
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
await localAuthProvider.loadUserData();
// --- Étape 2: Charger les données utilisateur en arrière-plan ---
unawaited(
localAuthProvider.loadUserData().catchError((e) {
debugPrint('Erreur chargement profil après connexion : $e');
}),
);
// Vérifier si le contexte est toujours valide
if (context.mounted) {
// Vérifier si l'utilisateur a bien été chargé dans le provider
if (localAuthProvider.currentUser != null) {
// Utiliser pushReplacementNamed pour une transition propre
Navigator.of(context, rootNavigator: true)
.pushReplacementNamed('/calendar');
} else {
errorMessage =
'Erreur inattendue après connexion: Données utilisateur non chargées.';
isLoading = false;
notifyListeners();
}
}
} on FirebaseAuthException catch (e) {
// Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.)
+580 -57
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:em2rp/providers/local_user_provider.dart';
@@ -10,6 +11,7 @@ import 'package:provider/provider.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart';
import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
@@ -40,8 +42,18 @@ class _CalendarPageState extends State<CalendarPage> {
int _selectedEventIndex = 0;
String?
_selectedUserId; // Filtre par utilisateur (null = tous les événements)
final TextEditingController _searchController = TextEditingController();
Timer? _searchDebounce;
List<EventModel> _searchResults = [];
String _searchQuery = '';
String? _searchError;
bool _isSearching = false;
int _searchRequestId = 0;
bool _isMobileSearchVisible = false;
bool _isRefreshing = false;
double _detailsPaneFraction = 0.35;
String? _lastLoadedUserId;
bool _initialLoadScheduled = false;
@override
void initState() {
@@ -105,19 +117,22 @@ class _CalendarPageState extends State<CalendarPage> {
}
}
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
Future<void> _loadEventsAsync() async {
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
await _loadEvents();
// Sélectionner l'événement approprié après le chargement
if (mounted) {
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
_selectDefaultEvent();
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
void _scheduleInitialEventsLoad(String? userId) {
if (userId == null || userId == _lastLoadedUserId || _initialLoadScheduled) {
return;
}
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
_initialLoadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
if (!mounted) return;
if (_lastLoadedUserId == userId) return;
await _loadCurrentMonthEvents();
_lastLoadedUserId = userId;
} finally {
_initialLoadScheduled = false;
}
});
}
/// Sélectionne automatiquement l'événement le plus proche de maintenant
@@ -188,9 +203,15 @@ class _CalendarPageState extends State<CalendarPage> {
}
}
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif).
List<EventModel> _filterEventsByUser(List<EventModel> allEvents) {
if (_selectedUserId == null) {
return allEvents; // Pas de filtre, retourner tous les événements
}
@@ -208,6 +229,524 @@ class _CalendarPageState extends State<CalendarPage> {
}).toList();
}
bool _isSameDay(DateTime left, DateTime right) {
return left.year == right.year &&
left.month == right.month &&
left.day == right.day;
}
List<EventModel> _getEventsForDay(
List<EventModel> events,
DateTime? day, {
EventModel? selectedEvent,
}) {
if (day == null) {
return [];
}
final dayEvents = events
.where((event) => _isSameDay(event.startDateTime, day))
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
if (selectedEvent != null &&
_isSameDay(selectedEvent.startDateTime, day) &&
!dayEvents.any((event) => event.id == selectedEvent.id)) {
dayEvents.add(selectedEvent);
dayEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
}
return dayEvents;
}
List<EventModel> _getDetailsEvents(List<EventModel> events) {
final mergedEvents = [...events];
if (_selectedEvent != null &&
!mergedEvents.any((event) => event.id == _selectedEvent!.id)) {
mergedEvents.add(_selectedEvent!);
}
mergedEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
return mergedEvents;
}
String _formatSearchResultDate(DateTime dateTime) {
return DateFormat('EEE d MMM yyyy • HH:mm', 'fr_FR').format(dateTime);
}
Color _getStatusColor(EventStatus status) {
switch (status) {
case EventStatus.confirmed:
return Colors.green;
case EventStatus.canceled:
return Colors.red;
case EventStatus.waitingForApproval:
default:
return Colors.amber;
}
}
/// Combine uniquement le filtre utilisateur avec la vue calendrier.
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
return _filterEventsByUser(allEvents);
}
void _cancelPendingSearch() {
_searchDebounce?.cancel();
_searchDebounce = null;
}
void _scheduleSearch(String value) {
_cancelPendingSearch();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
_runSearch(value);
});
}
void _onSearchChanged(String value) {
final isMobile = MediaQuery.of(context).size.width < 600;
if (isMobile && value.isNotEmpty && !_isMobileSearchVisible) {
setState(() {
_isMobileSearchVisible = true;
});
}
setState(() {
_searchQuery = value;
});
if (value.trim().isEmpty) {
_cancelPendingSearch();
setState(() {
_searchResults = [];
_searchError = null;
_isSearching = false;
});
return;
}
_scheduleSearch(value);
}
void _clearSearch() {
_cancelPendingSearch();
if (_searchController.text.isEmpty) {
return;
}
_searchController.clear();
setState(() {
_searchQuery = '';
_searchResults = [];
_searchError = null;
_isSearching = false;
});
}
Future<void> _runSearch(String value) async {
final query = value.trim();
if (query.isEmpty) {
return;
}
final localUserProvider = context.read<LocalUserProvider>();
final userId = localUserProvider.uid;
if (userId == null) {
return;
}
final searchId = ++_searchRequestId;
setState(() {
_isSearching = true;
_searchError = null;
_searchResults = [];
});
try {
final eventProvider = context.read<EventProvider>();
final results = await eventProvider.searchEvents(
userId: userId,
query: query,
);
if (!mounted) {
return;
}
if (_searchQuery.trim() != query) {
return;
}
if (searchId != _searchRequestId) {
return;
}
setState(() {
_searchResults = results;
_searchError = null;
_isSearching = false;
});
} catch (e) {
if (!mounted || _searchQuery.trim() != query) {
return;
}
setState(() {
_searchResults = [];
_searchError = 'Erreur lors de la recherche : $e';
_isSearching = false;
});
}
}
Widget _buildDesktopFiltersBar({required bool canViewAllUserEvents}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: Colors.grey[100],
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Rechercher (titre, description, lieu)',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
tooltip: 'Effacer la recherche',
icon: const Icon(Icons.close),
onPressed: _clearSearch,
)
: null,
isDense: true,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
),
if (canViewAllUserEvents) ...[
const SizedBox(width: 12),
_buildCompactUserFilter(),
],
],
),
);
}
Widget _buildCompactUserFilter() {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Row(
children: [
Expanded(
child: UserFilterDropdown(
selectedUserId: _selectedUserId,
onUserSelected: (userId) {
setState(() {
_selectedUserId = userId;
});
},
),
),
],
),
);
}
Widget _buildMobileSearchBar() {
return Container(
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
children: [
Row(
children: [
IconButton(
icon: Icon(
_isMobileSearchVisible ? Icons.search_off : Icons.search,
color: AppColors.rouge,
),
tooltip: _isMobileSearchVisible
? 'Masquer la recherche'
: 'Afficher la recherche',
onPressed: () {
setState(() {
_isMobileSearchVisible = !_isMobileSearchVisible;
});
},
),
Expanded(
child: Text(
_searchQuery.isEmpty
? 'Rechercher un événement'
: 'Recherche active',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
if (_searchQuery.isNotEmpty)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Effacer la recherche',
onPressed: _clearSearch,
),
],
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _isMobileSearchVisible
? Padding(
key: const ValueKey('mobile-search-visible'),
padding: const EdgeInsets.only(top: 4, left: 8, right: 8),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Titre, description ou lieu',
prefixIcon:
const Icon(Icons.search, color: AppColors.rouge),
isDense: true,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
)
: const SizedBox.shrink(
key: ValueKey('mobile-search-hidden'),
),
),
],
),
);
}
Widget _buildSearchResultsPanel({required bool isMobile}) {
final hasQuery = _searchQuery.trim().isNotEmpty;
if (!hasQuery && !_isSearching && _searchError == null) {
return const SizedBox.shrink();
}
final panelPadding = EdgeInsets.symmetric(
horizontal: isMobile ? 8 : 16,
vertical: 8,
);
return Container(
width: double.infinity,
padding: panelPadding,
color: Colors.grey[50],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.manage_search, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
hasQuery
? 'Résultats pour "$_searchQuery"'
: 'Recherche d’événements',
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
if (_isSearching)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
if (_searchError != null) ...[
const SizedBox(height: 8),
Text(
_searchError!,
style: const TextStyle(color: Colors.red),
),
] else if (!hasQuery) ...[
const SizedBox(height: 8),
Text(
'Saisissez un titre, une description ou un lieu pour lancer la recherche.',
style: TextStyle(color: Colors.grey.shade700),
),
] else if (!_isSearching) ...[
const SizedBox(height: 8),
if (_searchResults.isEmpty)
Text(
'Aucun résultat trouvé.',
style: TextStyle(color: Colors.grey.shade700),
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: isMobile ? 240 : 280,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: _searchResults.length,
physics: const ClampingScrollPhysics(),
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
final event = _searchResults[index];
final isSelected = _selectedEvent?.id == event.id;
return Material(
color: isSelected
? AppColors.rouge.withOpacity(0.08)
: Colors.white,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _onSearchResultSelected(event),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _getStatusColor(event.status),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_formatSearchResultDate(
event.startDateTime),
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 12,
),
),
if (event.address.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
],
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right,
color: Colors.grey),
],
),
),
),
);
},
),
),
],
],
),
);
}
Future<void> _onSearchResultSelected(EventModel event) async {
final localUserProvider = context.read<LocalUserProvider>();
final eventProvider = context.read<EventProvider>();
final userId = localUserProvider.uid;
if (userId == null) {
return;
}
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
final selectedDay = DateTime(
event.startDateTime.year,
event.startDateTime.month,
event.startDateTime.day,
);
final shouldLoadMonth = _focusedDay.year != event.startDateTime.year ||
_focusedDay.month != event.startDateTime.month ||
eventProvider.events.isEmpty;
if (shouldLoadMonth) {
await eventProvider.loadMonthEvents(
userId,
event.startDateTime.year,
event.startDateTime.month,
canViewAllEvents: canViewAllEvents,
);
eventProvider.preloadAdjacentMonths(
userId,
event.startDateTime.year,
event.startDateTime.month,
canViewAllEvents: canViewAllEvents,
);
}
if (!mounted) {
return;
}
final eventsForSelectedDay = _getEventsForDay(
eventProvider.events,
selectedDay,
selectedEvent: event,
);
final isMobile = MediaQuery.of(context).size.width < 600;
setState(() {
_focusedDay = selectedDay;
_selectedDay = selectedDay;
_selectedEvent = event;
_selectedEventIndex =
eventsForSelectedDay.indexWhere((e) => e.id == event.id);
if (_selectedEventIndex < 0) {
_selectedEventIndex = 0;
}
_calendarCollapsed = false;
if (isMobile) {
_isMobileSearchVisible = true;
}
});
}
void _changeWeek(int delta) {
setState(() {
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
@@ -238,10 +777,12 @@ class _CalendarPageState extends State<CalendarPage> {
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
if (_selectedEvent != null) {
final detailsEvents = _getDetailsEvents(filteredEvents);
return EventDetails(
event: _selectedEvent!,
selectedDate: _selectedDay,
events: filteredEvents,
events: detailsEvents,
onSelectEvent: (event, date) {
setState(() {
_selectedEvent = event;
@@ -292,10 +833,13 @@ class _CalendarPageState extends State<CalendarPage> {
Widget build(BuildContext context) {
final eventProvider = Provider.of<EventProvider>(context);
final localUserProvider = Provider.of<LocalUserProvider>(context);
_scheduleInitialEventsLoad(localUserProvider.uid);
final canCreateEvents = localUserProvider.hasPermission('create_events');
final canViewAllUserEvents =
localUserProvider.hasPermission('view_all_user_events');
final isMobile = MediaQuery.of(context).size.width < 600;
final showSearchResults =
_searchQuery.trim().isNotEmpty || _isSearching || _searchError != null;
// Appliquer le filtre utilisateur si actif
final filteredEvents = _getFilteredEvents(eventProvider.events);
@@ -343,33 +887,11 @@ class _CalendarPageState extends State<CalendarPage> {
drawer: const MainDrawer(currentPage: '/calendar'),
body: Column(
children: [
// Filtre utilisateur dans le corps de la page
if (canViewAllUserEvents && !isMobile)
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey[100],
child: Row(
children: [
const Icon(Icons.filter_list, color: AppColors.rouge),
const SizedBox(width: 12),
const Text(
'Filtrer par utilisateur :',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
),
const SizedBox(width: 16),
Expanded(
child: UserFilterDropdown(
selectedUserId: _selectedUserId,
onUserSelected: (userId) {
setState(() {
_selectedUserId = userId;
});
},
),
),
],
),
),
if (isMobile)
_buildMobileSearchBar()
else
_buildDesktopFiltersBar(canViewAllUserEvents: canViewAllUserEvents),
if (showSearchResults) _buildSearchResultsPanel(isMobile: isMobile),
// Corps du calendrier
Expanded(
child: isMobile
@@ -426,18 +948,19 @@ class _CalendarPageState extends State<CalendarPage> {
}
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
final eventsForSelectedDay = _selectedDay == null
? []
: filteredEvents
.where((e) =>
e.startDateTime.year == _selectedDay!.year &&
e.startDateTime.month == _selectedDay!.month &&
e.startDateTime.day == _selectedDay!.day)
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final eventsForSelectedDay = _getEventsForDay(
filteredEvents,
_selectedDay,
selectedEvent: _selectedEvent,
);
final hasEvents = eventsForSelectedDay.isNotEmpty;
final currentEvent =
hasEvents && _selectedEventIndex < eventsForSelectedDay.length
final selectedEventIndex = _selectedEvent == null
? -1
: eventsForSelectedDay
.indexWhere((event) => event.id == _selectedEvent!.id);
final currentEvent = hasEvents && selectedEventIndex >= 0
? eventsForSelectedDay[selectedEventIndex]
: hasEvents && _selectedEventIndex < eventsForSelectedDay.length
? eventsForSelectedDay[_selectedEventIndex]
: null;
@@ -581,7 +1104,7 @@ class _CalendarPageState extends State<CalendarPage> {
child: EventDetails(
event: eventsForSelectedDay[_selectedEventIndex],
selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(),
events: eventsForSelectedDay,
onSelectEvent: (event, date) {
final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id);
@@ -600,7 +1123,7 @@ class _CalendarPageState extends State<CalendarPage> {
),
),
),
// Vue détail (prend tout l'espace quand calendrier caché)
// Vue détail (prend tout l'espace quand calendrier cache)
if (_calendarCollapsed && _selectedDay != null)
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
@@ -647,7 +1170,7 @@ class _CalendarPageState extends State<CalendarPage> {
child: EventDetails(
event: currentEvent,
selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(),
events: eventsForSelectedDay,
onSelectEvent: (event, date) {
final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id);
+31 -16
View File
@@ -102,7 +102,6 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
child: ListView(
padding: const EdgeInsets.all(24),
children: [
// Nom
TextFormField(
controller: _nameController,
@@ -259,7 +258,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.scale),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -281,7 +281,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Longueur (cm)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -300,7 +301,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Largeur (cm)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -319,7 +321,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Hauteur (cm)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -454,6 +457,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
Future<void> _selectEquipment() async {
final equipmentProvider = context.read<EquipmentProvider>();
// Toujours charger la liste complète pour éviter d'afficher uniquement
// la page paginée active d'un autre écran.
await equipmentProvider.loadEquipments();
if (!mounted) return;
await showDialog(
context: context,
builder: (context) => _EquipmentSelectorDialog(
@@ -462,6 +470,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
),
);
if (!mounted) return;
setState(() {});
}
@@ -537,7 +546,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId,
);
} catch (e) {
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
DebugLog.error(
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
}
}
@@ -575,7 +585,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
});
// Gérer les équipements ajoutés
final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet());
final addedEquipment =
_selectedEquipmentIds.difference(container.equipmentIds.toSet());
for (final equipmentId in addedEquipment) {
try {
await provider.addEquipmentToContainer(
@@ -583,12 +594,14 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId,
);
} catch (e) {
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
DebugLog.error(
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
}
}
// Gérer les équipements retirés
final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds);
final removedEquipment =
container.equipmentIds.toSet().difference(_selectedEquipmentIds);
for (final equipmentId in removedEquipment) {
try {
await provider.removeEquipmentFromContainer(
@@ -596,7 +609,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId,
);
} catch (e) {
DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e);
DebugLog.error(
'Erreur lors du retrait de l\'équipement $equipmentId', e);
}
}
@@ -632,7 +646,8 @@ class _EquipmentSelectorDialog extends StatefulWidget {
});
@override
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState();
State<_EquipmentSelectorDialog> createState() =>
_EquipmentSelectorDialogState();
}
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
@@ -797,7 +812,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: _filterCategory == null ? Colors.white : Colors.black,
color:
_filterCategory == null ? Colors.white : Colors.black,
),
),
const SizedBox(width: 8),
@@ -815,7 +831,9 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: _filterCategory == category ? Colors.white : Colors.black,
color: _filterCategory == category
? Colors.white
: Colors.black,
),
),
);
@@ -991,6 +1009,3 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
}
}
}
+49 -23
View File
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/qr_code_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
Future<void> _loadMaintenances() async {
try {
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
final maintenances = await _equipmentService
.getMaintenancesForEquipment(widget.equipment.id);
setState(() {
_maintenances = maintenances;
_isLoadingMaintenances = false;
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
}
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
const SizedBox(height: 24),
// 3. Notes
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
if (widget.equipment.notes != null &&
widget.equipment.notes!.isNotEmpty) ...[
EquipmentNotesSection(notes: widget.equipment.notes!),
const SizedBox(height: 24),
],
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
);
}
void _showQRCode() {
showDialog(
context: context,
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
),
const SizedBox(height: 4),
Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
.trim(),
style: TextStyle(color: Colors.grey[700]),
),
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
if (widget.equipment.subCategory != null &&
widget.equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'📁 ${widget.equipment.subCategory}',
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
if (!hasPermission) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
content:
Text('Vous n\'avez pas la permission de gérer les maintenances'),
backgroundColor: Colors.orange,
),
);
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
}
void _deleteEquipment() {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: widget.equipment.id,
name: widget.equipment.name,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
// Fermer le dialog
Navigator.pop(context);
Navigator.pop(dialogContext);
// Capturer le ScaffoldMessenger avant la suppression
final scaffoldMessenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final navigator = Navigator.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(widget.equipment.id);
final deleted =
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
widget.equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
// Revenir à la page précédente
navigator.pop();
@@ -455,19 +477,23 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
// Afficher le snackbar (même si le widget est démonté)
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès'),
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
} catch (e) {
// Afficher l'erreur
scaffoldMessenger.showSnackBar(
SnackBar(content: Text('Erreur: $e')),
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
+119 -53
View File
@@ -16,6 +16,7 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
@@ -28,7 +29,6 @@ class EquipmentManagementPage extends StatefulWidget {
_EquipmentManagementPageState();
}
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
@@ -66,7 +66,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
// Pas de setState ici pour éviter les rebuilds pendant le scroll
@@ -76,7 +75,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_isLoadingMore = false;
}).catchError((error) {
_isLoadingMore = false;
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
DebugLog.error(
'[EquipmentManagementPage] Error loading next page', error);
});
}
}
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>(
builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
DebugLog.info(
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
// Afficher l'indicateur de chargement initial uniquement
if (provider.isLoading && provider.equipment.isEmpty) {
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
DebugLog.info(
'[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator());
}
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
);
}
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
DebugLog.info(
'[EquipmentManagementPage] Building list with ${equipments.length} items');
// Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
@@ -540,7 +543,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
activeColor: AppColors.rouge,
)
: CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
backgroundColor:
equipment.category.color.withValues(alpha: 0.2),
child: equipment.category.getIcon(
size: 20,
color: equipment.category.color,
@@ -568,12 +572,14 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
.trim()
.isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'
.trim()
: 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
// Afficher la sous-catégorie si elle existe
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
if (equipment.subCategory != null &&
equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
@@ -615,7 +621,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
tooltip: 'QR Code',
onPressed: () => showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(equipment),
builder: (context) =>
QRCodeDialog.forEquipment(equipment),
),
),
// Bouton Modifier (permission required)
@@ -642,8 +649,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
),
)
);
));
}
Widget _buildQuantityDisplay(EquipmentModel equipment) {
@@ -705,7 +711,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
);
}
// Actions
void _createNewEquipment() {
Navigator.push(
@@ -726,39 +731,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
}
void _deleteEquipment(EquipmentModel equipment) {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: equipment.id,
name: equipment.name,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(equipment.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
final deleted =
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès')),
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -768,46 +798,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
void _deleteSelectedEquipment() async {
if (!hasSelection) return;
final pageContext = context;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
selectedCount,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
final provider = context.read<EquipmentProvider>();
final equipmentById = {
for (final equipment
in provider.equipment)
equipment.id: equipment,
};
var deletedCount = 0;
for (final id in selectedIds) {
await provider.deleteEquipment(id);
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
id: id,
name: equipmentById[id]?.name,
);
final deleted = await EquipmentDeleteUtils
.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: label,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
id,
forceDelete: forceDelete,
);
},
);
if (deleted) {
deletedCount++;
}
}
disableSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
'$selectedCount équipement(s) supprimé(s) avec succès'),
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
deletedCount,
),
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -853,7 +915,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
builder: (context) =>
QRCodeDialog.forEquipment(selectedEquipment.first),
);
}
} else {
@@ -1046,7 +1109,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
updatedAt: DateTime.now(),
);
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
await context
.read<EquipmentProvider>()
.updateEquipment(updatedEquipment);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1184,7 +1249,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
content: Text(
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);
+27 -5
View File
@@ -13,6 +13,7 @@ import 'package:em2rp/services/qr_code_processing_service.dart';
import 'package:em2rp/services/audio_feedback_service.dart';
import 'package:em2rp/services/smart_text_to_speech_service.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/event_preparation_service.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
@@ -1097,6 +1098,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Détermine le statut d'un équipement selon l'étape actuelle
String _determineEquipmentStatus(EventEquipment eq) {
if (_isNotTakenToEventAtReturn(eq)) {
return 'NOT_TAKEN';
}
// Vérifier d'abord si l'équipement est perdu (LOST)
if (_shouldMarkAsLost(eq)) {
return 'LOST';
@@ -1118,14 +1123,31 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// 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 EventPreparationService.shouldMarkEquipmentAsLost(
isReturnValidationStep: _isReturnValidationStep,
isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool _isNotTakenToEventAtReturn(EventEquipment eq) {
if (!_isReturnValidationStep) {
return false;
}
// Si manquant maintenant mais PAS manquant à la préparation = LOST
return eq.isMissingAtReturn && !eq.isMissingAtPreparation;
return EventPreparationService.isEquipmentNotTakenToEvent(
isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool get _isReturnValidationStep {
return _currentStep == PreparationStep.return_ ||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously);
}
/// Vérifie si un équipement est manquant à l'étape actuelle
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
class StartupSplashScreen extends StatelessWidget {
final String message;
const StartupSplashScreen({super.key, this.message = 'Démarrage...'});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 72,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 24),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
color: AppColors.noir,
fontSize: 16,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
bool shouldAutoLoadNextPage({
required bool hasMoreData,
required bool isLoadingMore,
required bool hasClients,
required double maxScrollExtent,
}) {
if (!hasMoreData || isLoadingMore) {
return false;
}
// If the list cannot scroll yet, preload the next page to avoid a truncated view.
return !hasClients || maxScrollExtent <= 0;
}
+1
View File
@@ -17,6 +17,7 @@ dependencies:
cloud_functions: ^6.0.4
google_sign_in: ^7.2.0
firebase_storage: ^13.0.3
shared_preferences: ^2.0.15
# State Management
provider: ^6.1.2
@@ -0,0 +1,61 @@
import 'package:em2rp/views/widgets/event/equipment_selection_pagination.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('shouldAutoLoadNextPage', () {
test('returns false when there is no more data', () {
final result = shouldAutoLoadNextPage(
hasMoreData: false,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 100,
);
expect(result, isFalse);
});
test('returns false while a page is already loading', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: true,
hasClients: true,
maxScrollExtent: 0,
);
expect(result, isFalse);
});
test('returns true when list has no scroll client yet', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: false,
maxScrollExtent: 0,
);
expect(result, isTrue);
});
test('returns true when list is not scrollable yet', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 0,
);
expect(result, isTrue);
});
test('returns false when list is scrollable', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 250,
);
expect(result, isFalse);
});
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"version": "1.1.18",
"version": "1.2.1",
"updateUrl": "https://app.em2events.fr",
"forceUpdate": true,
"releaseNotes": "Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.",
"timestamp": "2026-03-12T20:11:54.548Z"
"releaseNotes": "Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.",
"timestamp": "2026-05-25T21:50:50.578Z"
}