5 Commits

Author SHA1 Message Date
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
43 changed files with 3207 additions and 4688 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/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1773346314557,fda0011c81b6890abb52de8e160b96b7fa61bd4fbb8c45af2fbecb29d5df708d version.json,1777974738862,518123ebb7461c8343d5ad7d08a9bc31ca5555df3d9e09d36442cad4e5a4dcaa
index.html,1773346319918,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 index.html,1777974744949,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1773346397863,2f92f9c59bdab08ddbc8274db4459302bd6134e3987b0decdb26323a257b0ab7 flutter_service_worker.js,1777974838520,527efc67156a0e2688a3aca09ef3f967cbb514258c91dc1d8ad1d6a4935e2c65
assets/FontManifest.json,1773346394287,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 assets/FontManifest.json,1777974834864,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
flutter_bootstrap.js,1773346319903,1a83667573bf9cf4a4a90e3d1631fbc55b97cebfb14c643ddf9d3468bde748ec flutter_bootstrap.js,1777974744934,09a1770005261de742912a7cf492d739d3e263d2383f53cda5ba5bac6896c39c
assets/AssetManifest.json,1773346394287,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 assets/AssetManifest.json,1777974834864,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1773346394287,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 assets/AssetManifest.bin,1777974834864,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/AssetManifest.bin,1773346394287,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 assets/AssetManifest.bin.json,1777974834864,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773346397053,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1777974837564,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1773346394513,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 assets/shaders/ink_sparkle.frag,1777974835049,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1773346397057,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c assets/fonts/MaterialIcons-Regular.otf,1777974837570,213705f583b626b88f6f0c7a122a13567b6492f560ad5284176ef149f0b51fef
assets/NOTICES,1773346394289,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 assets/NOTICES,1777974834870,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
main.dart.js,1773346393292,a9b20044339caf5878c0d72b7a45df204e67eab3d4c288b5964d852059c88bdd main.dart.js,1777974833676,fbb6da7a84cb69d9dfb2a92eac87571303dadec0af700067d2d66ed69db416e8
+2 -1
View File
@@ -45,4 +45,5 @@ app.*.map.json
# Environment configuration with credentials # Environment configuration with credentials
lib/config/env.dev.dart lib/config/env.dev.dart
functions/.env functions/.env
functions/.env.local .env
env.dart
+13
View File
@@ -1,6 +1,19 @@
# Changelog - EM2RP # Changelog - EM2RP
Toutes les modifications notables de ce projet seront documentées dans ce fichier. Toutes les modifications notables de ce projet seront documentées dans ce fichier.
## 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 ## 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. 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
View File
@@ -7,4 +7,3 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
# URL de l'application # URL de l'application
APP_URL="https://app.em2events.fr" APP_URL="https://app.em2events.fr"
GEMINI_API_KEY="AIzaSyBdBdjFLma2pLenmFBlqZHArS4GVF-mclo"
File diff suppressed because it is too large Load Diff
+329 -76
View File
@@ -3,11 +3,8 @@
* Architecture backend sécurisée avec authentification et permissions * Architecture backend sécurisée avec authentification et permissions
*/ */
// Charger les variables d'environnement depuis .env.local (développement) // Charger les variables d'environnement depuis .env
// ou .env (production Firebase) require('dotenv').config();
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
require('dotenv').config({ path: path.join(__dirname, '.env') });
const { onRequest, onCall } = require("firebase-functions/v2/https"); const { onRequest, onCall } = require("firebase-functions/v2/https");
const { onSchedule } = require("firebase-functions/v2/scheduler"); const { onSchedule } = require("firebase-functions/v2/scheduler");
@@ -20,7 +17,6 @@ const { Storage } = require('@google-cloud/storage');
const auth = require('./utils/auth'); const auth = require('./utils/auth');
const helpers = require('./utils/helpers'); const helpers = require('./utils/helpers');
const { generateTTS } = require('./generateTTS'); const { generateTTS } = require('./generateTTS');
const { handleAiEquipmentProposal } = require('./aiEquipmentProposal');
// Initialisation sécurisée // Initialisation sécurisée
if (!admin.apps.length) { if (!admin.apps.length) {
@@ -37,13 +33,6 @@ const httpOptions = {
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS // Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
}; };
// Options dédiées pour les traitements IA potentiellement longs.
const aiHttpOptions = {
...httpOptions,
timeoutSeconds: 300,
memory: '1GiB',
};
// ============================================================================ // ============================================================================
// CORS Middleware // CORS Middleware
// ============================================================================ // ============================================================================
@@ -214,29 +203,51 @@ exports.deleteEquipment = onRequest(httpOptions, withCors(async (req, res) => {
return; return;
} }
const { equipmentId } = req.body.data; const { equipmentId, forceDelete = false } = req.body.data;
if (!equipmentId) { if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' }); res.status(400).json({ error: 'Equipment ID is required' });
return; 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') const eventsSnapshot = await db.collection('events')
.where('status', '!=', 'CANCELLED') .where('status', '!=', 'CANCELLED')
.get(); .get();
const now = new Date();
const upcomingEvents = [];
for (const eventDoc of eventsSnapshot.docs) { for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data(); const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || []; const assignedEquipment = eventData.assignedEquipment || [];
if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) { if (!assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
res.status(409).json({ continue;
error: 'Cannot delete equipment: it is assigned to active events',
eventId: eventDoc.id
});
return;
} }
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: 'FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events',
upcomingEvents,
});
return;
} }
await db.collection('equipments').doc(equipmentId).delete(); await db.collection('equipments').doc(equipmentId).delete();
@@ -1875,6 +1886,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) * 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é * Optimisé pour la page de préparation et l'affichage détaillé
@@ -3836,18 +3957,97 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
// Convertir en majuscules pour correspondre au format Firestore // Convertir en majuscules pour correspondre au format Firestore
const category = params.category ? params.category.toUpperCase() : null; const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.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 sortBy = params.sortBy || 'id';
const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc'; const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc';
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`); 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 // Construire la requête Firestore
let query = db.collection('equipments'); 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 // Appliquer les filtres
if (category) { if (category) {
query = query.where('category', '==', category); query = query.where('category', '==', category);
@@ -3872,20 +4072,10 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
} }
} }
// Limiter les résultats const timestampFields = ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'];
query = query.limit(queryLimit + 1);
const snapshot = await query.get(); const mapEquipmentDoc = (doc) => {
const data = {...(doc.data() || {})};
// 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();
// Masquer les prix si l'utilisateur n'a pas manage_equipment // Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) { if (!canManage) {
@@ -3893,36 +4083,116 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
delete data.rentalPrice; delete data.rentalPrice;
} }
return { const legacyId = typeof data.id === 'string' ? data.id : '';
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
// Filtrage textuel côté serveur return {
if (searchQuery) { ...helpers.serializeTimestamps(data, timestampFields),
equipments = equipments.filter(eq => { id: doc.id,
const searchableText = [ _legacyId: legacyId
eq.name || '', };
eq.id || '', };
eq.model || '',
eq.brand || '', const matchesSearchQuery = (equipment) => {
eq.subCategory || '' const searchableText = [
].join(' ').toLowerCase(); equipment.name || '',
return searchableText.includes(searchQuery); equipment.id || '',
equipment._legacyId || '',
equipment.model || '',
equipment.brand || '',
equipment.subCategory || ''
].join(' ').toLowerCase();
if (searchableText.includes(searchQuery)) {
return true;
}
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;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
lastVisible,
total: limitedEquipments.length
}); });
return;
} }
// Pour la limite finale après filtrage textuel // En mode recherche, scanner la collection par lots jusqu'à obtenir `limit + 1` matchs
const limitedEquipments = equipments.slice(0, limit); // 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; 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] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`); logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
res.status(200).json({ res.status(200).json({
equipments: limitedEquipments, equipments: limitedEquipments,
hasMore: hasMoreDocs, hasMore: hasMoreMatches,
lastVisible, lastVisible,
total: limitedEquipments.length total: limitedEquipments.length
}); });
@@ -4274,20 +4544,3 @@ exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => {
} }
}); });
// ============================================================================
// AI - Assistant Logisticien (Gemini avec function calling côté serveur)
// ============================================================================
exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors(async (req, res) => {
try {
// Authentification Firebase obligatoire (pas de clé API côté client)
await auth.authenticateUser(req);
await handleAiEquipmentProposal(req, res);
} catch (error) {
logger.error('[aiEquipmentProposal] Error:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
}));
+5 -19
View File
@@ -8,12 +8,11 @@
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.18.0", "@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0", "@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"envdot": "^0.0.3", "envdot": "^0.0.3",
"firebase-admin": "^12.6.0", "firebase-admin": "^12.6.0",
"firebase-functions": "^7.2.5", "firebase-functions": "^7.0.3",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"nodemailer": "^6.10.1" "nodemailer": "^6.10.1"
}, },
@@ -786,15 +785,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@google/generative-ai": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz",
"integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@grpc/grpc-js": { "node_modules/@grpc/grpc-js": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
@@ -3364,9 +3354,9 @@
} }
}, },
"node_modules/firebase-functions": { "node_modules/firebase-functions": {
"version": "7.2.5", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz", "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==", "integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -3385,8 +3375,7 @@
"peerDependencies": { "peerDependencies": {
"@apollo/server": "^5.2.0", "@apollo/server": "^5.2.0",
"@as-integrations/express4": "^1.1.2", "@as-integrations/express4": "^1.1.2",
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
"graphql": "^16.12.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@apollo/server": { "@apollo/server": {
@@ -3394,9 +3383,6 @@
}, },
"@as-integrations/express4": { "@as-integrations/express4": {
"optional": true "optional": true
},
"graphql": {
"optional": true
} }
} }
}, },
+1 -2
View File
@@ -16,12 +16,11 @@
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.18.0", "@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0", "@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"envdot": "^0.0.3", "envdot": "^0.0.3",
"firebase-admin": "^12.6.0", "firebase-admin": "^12.6.0",
"firebase-functions": "^7.2.5", "firebase-functions": "^7.0.3",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"nodemailer": "^6.10.1" "nodemailer": "^6.10.1"
}, },
+51 -6
View File
@@ -50,6 +50,11 @@ exports.processEquipmentValidation = onCall({
for (const equipment of equipmentList) { for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment; 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 // Cas 1: Équipement PERDU
if (status === 'LOST') { if (status === 'LOST') {
const alertData = await createAlertInFirestore({ const alertData = await createAlertInFirestore({
@@ -91,7 +96,9 @@ exports.processEquipmentValidation = onCall({
} }
// Cas 3: Quantité incorrecte // 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({ const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH', type: 'QUANTITY_MISMATCH',
severity: 'INFO', severity: 'INFO',
@@ -409,10 +416,48 @@ async function sendAlertEmails(alert, userIds) {
* Formate la date d'un événement * Formate la date d'un événement
*/ */
function formatEventDate(event) { function formatEventDate(event) {
if (event.startDate) { const rawDate =
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate); event?.StartDateTime ||
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'}); event?.startDateTime ||
} event?.startDate ||
return 'Date inconnue'; 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 /// Configuration de la version de l'application
class AppVersion { class AppVersion {
static const String version = '1.1.18'; static const String version = '1.1.23';
/// Retourne la version complète de l'application /// Retourne la version complète de l'application
static String get fullVersion => 'v$version'; static String get fullVersion => 'v$version';
+162 -254
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:em2rp/providers/users_provider.dart'; import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_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/container_model.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:em2rp/services/app_initializer.dart';
import 'firebase_options.dart';
import 'utils/colors.dart'; import 'utils/colors.dart';
import 'views/my_account_page.dart'; import 'views/my_account_page.dart';
import 'views/user_management_page.dart'; import 'views/user_management_page.dart';
@@ -30,35 +30,21 @@ import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart'; import 'providers/local_user_provider.dart';
import 'views/reset_password_page.dart'; import 'views/reset_password_page.dart';
import 'config/env.dart'; import 'config/env.dart';
import 'services/update_service.dart'; import 'utils/app_start_gate.dart';
import 'views/widgets/common/update_dialog.dart'; import 'views/widgets/common/startup_splash_screen.dart';
import 'config/api_config.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
void main() async { void main() {
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
WidgetsFlutterBinding.ensureInitialized(); 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( runApp(
MultiProvider( MultiProvider(
providers: [ 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 // LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>( ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()), create: (context) => LocalUserProvider()),
@@ -96,241 +82,163 @@ void main() async {
); );
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return FutureBuilder<void>(
title: 'EM2 Hub', future: _startupFuture,
theme: ThemeData( builder: (context, snapshot) {
primarySwatch: Colors.red, if (snapshot.connectionState != ConnectionState.done) {
primaryColor: AppColors.noir, return const MaterialApp(
colorScheme: debugShowCheckedModeBanner: false,
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge), home: StartupSplashScreen(),
textTheme: const TextTheme( );
bodyMedium: TextStyle(color: AppColors.noir), }
),
inputDecorationTheme: const InputDecorationTheme( return MaterialApp(
focusedBorder: OutlineInputBorder( title: 'EM2 Hub',
borderSide: BorderSide(color: AppColors.noir), theme: ThemeData(
), primarySwatch: Colors.red,
enabledBorder: OutlineInputBorder( primaryColor: AppColors.noir,
borderSide: BorderSide(color: AppColors.gris), colorScheme:
), ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
labelStyle: TextStyle(color: AppColors.noir), textTheme: const TextTheme(
hintStyle: TextStyle(color: AppColors.gris), bodyMedium: TextStyle(color: AppColors.noir),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( inputDecorationTheme: const InputDecorationTheme(
style: ElevatedButton.styleFrom( focusedBorder: OutlineInputBorder(
foregroundColor: AppColors.blanc, borderSide: BorderSide(color: AppColors.noir),
backgroundColor: AppColors.noir, ),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.gris),
),
labelStyle: TextStyle(color: AppColors.noir),
hintStyle: TextStyle(color: AppColors.gris),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: AppColors.blanc,
backgroundColor: AppColors.noir,
),
),
), ),
), locale: const Locale('fr', 'FR'),
), supportedLocales: const [
locale: const Locale('fr', 'FR'), Locale('fr', 'FR'),
supportedLocales: const [ ],
Locale('fr', 'FR'), localizationsDelegates: const [
], GlobalMaterialLocalizations.delegate,
localizationsDelegates: const [ GlobalWidgetsLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, ],
GlobalCupertinoLocalizations.delegate, routes: {
], '/login': (context) => const LoginPage(),
initialRoute: '/', '/alerts': (context) => const AuthGuard(child: AlertsPage()),
routes: { '/calendar': (context) => const AuthGuard(
'/': (context) => const AutoLoginWrapper(), allowWhileLoading: true, child: CalendarPage()),
'/login': (context) => const LoginPage(), '/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/alerts': (context) => const AuthGuard(child: AlertsPage()), '/user_management': (context) => const AuthGuard(
'/calendar': (context) => const AuthGuard(child: CalendarPage()), requiredPermission: "view_all_users",
'/my_account': (context) => const AuthGuard(child: MyAccountPage()), child: UserManagementPage()),
'/user_management': (context) => const AuthGuard( '/reset_password': (context) {
requiredPermission: "view_all_users", child: UserManagementPage()), final args = ModalRoute.of(context)!.settings.arguments
'/reset_password': (context) { as Map<String, dynamic>;
final args = ModalRoute.of(context)!.settings.arguments return ResetPasswordPage(
as Map<String, dynamic>; email: args['email'] as String,
return ResetPasswordPage( actionCode: args['actionCode'] as String,
email: args['email'] as String, );
actionCode: args['actionCode'] as String, },
); '/equipment_management': (context) => const AuthGuard(
}, requiredPermission: "view_equipment",
'/equipment_management': (context) => const AuthGuard( child: EquipmentManagementPage()),
requiredPermission: "view_equipment", '/container_management': (context) => const AuthGuard(
child: EquipmentManagementPage()), requiredPermission: "view_equipment",
'/container_management': (context) => const AuthGuard( child: ContainerManagementPage()),
requiredPermission: "view_equipment", '/maintenance_management': (context) => const AuthGuard(
child: ContainerManagementPage()), requiredPermission: "manage_maintenances",
'/maintenance_management': (context) => const AuthGuard( child: MaintenanceManagementPage()),
requiredPermission: "manage_maintenances", '/container_form': (context) {
child: MaintenanceManagementPage()), final args = ModalRoute.of(context)?.settings.arguments;
'/container_form': (context) { return AuthGuard(
final args = ModalRoute.of(context)?.settings.arguments; requiredPermission: "manage_equipment",
return AuthGuard( child: ContainerFormPage(
requiredPermission: "manage_equipment", container: args as ContainerModel?,
child: ContainerFormPage( ),
container: args as ContainerModel?, );
), },
); '/container_detail': (context) {
}, final container = ModalRoute.of(context)!.settings.arguments
'/container_detail': (context) { as ContainerModel;
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel; return AuthGuard(
return AuthGuard( requiredPermission: "view_equipment",
requiredPermission: "view_equipment", child: ContainerDetailPage(container: container),
child: ContainerDetailPage(container: container), );
); },
}, '/event_preparation': (context) {
'/event_preparation': (context) { final args = ModalRoute.of(context)!.settings.arguments
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>; as Map<String, dynamic>;
final event = args['event'] as EventModel; final event = args['event'] as EventModel;
return AuthGuard( return AuthGuard(
child: EventPreparationPage( child: EventPreparationPage(
initialEvent: event, initialEvent: event,
), ),
); );
}, },
'/event_statistics': (context) => const AuthGuard( '/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,
),
),
],
),
),
);
}
}
-6
View File
@@ -174,7 +174,6 @@ ReturnStatus returnStatusFromString(String? status) {
class EventEquipment { class EventEquipment {
final String equipmentId; // ID de l'équipement final String equipmentId; // ID de l'équipement
final int quantity; // Quantité initiale assignée final int quantity; // Quantité initiale assignée
final String? rationale; // Explication/Justification (ex: IA alternative)
final bool isPrepared; // Validé en préparation final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement final bool isUnloaded; // Validé au déchargement
@@ -195,7 +194,6 @@ class EventEquipment {
EventEquipment({ EventEquipment({
required this.equipmentId, required this.equipmentId,
this.quantity = 1, this.quantity = 1,
this.rationale,
this.isPrepared = false, this.isPrepared = false,
this.isLoaded = false, this.isLoaded = false,
this.isUnloaded = false, this.isUnloaded = false,
@@ -214,7 +212,6 @@ class EventEquipment {
return EventEquipment( return EventEquipment(
equipmentId: map['equipmentId'] ?? '', equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1, quantity: map['quantity'] ?? 1,
rationale: map['rationale'],
isPrepared: map['isPrepared'] ?? false, isPrepared: map['isPrepared'] ?? false,
isLoaded: map['isLoaded'] ?? false, isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false, isUnloaded: map['isUnloaded'] ?? false,
@@ -234,7 +231,6 @@ class EventEquipment {
return { return {
'equipmentId': equipmentId, 'equipmentId': equipmentId,
'quantity': quantity, 'quantity': quantity,
'rationale': rationale,
'isPrepared': isPrepared, 'isPrepared': isPrepared,
'isLoaded': isLoaded, 'isLoaded': isLoaded,
'isUnloaded': isUnloaded, 'isUnloaded': isUnloaded,
@@ -253,7 +249,6 @@ class EventEquipment {
EventEquipment copyWith({ EventEquipment copyWith({
String? equipmentId, String? equipmentId,
int? quantity, int? quantity,
String? rationale,
bool? isPrepared, bool? isPrepared,
bool? isLoaded, bool? isLoaded,
bool? isUnloaded, bool? isUnloaded,
@@ -270,7 +265,6 @@ class EventEquipment {
return EventEquipment( return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId, equipmentId: equipmentId ?? this.equipmentId,
quantity: quantity ?? this.quantity, quantity: quantity ?? this.quantity,
rationale: rationale ?? this.rationale,
isPrepared: isPrepared ?? this.isPrepared, isPrepared: isPrepared ?? this.isPrepared,
isLoaded: isLoaded ?? this.isLoaded, isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded, isUnloaded: isUnloaded ?? this.isUnloaded,
+2 -2
View File
@@ -433,9 +433,9 @@ class EquipmentProvider extends ChangeNotifier {
} }
/// Supprimer un équipement /// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async { Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
try { try {
await _dataService.deleteEquipment(equipmentId); await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
if (_usePagination) { if (_usePagination) {
await reload(); await reload();
} else { } else {
+75 -29
View File
@@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier {
bool _lastCanViewAll = false; bool _lastCanViewAll = false;
// Nouveau: Cache par mois pour le lazy loading // 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é String? _currentMonth; // Mois actuellement affiché
List<EventModel> get events => _events; 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) /// Vérifie si les données doivent être rechargées (cache de 30 secondes)
bool _shouldReload(String userId, bool canViewAllEvents) { bool _shouldReload(String userId, bool canViewAllEvents) {
if (_lastLoadTime == null) return true; if (_lastLoadTime == null) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true; if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents)
return true;
final now = DateTime.now(); final now = DateTime.now();
final difference = now.difference(_lastLoadTime!); final difference = now.difference(_lastLoadTime!);
@@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier {
} }
/// Charger les événements d'un utilisateur via l'API /// 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'); PerformanceMonitor.start('EventProvider.loadUserEvents');
// Éviter les rechargements inutiles // Éviter les rechargements inutiles
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) { 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'); PerformanceMonitor.end('EventProvider.loadUserEvents');
return; return;
} }
@@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)'); print(
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
PerformanceMonitor.start('EventProvider.getEvents_API'); PerformanceMonitor.start('EventProvider.getEvents_API');
// Charger via l'API - les permissions sont vérifiées côté serveur // 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>; final usersData = result['users'] as Map<String, dynamic>;
// Stocker les utilisateurs dans le cache // Stocker les utilisateurs dans le cache
_usersCache = usersData.map((key, value) => _usersCache = usersData
MapEntry(key, value as Map<String, dynamic>) .map((key, value) => MapEntry(key, value as Map<String, dynamic>));
);
print('Found ${eventsData.length} events from API'); print('Found ${eventsData.length} events from API');
@@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier {
// Parser chaque événement // Parser chaque événement
for (var eventData in eventsData) { for (var eventData in eventsData) {
try { try {
final event = EventModel.fromMap(eventData, eventData['id'] as String); final event =
EventModel.fromMap(eventData, eventData['id'] as String);
allEvents.add(event); allEvents.add(event);
} catch (e) { } catch (e) {
print('Failed to parse event ${eventData['id']}: $e'); print('Failed to parse event ${eventData['id']}: $e');
@@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId; _lastUserId = userId;
_lastCanViewAll = canViewAllEvents; _lastCanViewAll = canViewAllEvents;
print('Successfully loaded ${_events.length} events ($failedCount failed)'); print(
'Successfully loaded ${_events.length} events ($failedCount failed)');
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier {
/// Charger les événements d'un mois spécifique (lazy loading optimisé) /// Charger les événements d'un mois spécifique (lazy loading optimisé)
Future<void> loadMonthEvents(String userId, int year, int month, 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')}'; final monthKey = '$year-${month.toString().padLeft(2, '0')}';
// Vérifier le cache // Vérifier le cache
@@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier {
PerformanceMonitor.start('EventProvider.loadMonthEvents_API'); PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
final result = await _dataService.getEventsByMonth( final result = await _dataService.getEventsByMonth(
userId: userId, userId: userId, year: year, month: month);
year: year,
month: month
);
PerformanceMonitor.end('EventProvider.loadMonthEvents_API'); PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
final eventsData = result['events'] as List<Map<String, dynamic>>; final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>; final usersData = result['users'] as Map<String, dynamic>;
// Mettre à jour le cache utilisateurs (addAll pour cumuler) // Mettre à jour le cache utilisateurs (addAll pour cumuler)
_usersCache.addAll( _usersCache.addAll(usersData
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>)) .map((key, value) => MapEntry(key, value as Map<String, dynamic>)));
);
print('[EventProvider] Found ${eventsData.length} events for $monthKey'); print('[EventProvider] Found ${eventsData.length} events for $monthKey');
@@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier {
// Parser les événements // Parser les événements
for (var eventData in eventsData) { for (var eventData in eventsData) {
try { try {
final event = EventModel.fromMap(eventData, eventData['id'] as String); final event =
EventModel.fromMap(eventData, eventData['id'] as String);
monthEvents.add(event); monthEvents.add(event);
} catch (e) { } catch (e) {
print('[EventProvider] Failed to parse event ${eventData['id']}: $e'); print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
@@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId; _lastUserId = userId;
_lastCanViewAll = canViewAllEvents; _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) { if (!silent) {
_isLoading = false; _isLoading = false;
@@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier {
/// Précharger les mois adjacents en arrière-plan /// Précharger les mois adjacents en arrière-plan
void preloadAdjacentMonths(String userId, int year, int month, void preloadAdjacentMonths(String userId, int year, int month,
{bool canViewAllEvents = false}) { {bool canViewAllEvents = false}) {
// Mois précédent // Mois précédent
final prevMonth = month == 1 ? 12 : month - 1; final prevMonth = month == 1 ? 12 : month - 1;
final prevYear = month == 1 ? year - 1 : year; final prevYear = month == 1 ? year - 1 : year;
@@ -230,8 +234,10 @@ class EventProvider with ChangeNotifier {
} }
/// Recharger les événements (utilise le dernier userId) /// Recharger les événements (utilise le dernier userId)
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async { Future<void> refreshEvents(String userId,
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true); {bool canViewAllEvents = false}) async {
await loadUserEvents(userId,
canViewAllEvents: canViewAllEvents, forceReload: true);
} }
/// Récupérer un événement spécifique par ID /// 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 /// Ajouter un nouvel événement
Future<void> addEvent(EventModel event) async { Future<void> addEvent(EventModel event) async {
try { try {
@@ -250,7 +291,8 @@ class EventProvider with ChangeNotifier {
_events.add(event); _events.add(event);
// Ajouter dans le cache par mois // 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)) { if (_eventsByMonth.containsKey(monthKey)) {
_eventsByMonth[monthKey]!.add(event); _eventsByMonth[monthKey]!.add(event);
} }
@@ -272,8 +314,10 @@ class EventProvider with ChangeNotifier {
_events[index] = event; _events[index] = event;
// Mettre à jour dans le cache par mois // Mettre à jour dans le cache par mois
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}'; final oldMonthKey =
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}'; '${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 // Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
if (oldMonthKey != newMonthKey) { if (oldMonthKey != newMonthKey) {
@@ -286,7 +330,8 @@ class EventProvider with ChangeNotifier {
} else { } else {
// Même mois, juste mettre à jour // Même mois, juste mettre à jour
if (_eventsByMonth.containsKey(newMonthKey)) { 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) { if (monthIndex != -1) {
_eventsByMonth[newMonthKey]![monthIndex] = event; _eventsByMonth[newMonthKey]![monthIndex] = event;
} }
@@ -308,7 +353,8 @@ class EventProvider with ChangeNotifier {
// Trouver l'événement pour obtenir sa date avant de le supprimer // Trouver l'événement pour obtenir sa date avant de le supprimer
final eventToDelete = _events.firstWhere((e) => e.id == eventId); 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 // Supprimer de _events
_events.removeWhere((event) => event.id == eventId); _events.removeWhere((event) => event.id == eventId);
+51 -5
View File
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
class LocalUserProvider with ChangeNotifier { class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser; UserModel? _currentUser;
RoleModel? _currentRole; RoleModel? _currentRole;
final FirebaseAuth _auth = FirebaseAuth.instance; FirebaseAuth? _auth;
final FirebaseStorageManager _storageManager = FirebaseStorageManager(); final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService); final DataService _dataService = DataService(apiService);
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
/// Charge les données de l'utilisateur actuel via Cloud Function /// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData({bool forceReload = false}) async { 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'); print('No current user in Auth');
return; 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 // Éviter les rechargements inutiles
if (!forceReload && !_shouldReloadUserData()) { if (!forceReload && !_shouldReloadUserData()) {
print('Using cached user data'); print('Using cached user data');
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
_isLoadingUserData = true; _isLoadingUserData = true;
PerformanceMonitor.start('LocalUserProvider.loadUserData'); PerformanceMonitor.start('LocalUserProvider.loadUserData');
print('Loading user data for: ${_auth.currentUser!.uid}'); print('Loading user data for: ${_auth!.currentUser!.uid}');
try { try {
// Utiliser la Cloud Function getCurrentUser // Utiliser la Cloud Function getCurrentUser
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API'); PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
Future<UserCredential> signInWithEmailAndPassword( Future<UserCredential> signInWithEmailAndPassword(
String email, String password) async { String email, String password) async {
try { try {
UserCredential userCredential = await _auth.signInWithEmailAndPassword( final auth = _getAuthInstance();
UserCredential userCredential = await auth.signInWithEmailAndPassword(
email: email, password: password); email: email, password: password);
// Note: loadUserData() sera appelé en arrière-plan dans main.dart // Note: loadUserData() sera appelé en arrière-plan dans main.dart
// pour ne pas bloquer la navigation // pour ne pas bloquer la navigation
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
/// Déconnexion /// Déconnexion
Future<void> signOut() async { Future<void> signOut() async {
await _auth.signOut(); try {
final auth = _getAuthInstance();
await auth.signOut();
} catch (e) {
debugPrint('Error during signOut: $e');
}
clearUser(); 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 /// Vérifie si l'utilisateur a une permission spécifique
bool hasPermission(String permission) { bool hasPermission(String permission) {
return _currentRole?.permissions.contains(permission) ?? false; return _currentRole?.permissions.contains(permission) ?? false;
@@ -1,280 +0,0 @@
import 'dart:async';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Représente un tour de conversation dans le chat.
class AiAssistantChatTurn {
final bool isUser;
final String text;
const AiAssistantChatTurn({required this.isUser, required this.text});
}
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
class AiEquipmentDocument {
final String base64Data;
final String mimeType;
final String? fileName;
const AiEquipmentDocument({
required this.base64Data,
required this.mimeType,
this.fileName,
});
}
/// Un item proposé par l'IA dans la liste de matériel.
class AiEquipmentProposalItem {
final String equipmentId;
final int quantity;
final String rationale;
const AiEquipmentProposalItem({
required this.equipmentId,
required this.quantity,
required this.rationale,
});
}
/// Métadonnées pour un container proposé par l'IA.
class AiEquipmentProposalContainer {
final String containerId;
final String rationale;
final List<String> equipmentIds;
final List<String> matchingEquipmentIds;
final List<String> missingEquipmentIds;
final bool partial;
final bool? available;
final dynamic availabilityDetail;
const AiEquipmentProposalContainer({
required this.containerId,
required this.rationale,
this.equipmentIds = const [],
this.matchingEquipmentIds = const [],
this.missingEquipmentIds = const [],
this.partial = false,
this.available,
this.availabilityDetail,
});
}
/// Proposition complète retournée par l'IA.
class AiEquipmentProposal {
final String summary;
final List<AiEquipmentProposalItem> items;
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
final List<EventEquipment> asEventEquipment;
/// Containers (métadonnées) proposés par l'IA.
final List<AiEquipmentProposalContainer> containers;
List<String> get containerIds => containers.map((c) => c.containerId).toList();
const AiEquipmentProposal({
required this.summary,
required this.items,
required this.asEventEquipment,
required this.containers,
});
}
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
class AiEquipmentAssistantResponse {
final String assistantMessage;
final AiEquipmentProposal? proposal;
final List<String> debugLogs;
const AiEquipmentAssistantResponse({
required this.assistantMessage,
this.proposal,
this.debugLogs = const [],
});
}
/// Service assistant IA logisticien.
/// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal].
/// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client.
class AiEquipmentAssistantService {
final ApiService _apiService;
AiEquipmentAssistantService({ApiService? apiService})
: _apiService = apiService ?? FirebaseFunctionsApiService();
/// Envoie un message et retourne la réponse de l'assistant IA.
Future<AiEquipmentAssistantResponse> generateProposal({
required DateTime startDate,
required DateTime endDate,
required List<AiAssistantChatTurn> history,
required String userMessage,
String? eventTypeId,
String? excludeEventId,
List<EventEquipment> currentAssignedEquipment = const [],
List<EventEquipment> workingProposalEquipment = const [],
AiEquipmentDocument? document,
}) async {
final payload = <String, dynamic>{
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'userMessage': userMessage.trim(),
'history': history
.where((turn) => turn.text.trim().isNotEmpty)
.map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()})
.toList(),
'currentEquipment': currentAssignedEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
'workingProposal': workingProposalEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
};
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
if (document != null) {
payload['document'] = {
'mimeType': document.mimeType,
'data': document.base64Data,
if (document.fileName != null) 'fileName': document.fileName,
};
}
try {
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
final result = await _apiService.call('aiEquipmentProposal', payload);
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
final proposal = _parseProposal(result['proposal']);
final rawLogs = result['debugLogs'];
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
DebugLog.info(
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
);
return AiEquipmentAssistantResponse(
assistantMessage: assistantMessage.isNotEmpty
? assistantMessage
: 'Je n\'ai pas pu générer de réponse.',
proposal: proposal,
debugLogs: debugLogs,
);
} on ApiException catch (e) {
DebugLog.error('[AiEquipmentAssistantService] API error', e);
if (e.isUnauthorized) {
throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.');
}
throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}');
} catch (e) {
DebugLog.error('[AiEquipmentAssistantService] Error', e);
rethrow;
}
}
AiEquipmentProposal? _parseProposal(dynamic rawProposal) {
if (rawProposal == null || rawProposal is! Map<String, dynamic>) return null;
final proposalItems = <AiEquipmentProposalItem>[];
final eventEquipmentList = <EventEquipment>[];
// legacy containerIds variable removed (we now use containersMeta)
final rawItems = rawProposal['items'];
if (rawItems is List) {
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
final item = Map<String, dynamic>.from(rawItem);
final equipmentId = item['equipmentId']?.toString().trim() ?? '';
final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1;
if (equipmentId.isEmpty || quantity <= 0) continue;
final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA';
proposalItems.add(AiEquipmentProposalItem(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
eventEquipmentList.add(EventEquipment(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
}
}
final containersMeta = <AiEquipmentProposalContainer>[];
final rawContainers = rawProposal['containers'];
if (rawContainers is List) {
for (final rawContainer in rawContainers) {
if (rawContainer is String) {
final cid = rawContainer.toString().trim();
if (cid.isNotEmpty) {
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
}
continue;
}
if (rawContainer is! Map) continue;
final container = Map<String, dynamic>.from(rawContainer);
final containerId = container['containerId']?.toString().trim() ?? '';
if (containerId.isEmpty) continue;
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
final equipmentIds = <String>[];
final matching = <String>[];
final missing = <String>[];
if (container['equipmentIds'] is List) {
for (final v in container['equipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) equipmentIds.add(s);
}
}
if (container['matchingEquipmentIds'] is List) {
for (final v in container['matchingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) matching.add(s);
}
}
if (container['missingEquipmentIds'] is List) {
for (final v in container['missingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) missing.add(s);
}
}
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
containersMeta.add(AiEquipmentProposalContainer(
containerId: containerId,
rationale: rationale,
equipmentIds: equipmentIds,
matchingEquipmentIds: matching,
missingEquipmentIds: missing,
partial: partial,
available: available,
availabilityDetail: availabilityDetail,
));
}
}
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
return AiEquipmentProposal(
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
? rawProposal['summary'].toString().trim()
: 'Proposition matériel générée automatiquement.',
items: proposalItems,
asEventEquipment: eventEquipmentList,
containers: containersMeta,
);
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService;
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions /// Architecture simplifiée : le client appelle uniquement les Cloud Functions
/// Toute la logique métier est gérée côté backend /// Toute la logique métier est gérée côté backend
class AlertService { class AlertService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; FirebaseFirestore get _firestore => FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance; FirebaseAuth get _auth => FirebaseAuth.instance;
/// Stream des alertes pour l'utilisateur connecté /// Stream des alertes pour l'utilisateur connecté
Stream<List<AlertModel>> getAlertsStream() { Stream<List<AlertModel>> getAlertsStream() {
+2
View File
@@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService {
statusCode: response.statusCode, statusCode: response.statusCode,
); );
} }
} on ApiException {
rethrow;
} catch (e) { } catch (e) {
DebugLog.error('[API] Error during request: $functionName', e); DebugLog.error('[API] Error during request: $functionName', e);
throw ApiException( 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 -152
View File
@@ -139,9 +139,15 @@ class DataService {
} }
/// Supprime un équipement /// Supprime un équipement
Future<void> deleteEquipment(String equipmentId) async { Future<void> deleteEquipment(String equipmentId,
{bool forceDelete = false}) async {
try { try {
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId}); await _apiService.call('deleteEquipment', {
'equipmentId': equipmentId,
'forceDelete': forceDelete,
});
} on ApiException {
rethrow;
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la suppression de l\'équipement: $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) /// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async { Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
try { try {
@@ -523,156 +553,6 @@ class DataService {
} }
} }
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
required String query,
int limit = 12,
}) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return [];
}
try {
final quickResults = await quickSearch(
normalizedQuery,
limit: limit,
includeEquipments: true,
includeContainers: false,
);
final equipmentResults = quickResults
.where((item) =>
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
.map(_normalizeAssistantEquipment)
.toList();
if (equipmentResults.isNotEmpty) {
return equipmentResults;
}
final paginated = await getEquipmentsPaginated(
limit: limit,
searchQuery: normalizedQuery,
sortBy: 'id',
sortOrder: 'asc',
);
final equipments =
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
return equipments.map(_normalizeAssistantEquipment).toList();
} catch (e) {
DebugLog.error('[DataService] Error in searchEquipmentsForAssistant', e);
throw Exception('Erreur lors de la recherche de matériel: $e');
}
}
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
final available = result['available'] as bool? ?? true;
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map((conflict) {
final eventData =
conflict['eventData'] as Map<String, dynamic>? ?? const {};
final eventName =
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
return {
'eventId': conflict['eventId']?.toString() ?? '',
'eventName': eventName,
'overlapDays': conflict['overlapDays'] as int? ?? 0,
};
}).toList();
return {
'equipmentId': equipmentId,
'available': available,
'conflictCount': conflicts.length,
'conflicts': conflicts,
};
} catch (e) {
DebugLog.error(
'[DataService] Error in checkEquipmentAvailabilityForAssistant', e);
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Retourne des événements passés, idéalement filtrés par type d'événement.
Future<List<Map<String, dynamic>>> getPastEventsForAssistant({
String? eventTypeId,
int limit = 10,
}) async {
try {
final now = DateTime.now();
final events = eventTypeId != null && eventTypeId.isNotEmpty
? await getEventsByEventType(eventTypeId)
: (await getEvents())['events'] as List<Map<String, dynamic>>? ?? [];
final pastEvents = events.where((event) {
final endDate = _parseEventDate(event['EndDateTime']);
return endDate != null && endDate.isBefore(now);
}).toList();
pastEvents.sort((a, b) {
final aDate = _parseEventDate(a['StartDateTime']) ??
DateTime.fromMillisecondsSinceEpoch(0);
final bDate = _parseEventDate(b['StartDateTime']) ??
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(aDate);
});
return pastEvents.take(limit).map((event) {
final assignedEquipment =
event['assignedEquipment'] as List<dynamic>? ?? const [];
return {
'id': event['id']?.toString() ?? '',
'name': (event['Name'] ?? '').toString(),
'startDate': event['StartDateTime']?.toString() ?? '',
'endDate': event['EndDateTime']?.toString() ?? '',
'assignedEquipment': assignedEquipment,
'assignedEquipmentCount': assignedEquipment.length,
};
}).toList();
} catch (e) {
DebugLog.error('[DataService] Error in getPastEventsForAssistant', e);
throw Exception(
'Erreur lors de la récupération des événements passés: $e');
}
}
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
return {
'id': (item['id'] ?? '').toString(),
'name': (item['name'] ?? item['id'] ?? '').toString(),
'category': (item['category'] ?? '').toString(),
'status': (item['status'] ?? '').toString(),
'brand': item['brand']?.toString(),
'model': item['model']?.toString(),
'availableQuantity': item['availableQuantity'],
'totalQuantity': item['totalQuantity'],
};
}
DateTime? _parseEventDate(dynamic rawValue) {
if (rawValue is String) {
return DateTime.tryParse(rawValue);
}
return null;
}
// ============================================================================ // ============================================================================
// USER - Current User // USER - Current User
// ============================================================================ // ============================================================================
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions /// Service d'envoi d'emails via Cloud Functions
class EmailService { 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 /// Envoie un email d'alerte à un utilisateur
/// ///
@@ -4,6 +4,44 @@ import 'package:em2rp/services/api_service.dart';
class EventPreparationService { class EventPreparationService {
final ApiService _apiService = apiService; 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 === // === PRÉPARATION ===
/// Valider un équipement individuel en 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/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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/views/login_page.dart';
class AuthGuard extends StatelessWidget { class AuthGuard extends StatelessWidget {
final Widget child; final Widget child;
final String? requiredPermission; final String? requiredPermission;
final bool allowWhileLoading;
const AuthGuard({ const AuthGuard({
super.key, super.key,
required this.child, required this.child,
this.requiredPermission, this.requiredPermission,
this.allowWhileLoading = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localAuthProvider = Provider.of<LocalUserProvider>(context); final localAuthProvider = Provider.of<LocalUserProvider>(context);
final firebaseUser = FirebaseAuth.instance.currentUser;
// Log pour débug // Log pour débug
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission'); 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é // 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'); print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
return const 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'; import 'package:em2rp/services/api_service.dart';
class FirebaseStorageManager { class FirebaseStorageManager {
final FirebaseStorage _storage = FirebaseStorage.instance; FirebaseStorage get _storage => FirebaseStorage.instance;
final DataService _dataService = DataService(FirebaseFunctionsApiService()); final DataService _dataService = DataService(apiService);
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage. /// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
/// Pour le Web, on fixe l'extension .jpg. /// Pour le Web, on fixe l'extension .jpg.
+10 -13
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import '../providers/local_user_provider.dart'; import '../providers/local_user_provider.dart';
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
passwordController.text, passwordController.text,
); );
// --- Étape 2: Charger les données utilisateur depuis Firestore --- // --- Étape 2: Charger les données utilisateur en arrière-plan ---
await localAuthProvider.loadUserData(); unawaited(
localAuthProvider.loadUserData().catchError((e) {
debugPrint('Erreur chargement profil après connexion : $e');
}),
);
// Vérifier si le contexte est toujours valide // Vérifier si le contexte est toujours valide
if (context.mounted) { if (context.mounted) {
// Vérifier si l'utilisateur a bien été chargé dans le provider Navigator.of(context, rootNavigator: true)
if (localAuthProvider.currentUser != null) { .pushReplacementNamed('/calendar');
// 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) { } on FirebaseAuthException catch (e) {
// Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.) // 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 'dart:math' as math;
import 'package:em2rp/providers/local_user_provider.dart'; 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:table_calendar/table_calendar.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/views/widgets/calendar_widgets/event_details.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:intl/date_symbol_data_local.dart';
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
@@ -40,8 +42,18 @@ class _CalendarPageState extends State<CalendarPage> {
int _selectedEventIndex = 0; int _selectedEventIndex = 0;
String? String?
_selectedUserId; // Filtre par utilisateur (null = tous les événements) _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; bool _isRefreshing = false;
double _detailsPaneFraction = 0.35; double _detailsPaneFraction = 0.35;
String? _lastLoadedUserId;
bool _initialLoadScheduled = false;
@override @override
void initState() { 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é void _scheduleInitialEventsLoad(String? userId) {
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place if (userId == null || userId == _lastLoadedUserId || _initialLoadScheduled) {
Future<void> _loadEventsAsync() async { return;
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');
} }
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 /// 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) @override
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore void dispose() {
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) { _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) { if (_selectedUserId == null) {
return allEvents; // Pas de filtre, retourner tous les événements return allEvents; // Pas de filtre, retourner tous les événements
} }
@@ -208,6 +229,524 @@ class _CalendarPageState extends State<CalendarPage> {
}).toList(); }).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) { void _changeWeek(int delta) {
setState(() { setState(() {
_focusedDay = _focusedDay.add(Duration(days: 7 * delta)); _focusedDay = _focusedDay.add(Duration(days: 7 * delta));
@@ -238,10 +777,12 @@ class _CalendarPageState extends State<CalendarPage> {
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) { Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
if (_selectedEvent != null) { if (_selectedEvent != null) {
final detailsEvents = _getDetailsEvents(filteredEvents);
return EventDetails( return EventDetails(
event: _selectedEvent!, event: _selectedEvent!,
selectedDate: _selectedDay, selectedDate: _selectedDay,
events: filteredEvents, events: detailsEvents,
onSelectEvent: (event, date) { onSelectEvent: (event, date) {
setState(() { setState(() {
_selectedEvent = event; _selectedEvent = event;
@@ -292,10 +833,13 @@ class _CalendarPageState extends State<CalendarPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final eventProvider = Provider.of<EventProvider>(context); final eventProvider = Provider.of<EventProvider>(context);
final localUserProvider = Provider.of<LocalUserProvider>(context); final localUserProvider = Provider.of<LocalUserProvider>(context);
_scheduleInitialEventsLoad(localUserProvider.uid);
final canCreateEvents = localUserProvider.hasPermission('create_events'); final canCreateEvents = localUserProvider.hasPermission('create_events');
final canViewAllUserEvents = final canViewAllUserEvents =
localUserProvider.hasPermission('view_all_user_events'); localUserProvider.hasPermission('view_all_user_events');
final isMobile = MediaQuery.of(context).size.width < 600; final isMobile = MediaQuery.of(context).size.width < 600;
final showSearchResults =
_searchQuery.trim().isNotEmpty || _isSearching || _searchError != null;
// Appliquer le filtre utilisateur si actif // Appliquer le filtre utilisateur si actif
final filteredEvents = _getFilteredEvents(eventProvider.events); final filteredEvents = _getFilteredEvents(eventProvider.events);
@@ -343,33 +887,11 @@ class _CalendarPageState extends State<CalendarPage> {
drawer: const MainDrawer(currentPage: '/calendar'), drawer: const MainDrawer(currentPage: '/calendar'),
body: Column( body: Column(
children: [ children: [
// Filtre utilisateur dans le corps de la page if (isMobile)
if (canViewAllUserEvents && !isMobile) _buildMobileSearchBar()
Container( else
padding: const EdgeInsets.all(16), _buildDesktopFiltersBar(canViewAllUserEvents: canViewAllUserEvents),
color: Colors.grey[100], if (showSearchResults) _buildSearchResultsPanel(isMobile: isMobile),
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;
});
},
),
),
],
),
),
// Corps du calendrier // Corps du calendrier
Expanded( Expanded(
child: isMobile child: isMobile
@@ -426,18 +948,19 @@ class _CalendarPageState extends State<CalendarPage> {
} }
Widget _buildMobileLayout(List<EventModel> filteredEvents) { Widget _buildMobileLayout(List<EventModel> filteredEvents) {
final eventsForSelectedDay = _selectedDay == null final eventsForSelectedDay = _getEventsForDay(
? [] filteredEvents,
: filteredEvents _selectedDay,
.where((e) => selectedEvent: _selectedEvent,
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 hasEvents = eventsForSelectedDay.isNotEmpty; final hasEvents = eventsForSelectedDay.isNotEmpty;
final currentEvent = final selectedEventIndex = _selectedEvent == null
hasEvents && _selectedEventIndex < eventsForSelectedDay.length ? -1
: eventsForSelectedDay
.indexWhere((event) => event.id == _selectedEvent!.id);
final currentEvent = hasEvents && selectedEventIndex >= 0
? eventsForSelectedDay[selectedEventIndex]
: hasEvents && _selectedEventIndex < eventsForSelectedDay.length
? eventsForSelectedDay[_selectedEventIndex] ? eventsForSelectedDay[_selectedEventIndex]
: null; : null;
@@ -581,7 +1104,7 @@ class _CalendarPageState extends State<CalendarPage> {
child: EventDetails( child: EventDetails(
event: eventsForSelectedDay[_selectedEventIndex], event: eventsForSelectedDay[_selectedEventIndex],
selectedDate: _selectedDay, selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(), events: eventsForSelectedDay,
onSelectEvent: (event, date) { onSelectEvent: (event, date) {
final idx = eventsForSelectedDay final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id); .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) if (_calendarCollapsed && _selectedDay != null)
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
@@ -647,7 +1170,7 @@ class _CalendarPageState extends State<CalendarPage> {
child: EventDetails( child: EventDetails(
event: currentEvent, event: currentEvent,
selectedDate: _selectedDay, selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(), events: eventsForSelectedDay,
onSelectEvent: (event, date) { onSelectEvent: (event, date) {
final idx = eventsForSelectedDay final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id); .indexWhere((e) => e.id == event.id);
+115 -139
View File
@@ -7,8 +7,6 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/id_generator.dart'; import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class ContainerFormPage extends StatefulWidget { class ContainerFormPage extends StatefulWidget {
final ContainerModel? container; final ContainerModel? container;
@@ -102,7 +100,6 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
child: ListView( child: ListView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
children: [ children: [
// Nom // Nom
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
@@ -259,7 +256,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
prefixIcon: Icon(Icons.scale), prefixIcon: Icon(Icons.scale),
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -281,7 +279,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Longueur (cm)', labelText: 'Longueur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -300,7 +299,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Largeur (cm)', labelText: 'Largeur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -319,7 +319,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Hauteur (cm)', labelText: 'Hauteur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -454,6 +455,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
Future<void> _selectEquipment() async { Future<void> _selectEquipment() async {
final equipmentProvider = context.read<EquipmentProvider>(); 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( await showDialog(
context: context, context: context,
builder: (context) => _EquipmentSelectorDialog( builder: (context) => _EquipmentSelectorDialog(
@@ -462,6 +468,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
), ),
); );
if (!mounted) return;
setState(() {}); setState(() {});
} }
@@ -537,7 +544,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } 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 +583,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
}); });
// Gérer les équipements ajoutés // 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) { for (final equipmentId in addedEquipment) {
try { try {
await provider.addEquipmentToContainer( await provider.addEquipmentToContainer(
@@ -583,12 +592,14 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } 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 // 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) { for (final equipmentId in removedEquipment) {
try { try {
await provider.removeEquipmentFromContainer( await provider.removeEquipmentFromContainer(
@@ -596,7 +607,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } 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,91 +644,31 @@ class _EquipmentSelectorDialog extends StatefulWidget {
}); });
@override @override
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState(); State<_EquipmentSelectorDialog> createState() =>
_EquipmentSelectorDialogState();
} }
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
EquipmentCategory? _filterCategory; EquipmentCategory? _filterCategory;
String _searchQuery = ''; String _searchQuery = '';
late Set<String> _tempSelectedIds; late Set<String> _tempSelectedIds;
late final Future<void> _loadingFuture;
final List<EquipmentModel> _paginatedEquipments = [];
bool _isLoadingMore = false;
bool _hasMoreEquipments = true;
String? _lastEquipmentId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Créer une copie temporaire des IDs sélectionnés // Créer une copie temporaire des IDs sélectionnés
_tempSelectedIds = Set<String>.from(widget.selectedIds); _tempSelectedIds = Set<String>.from(widget.selectedIds);
_scrollController.addListener(_onScroll); _loadingFuture = widget.equipmentProvider.loadEquipments();
_loadNextPage();
} }
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_scrollController.dispose();
super.dispose(); super.dispose();
} }
void _onScroll() {
if (_isLoadingMore) return;
if (_scrollController.hasClients &&
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
if (_hasMoreEquipments) {
_loadNextPage();
}
}
}
Future<void> _loadNextPage() async {
if (_isLoadingMore || !_hasMoreEquipments) return;
setState(() => _isLoadingMore = true);
try {
final result = await _dataService.getEquipmentsPaginated(
limit: 50,
startAfter: _lastEquipmentId,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newEquipments = (result['equipments'] as List<dynamic>)
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
.toList();
if (mounted) {
setState(() {
_paginatedEquipments.addAll(newEquipments);
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
_lastEquipmentId = result['lastVisible'] as String?;
_isLoadingMore = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}
}
Future<void> _reloadData() async {
setState(() {
_paginatedEquipments.clear();
_lastEquipmentId = null;
_hasMoreEquipments = true;
});
await _loadNextPage();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Dialog( return Dialog(
@@ -766,7 +718,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_searchQuery = ''; _searchQuery = '';
}); });
_reloadData();
}, },
) )
: null, : null,
@@ -775,7 +726,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_searchQuery = value; _searchQuery = value;
}); });
_reloadData();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -793,11 +743,11 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_filterCategory = null; _filterCategory = null;
}); });
_reloadData();
}, },
selectedColor: AppColors.rouge, selectedColor: AppColors.rouge,
labelStyle: TextStyle( labelStyle: TextStyle(
color: _filterCategory == null ? Colors.white : Colors.black, color:
_filterCategory == null ? Colors.white : Colors.black,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -811,11 +761,12 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() { setState(() {
_filterCategory = selected ? category : null; _filterCategory = selected ? category : null;
}); });
_reloadData();
}, },
selectedColor: AppColors.rouge, selectedColor: AppColors.rouge,
labelStyle: TextStyle( labelStyle: TextStyle(
color: _filterCategory == category ? Colors.white : Colors.black, color: _filterCategory == category
? Colors.white
: Colors.black,
), ),
), ),
); );
@@ -829,7 +780,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1), color: AppColors.rouge.withOpacity(0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -847,62 +798,90 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
// Liste des équipements // Liste des équipements
Expanded( Expanded(
child: _paginatedEquipments.isEmpty && !_isLoadingMore child: FutureBuilder<void>(
? const Center(child: Text('Aucun équipement trouvé')) future: _loadingFuture,
: ListView.builder( builder: (context, snapshot) {
controller: _scrollController, if (snapshot.connectionState == ConnectionState.waiting) {
itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0), return const Center(child: CircularProgressIndicator());
itemBuilder: (context, index) { }
if (index == _paginatedEquipments.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
),
);
}
final item = _paginatedEquipments[index]; if (snapshot.hasError) {
final isSelected = _tempSelectedIds.contains(item.id); return Center(child: Text('Erreur: ${snapshot.error}'));
}
return CheckboxListTile( var equipment = List<EquipmentModel>.from(
value: isSelected, widget.equipmentProvider.allEquipment,
onChanged: (selected) { );
setState(() {
if (selected == true) { // Filtrer par catégorie
_tempSelectedIds.add(item.id); if (_filterCategory != null) {
} else { equipment = equipment
_tempSelectedIds.remove(item.id); .where((e) => e.category == _filterCategory)
} .toList();
}); }
},
title: Text( // Filtrer par recherche
item.id, if (_searchQuery.isNotEmpty) {
style: const TextStyle(fontWeight: FontWeight.bold), final query = _searchQuery.toLowerCase();
), equipment = equipment.where((e) {
subtitle: Column( return e.id.toLowerCase().contains(query) ||
crossAxisAlignment: CrossAxisAlignment.start, (e.brand?.toLowerCase().contains(query) ?? false) ||
children: [ (e.model?.toLowerCase().contains(query) ?? false);
if (item.brand != null || item.model != null) }).toList();
Text('${item.brand ?? ''} ${item.model ?? ''}'), }
const SizedBox(height: 4),
Text( if (equipment.isEmpty) {
_getCategoryLabel(item.category), return const Center(
style: TextStyle( child: Text('Aucun équipement trouvé'),
fontSize: 12, );
color: Colors.grey.shade600, }
),
return ListView.builder(
itemCount: equipment.length,
itemBuilder: (context, index) {
final item = equipment[index];
final isSelected = _tempSelectedIds.contains(item.id);
return CheckboxListTile(
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
_tempSelectedIds.add(item.id);
} else {
_tempSelectedIds.remove(item.id);
}
});
},
title: Text(
item.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.brand != null || item.model != null)
Text('${item.brand ?? ''} ${item.model ?? ''}'),
const SizedBox(height: 4),
Text(
_getCategoryLabel(item.category),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
), ),
], ),
), ],
secondary: Icon( ),
_getCategoryIcon(item.category), secondary: Icon(
color: AppColors.rouge, _getCategoryIcon(item.category),
), color: AppColors.rouge,
activeColor: AppColors.rouge, ),
); activeColor: AppColors.rouge,
}, );
), },
);
},
),
), ),
// Boutons d'action // Boutons d'action
@@ -991,6 +970,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/equipment_service.dart';
import 'package:em2rp/services/qr_code_service.dart'; import 'package:em2rp/services/qr_code_service.dart';
import 'package:em2rp/utils/colors.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/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart'; import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart'; import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
Future<void> _loadMaintenances() async { Future<void> _loadMaintenances() async {
try { try {
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id); final maintenances = await _equipmentService
.getMaintenancesForEquipment(widget.equipment.id);
setState(() { setState(() {
_maintenances = maintenances; _maintenances = maintenances;
_isLoadingMaintenances = false; _isLoadingMaintenances = false;
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
const SizedBox(height: 24), const SizedBox(height: 24),
// 3. Notes // 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!), EquipmentNotesSection(notes: widget.equipment.notes!),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
); );
} }
void _showQRCode() { void _showQRCode() {
showDialog( showDialog(
context: context, context: context,
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(), '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
.trim(),
style: TextStyle(color: Colors.grey[700]), 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), const SizedBox(height: 4),
Text( Text(
'📁 ${widget.equipment.subCategory}', '📁 ${widget.equipment.subCategory}',
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
if (!hasPermission) { if (!hasPermission) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( 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, backgroundColor: Colors.orange,
), ),
); );
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
} }
void _deleteEquipment() { void _deleteEquipment() {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: widget.equipment.id,
name: widget.equipment.name,
);
showDialog( showDialog(
context: context, context: pageContext,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Confirmer la suppression'), title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text( content: Text(
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.', EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'), child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
// Fermer le dialog // Fermer le dialog
Navigator.pop(context); Navigator.pop(dialogContext);
// Capturer le ScaffoldMessenger avant la suppression // Capturer le ScaffoldMessenger avant la suppression
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final navigator = Navigator.of(context); final navigator = Navigator.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try { try {
await context final deleted =
.read<EquipmentProvider>() await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
.deleteEquipment(widget.equipment.id); 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 // Revenir à la page précédente
navigator.pop(); navigator.pop();
@@ -455,19 +477,23 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
// Afficher le snackbar (même si le widget est démonté) // Afficher le snackbar (même si le widget est démonté)
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
const SnackBar( const SnackBar(
content: Text('Équipement supprimé avec succès'), content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
} catch (e) { } catch (e) {
// Afficher l'erreur // Afficher l'erreur
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar(content: Text('Erreur: $e')), SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
); );
} }
}, },
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'), child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
), ),
], ],
), ),
+3 -3
View File
@@ -163,11 +163,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
TextFormField( TextFormField(
controller: _identifierController, controller: _identifierController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Identifiant (Laissez vide pour auto-génération) *', labelText: 'Identifiant *',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.tag), prefixIcon: const Icon(Icons.tag),
hintText: isEditing ? null : 'Auto-attribué par défaut', hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle', helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
), ),
enabled: !isEditing, enabled: !isEditing,
validator: (value) { validator: (value) {
+231 -165
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/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart'; import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.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/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart'; import 'package:em2rp/views/widgets/notification_badge.dart';
@@ -28,7 +29,6 @@ class EquipmentManagementPage extends StatefulWidget {
_EquipmentManagementPageState(); _EquipmentManagementPageState();
} }
class _EquipmentManagementPageState extends State<EquipmentManagementPage> class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> { with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
@@ -66,7 +66,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (_scrollController.hasClients && if (_scrollController.hasClients &&
_scrollController.position.pixels >= _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) { _scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus // Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) { if (provider.hasMore && !provider.isLoadingMore) {
// Pas de setState ici pour éviter les rebuilds pendant le scroll // Pas de setState ici pour éviter les rebuilds pendant le scroll
@@ -76,7 +75,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_isLoadingMore = false; _isLoadingMore = false;
}).catchError((error) { }).catchError((error) {
_isLoadingMore = false; _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() { Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>( return Consumer<EquipmentProvider>(
builder: (context, provider, child) { 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 // Afficher l'indicateur de chargement initial uniquement
if (provider.isLoading && provider.equipment.isEmpty) { 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()); 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) // Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0); final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
@@ -526,124 +529,127 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
// RepaintBoundary pour isoler le repaint de chaque carte // RepaintBoundary pour isoler le repaint de chaque carte
return RepaintBoundary( return RepaintBoundary(
key: ValueKey(equipment.id), key: ValueKey(equipment.id),
child: Card( child: Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
color: isSelectionMode && isSelected color: isSelectionMode && isSelected
? AppColors.rouge.withValues(alpha: 0.1) ? AppColors.rouge.withValues(alpha: 0.1)
: null, : null,
child: ListTile( child: ListTile(
leading: isSelectionMode leading: isSelectionMode
? Checkbox( ? Checkbox(
value: isSelected, value: isSelected,
onChanged: (value) => toggleItemSelection(equipment.id), onChanged: (value) => toggleItemSelection(equipment.id),
activeColor: AppColors.rouge, activeColor: AppColors.rouge,
) )
: CircleAvatar( : CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2), backgroundColor:
child: equipment.category.getIcon( equipment.category.color.withValues(alpha: 0.2),
size: 20, child: equipment.category.getIcon(
color: equipment.category.color, size: 20,
), color: equipment.category.color,
), ),
title: Row( ),
children: [ title: Row(
Expanded( children: [
child: Text( Expanded(
equipment.id, child: Text(
style: const TextStyle(fontWeight: FontWeight.bold), equipment.id,
), style: const TextStyle(fontWeight: FontWeight.bold),
),
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
EquipmentStatusBadge(equipment: equipment),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
.trim()
.isNotEmpty
? '${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) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontStyle: FontStyle.italic,
), ),
), ),
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
EquipmentStatusBadge(equipment: equipment),
], ],
// Afficher la quantité disponible pour les consommables/câbles ),
if (equipment.category == EquipmentCategory.consumable || subtitle: Column(
equipment.category == EquipmentCategory.cable) ...[ crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4), const SizedBox(height: 4),
_buildQuantityDisplay(equipment), Text(
], '${equipment.brand ?? ''} ${equipment.model ?? ''}'
], .trim()
), .isNotEmpty
trailing: isSelectionMode ? '${equipment.brand ?? ''} ${equipment.model ?? ''}'
? null .trim()
: Row( : 'Marque/Modèle non défini',
mainAxisSize: MainAxisSize.min, style: TextStyle(color: Colors.grey[600], fontSize: 14),
children: [ ),
// Bouton Restock (uniquement pour consommables/câbles avec permission) // Afficher la sous-catégorie si elle existe
if (equipment.category == EquipmentCategory.consumable || if (equipment.subCategory != null &&
equipment.category == EquipmentCategory.cable) equipment.subCategory!.isNotEmpty) ...[
PermissionGate( const SizedBox(height: 2),
requiredPermissions: const ['manage_equipment'], Text(
child: IconButton( '📁 ${equipment.subCategory}',
icon: const Icon(Icons.add_shopping_cart, style: TextStyle(
color: AppColors.rouge), color: Colors.grey[500],
tooltip: 'Restock', fontSize: 12,
onPressed: () => _showRestockDialog(equipment), fontStyle: FontStyle.italic,
),
),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
tooltip: 'QR Code',
onPressed: () => showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(equipment),
),
),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () => _editEquipment(equipment),
),
),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _deleteEquipment(equipment),
), ),
), ),
], ],
), // Afficher la quantité disponible pour les consommables/câbles
onTap: isSelectionMode if (equipment.category == EquipmentCategory.consumable ||
? () => toggleItemSelection(equipment.id) equipment.category == EquipmentCategory.cable) ...[
: () => _viewEquipmentDetails(equipment), const SizedBox(height: 4),
), _buildQuantityDisplay(equipment),
) ],
); ],
),
trailing: isSelectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Restock (uniquement pour consommables/câbles avec permission)
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.add_shopping_cart,
color: AppColors.rouge),
tooltip: 'Restock',
onPressed: () => _showRestockDialog(equipment),
),
),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
tooltip: 'QR Code',
onPressed: () => showDialog(
context: context,
builder: (context) =>
QRCodeDialog.forEquipment(equipment),
),
),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () => _editEquipment(equipment),
),
),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _deleteEquipment(equipment),
),
),
],
),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
),
));
} }
Widget _buildQuantityDisplay(EquipmentModel equipment) { Widget _buildQuantityDisplay(EquipmentModel equipment) {
@@ -705,7 +711,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
); );
} }
// Actions // Actions
void _createNewEquipment() { void _createNewEquipment() {
Navigator.push( Navigator.push(
@@ -726,39 +731,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
} }
void _deleteEquipment(EquipmentModel equipment) { void _deleteEquipment(EquipmentModel equipment) {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: equipment.id,
name: equipment.name,
);
showDialog( showDialog(
context: context, context: pageContext,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Confirmer la suppression'), title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'), content: Text(
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'), child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try { try {
await context final deleted =
.read<EquipmentProvider>() await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
.deleteEquipment(equipment.id); context: pageContext,
if (mounted) { equipmentLabel: equipmentLabel,
ScaffoldMessenger.of(context).showSnackBar( deleteEquipment: ({bool forceDelete = false}) {
const SnackBar( return provider.deleteEquipment(
content: Text('Équipement supprimé avec succès')), equipment.id,
); forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
} }
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
} catch (e) { } catch (e) {
if (mounted) { scaffoldMessenger.showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar(content: Text('Erreur: $e')), content: Text(
); EquipmentDeleteUtils.buildDeleteErrorMessage(e),
} ),
),
);
} }
}, },
style: TextButton.styleFrom(foregroundColor: Colors.red), 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 { void _deleteSelectedEquipment() async {
if (!hasSelection) return; if (!hasSelection) return;
final pageContext = context;
showDialog( showDialog(
context: context, context: pageContext,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Confirmer la suppression'), title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text( content: Text(
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?', EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
selectedCount,
),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'), child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context); Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try { 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) { 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(); disableSelectionMode();
if (mounted) { scaffoldMessenger.showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar( content: Text(
content: Text( EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
'$selectedCount équipement(s) supprimé(s) avec succès'), deletedCount,
backgroundColor: Colors.green, ),
), ),
); backgroundColor: Colors.green,
} ),
);
} catch (e) { } catch (e) {
if (mounted) { scaffoldMessenger.showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar(content: Text('Erreur: $e')), content: Text(
); EquipmentDeleteUtils.buildDeleteErrorMessage(e),
} ),
),
);
} }
}, },
style: TextButton.styleFrom(foregroundColor: Colors.red), 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) { if (mounted) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first), builder: (context) =>
QRCodeDialog.forEquipment(selectedEquipment.first),
); );
} }
} else { } else {
@@ -1046,7 +1109,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
); );
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment); await context
.read<EquipmentProvider>()
.updateEquipment(updatedEquipment);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -1184,7 +1249,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( 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, backgroundColor: Colors.orange,
), ),
); );
+22 -25
View File
@@ -77,8 +77,7 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
return; return;
} }
final success = final success = await _controller.submitForm(context, existingEvent: widget.event);
await _controller.submitForm(context, existingEvent: widget.event);
if (success && mounted) { if (success && mounted) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
@@ -159,25 +158,21 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
), ),
body: Center( body: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
child: (isMobile child: (isMobile
? Padding( ? Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
horizontal: 16, vertical: 12),
child: _buildFormContent(isMobile), child: _buildFormContent(isMobile),
) )
: Card( : Card(
elevation: 6, elevation: 6,
margin: const EdgeInsets.all(24), margin: const EdgeInsets.all(24),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
borderRadius: BorderRadius.circular(18)),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
horizontal: 32, vertical: 32),
child: _buildFormContent(isMobile), child: _buildFormContent(isMobile),
), ),
)), )),
@@ -191,6 +186,15 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
Widget _buildFormContent(bool isMobile) { Widget _buildFormContent(bool isMobile) {
return Consumer<EventFormController>( return Consumer<EventFormController>(
builder: (context, controller, child) { builder: (context, controller, child) {
// Trouver le nom du type d'événement pour le passer au sélecteur d'options
final selectedEventTypeIndex = controller.selectedEventTypeId != null
? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
: -1;
final selectedEventType = selectedEventTypeIndex != -1
? controller.eventTypes[selectedEventTypeIndex]
: null;
final selectedEventTypeName = selectedEventType?.name;
return Form( return Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -205,22 +209,18 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
selectedEventTypeId: controller.selectedEventTypeId, selectedEventTypeId: controller.selectedEventTypeId,
startDateTime: controller.startDateTime, startDateTime: controller.startDateTime,
endDateTime: controller.endDateTime, endDateTime: controller.endDateTime,
onEventTypeChanged: (typeId) => onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
controller.onEventTypeChanged(typeId, context),
onStartDateTimeChanged: controller.setStartDateTime, onStartDateTimeChanged: controller.setStartDateTime,
onEndDateTimeChanged: controller.setEndDateTime, onEndDateTimeChanged: controller.setEndDateTime,
onAnyFieldChanged: onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
() {}, // Géré automatiquement par le contrôleur
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
OptionSelectorWidget( OptionSelectorWidget(
eventType: controller eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom
.selectedEventTypeId, // Utilise l'ID au lieu du nom
selectedOptions: controller.selectedOptions, selectedOptions: controller.selectedOptions,
onChanged: controller.setSelectedOptions, onChanged: controller.setSelectedOptions,
onRemove: (optionId) { onRemove: (optionId) {
final newOptions = List<Map<String, dynamic>>.from( final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
controller.selectedOptions);
newOptions.removeWhere((o) => o['id'] == optionId); newOptions.removeWhere((o) => o['id'] == optionId);
controller.setSelectedOptions(newOptions); controller.setSelectedOptions(newOptions);
}, },
@@ -236,7 +236,6 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
endDate: controller.endDateTime, endDate: controller.endDateTime,
onChanged: controller.setAssignedEquipment, onChanged: controller.setAssignedEquipment,
eventId: widget.event?.id, eventId: widget.event?.id,
eventTypeId: controller.selectedEventTypeId,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
EventDetailsSection( EventDetailsSection(
@@ -248,8 +247,7 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
contactEmailController: controller.contactEmailController, contactEmailController: controller.contactEmailController,
contactPhoneController: controller.contactPhoneController, contactPhoneController: controller.contactPhoneController,
isMobile: isMobile, isMobile: isMobile,
onAnyFieldChanged: onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
() {}, // Géré automatiquement par le contrôleur
), ),
EventStaffAndDocumentsSection( EventStaffAndDocumentsSection(
allUsers: controller.allUsers, allUsers: controller.allUsers,
@@ -292,10 +290,9 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
} }
}, },
onSubmit: _submit, onSubmit: _submit,
onSetConfirmed: !isEditMode ? () {} : null, onSetConfirmed: !isEditMode ? () {
onDelete: isEditMode } : null,
? _deleteEvent onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression
: null, // Ajout du callback de suppression
), ),
], ],
), ),
+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/audio_feedback_service.dart';
import 'package:em2rp/services/smart_text_to_speech_service.dart'; import 'package:em2rp/services/smart_text_to_speech_service.dart';
import 'package:em2rp/services/equipment_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/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart'; import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.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 /// Détermine le statut d'un équipement selon l'étape actuelle
String _determineEquipmentStatus(EventEquipment eq) { String _determineEquipmentStatus(EventEquipment eq) {
if (_isNotTakenToEventAtReturn(eq)) {
return 'NOT_TAKEN';
}
// Vérifier d'abord si l'équipement est perdu (LOST) // Vérifier d'abord si l'équipement est perdu (LOST)
if (_shouldMarkAsLost(eq)) { if (_shouldMarkAsLost(eq)) {
return 'LOST'; return 'LOST';
@@ -1118,14 +1123,31 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Vérifie si un équipement doit être marqué comme LOST /// Vérifie si un équipement doit être marqué comme LOST
bool _shouldMarkAsLost(EventEquipment eq) { bool _shouldMarkAsLost(EventEquipment eq) {
// Seulement aux étapes de retour return EventPreparationService.shouldMarkEquipmentAsLost(
if (_currentStep != PreparationStep.return_ && isReturnValidationStep: _isReturnValidationStep,
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) { isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool _isNotTakenToEventAtReturn(EventEquipment eq) {
if (!_isReturnValidationStep) {
return false; return false;
} }
// Si manquant maintenant mais PAS manquant à la préparation = LOST return EventPreparationService.isEquipmentNotTakenToEvent(
return eq.isMissingAtReturn && !eq.isMissingAtPreparation; 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 /// 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,755 +0,0 @@
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:convert';
/// Résultat retourné par le dialog après confirmation de la proposition IA.
class AiProposalResult {
final List<EventEquipment> equipment;
final List<String> containerIds;
const AiProposalResult({
required this.equipment,
required this.containerIds,
});
}
class AiEquipmentAssistantDialog extends StatefulWidget {
final DateTime startDate;
final DateTime endDate;
final String? eventTypeId;
final String? excludeEventId;
final List<EventEquipment> currentAssignedEquipment;
const AiEquipmentAssistantDialog({
super.key,
required this.startDate,
required this.endDate,
required this.currentAssignedEquipment,
this.eventTypeId,
this.excludeEventId,
});
@override
State<AiEquipmentAssistantDialog> createState() =>
_AiEquipmentAssistantDialogState();
}
class _AiEquipmentAssistantDialogState
extends State<AiEquipmentAssistantDialog> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final ScrollController _proposalScrollController = ScrollController();
final List<_AssistantChatMessage> _messages = [];
late final AiEquipmentAssistantService _assistantService;
bool _isLoading = false;
String? _errorMessage;
AiEquipmentProposal? _latestProposal;
late List<EventEquipment> _workingEquipment;
AiEquipmentDocument? _selectedDocument;
List<String> _sessionLogs = [];
Set<String> _selectedContainerIds = {};
@override
void initState() {
super.initState();
_assistantService = AiEquipmentAssistantService();
_workingEquipment = List<EventEquipment>.from(widget.currentAssignedEquipment);
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_proposalScrollController.dispose();
super.dispose();
}
bool get _isChatEmpty => _messages.isEmpty;
String get _actionButtonLabel {
return _isChatEmpty ? 'Generer la liste automatiquement' : 'Envoyer';
}
Future<void> _sendMessage() async {
if (_isLoading) {
return;
}
final rawInput = _messageController.text.trim();
final isAutoMode = _isChatEmpty;
final userMessage = isAutoMode
? (rawInput.isNotEmpty
? rawInput
: 'Genere automatiquement une proposition de materiel pour cet evenement.')
: rawInput;
if (userMessage.isEmpty) {
return;
}
_messageController.clear();
setState(() {
_errorMessage = null;
_messages.add(_AssistantChatMessage.user(userMessage));
if (_selectedDocument != null) {
_messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]'));
}
_isLoading = true;
});
_scrollToBottom();
try {
final documentToSend = _selectedDocument;
_selectedDocument = null; // Clear after sending
final response = await _assistantService
.generateProposal(
startDate: widget.startDate,
endDate: widget.endDate,
eventTypeId: widget.eventTypeId,
excludeEventId: widget.excludeEventId,
currentAssignedEquipment: widget.currentAssignedEquipment,
workingProposalEquipment: _workingEquipment,
userMessage: userMessage,
document: documentToSend,
history: _messages
.map((message) => AiAssistantChatTurn(
isUser: message.isUser, text: message.text))
.toList(),
);
if (!mounted) {
return;
}
setState(() {
_messages
.add(_AssistantChatMessage.assistant(response.assistantMessage));
_latestProposal = response.proposal;
if (response.proposal != null) {
_workingEquipment = List<EventEquipment>.from(
response.proposal!.asEventEquipment,
);
// Préselectionner les containers non partiels
_selectedContainerIds = {
for (final c in response.proposal!.containers)
if (!c.partial) c.containerId
};
}
_sessionLogs.addAll(response.debugLogs);
_isLoading = false;
});
_scrollToBottom();
} on FormatException catch (error) {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = 'Reponse IA invalide: ${error.message}';
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = 'Erreur IA: $error';
});
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) {
return;
}
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
});
}
Future<void> _pickDocument() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.bytes != null) {
final base64String = base64Encode(file.bytes!);
String mimeType = 'application/octet-stream';
if (file.extension == 'pdf') mimeType = 'application/pdf';
else if (file.extension == 'txt') mimeType = 'text/plain';
else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg';
else if (file.extension == 'png') mimeType = 'image/png';
setState(() {
_selectedDocument = AiEquipmentDocument(
base64Data: base64String,
mimeType: mimeType,
fileName: file.name,
);
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Erreur lors de la selection du document : $e';
});
}
}
}
void _showLogsDialog() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Logs de l\'IA'),
content: SizedBox(
width: 800,
height: 600,
child: ListView.builder(
itemCount: _sessionLogs.length,
itemBuilder: (context, index) {
final log = _sessionLogs[index];
final isError = log.startsWith('[ERROR]');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
log,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: isError ? Colors.red : Colors.black87,
),
),
);
},
),
),
actions: [
TextButton(
onPressed: () {
final fullLogs = _sessionLogs.join('\n');
Clipboard.setData(ClipboardData(text: fullLogs));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs copiés dans le presse-papiers')),
);
},
child: const Text('Copier tout'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: SizedBox(
width: 760,
height: 640,
child: Column(
children: [
AppBar(
automaticallyImplyLeading: false,
title: const Text('(BETA) Assistant IA Logisticien'),
actions: [
if (_sessionLogs.isNotEmpty)
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Voir les logs',
onPressed: _showLogsDialog,
),
IconButton(
icon: const Icon(Icons.close),
onPressed:
_isLoading ? null : () => Navigator.of(context).pop(),
),
],
),
Expanded(
child: Column(
children: [
Expanded(
child: Container(
color: Colors.grey.shade50,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return _buildMessageBubble(message);
},
),
),
),
if (_isLoading)
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Expanded(
child: const Text(
'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)',
textAlign: TextAlign.left,
),
),
],
),
),
if (_errorMessage != null)
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade800),
),
),
if (_latestProposal != null)
_buildProposalSummary(_latestProposal!),
if (_selectedDocument != null)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 8),
child: Row(
children: [
const Icon(Icons.attach_file, color: Colors.blue, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedDocument!.fileName ?? 'Document joint',
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () {
setState(() {
_selectedDocument = null;
});
},
tooltip: 'Retirer le document',
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.attach_file),
onPressed: _isLoading ? null : _pickDocument,
tooltip: 'Joindre un devis ou document',
),
Expanded(
child: TextField(
controller: _messageController,
enabled: !_isLoading,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText:
'Precisez votre besoin (style, jauge, contraintes...)',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _isLoading ? null : _sendMessage,
child: Text(_actionButtonLabel),
),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildMessageBubble(_AssistantChatMessage message) {
final bubbleColor = message.isUser ? Colors.blue.shade600 : Colors.white;
final textColor = message.isUser ? Colors.white : Colors.black87;
return Align(
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
constraints: const BoxConstraints(maxWidth: 560),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
bottomRight: Radius.circular(message.isUser ? 4 : 16),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border:
message.isUser ? null : Border.all(color: Colors.grey.shade200),
),
child: message.isUser
? Text(message.text, style: TextStyle(color: textColor))
: _buildAssistantMessageContent(message.text),
),
);
}
Widget _buildAssistantMessageContent(String text) {
// Si le message semble structuré par l'IA avec nos nouvelles règles
if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) {
final sections = text.split('\n\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.map((section) {
final isAdded = section.contains('Matériel ajouté :');
final isMissing = section.contains('Matériel non trouvé');
if (isAdded) {
return _buildStatusSection(
title: section.split('\n').first,
content: section.split('\n').skip(1).join('\n'),
icon: Icons.check_circle_outline,
color: Colors.green.shade700,
bgColor: Colors.green.shade50,
);
} else if (isMissing) {
return _buildStatusSection(
title: section.split('\n').first,
content: section.split('\n').skip(1).join('\n'),
icon: Icons.warning_amber_rounded,
color: Colors.orange.shade800,
bgColor: Colors.orange.shade50,
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(section),
);
}).toList(),
);
}
return Text(text);
}
Widget _buildStatusSection({
required String title,
required String content,
required IconData icon,
required Color color,
required Color bgColor,
}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
title.replaceAll(':', '').trim(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
),
],
),
if (content.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
content.trim(),
style: TextStyle(fontSize: 13, color: Colors.grey.shade800),
),
],
],
),
);
}
void _confirmProposal({bool excludeAlternatives = false}) {
if (_latestProposal == null) return;
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
// Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets)
final List<String> containerIds = _selectedContainerIds.isNotEmpty
? _selectedContainerIds.toList()
: List.from(_latestProposal!.containerIds);
if (excludeAlternatives) {
// On utilise la liste des items d'origine pour savoir lesquels exclure
// car ils contiennent le champ rationale (avant conversion en EventEquipment)
final idsToExclude = _latestProposal!.items
.where((item) {
final rationale = item.rationale.toLowerCase();
return rationale.contains('alternative') ||
rationale.contains('remplacement') ||
rationale.contains('indisponible');
})
.map((item) => item.equipmentId)
.toSet();
equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList();
}
Navigator.of(context).pop(
AiProposalResult(
equipment: equipment,
containerIds: containerIds,
),
);
}
Widget _buildProposalSummary(AiEquipmentProposal proposal) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxHeight: 280),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Récapitulatif de la proposition IA',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.indigo,
),
),
),
],
),
const SizedBox(height: 12),
Flexible(
child: Scrollbar(
controller: _proposalScrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _proposalScrollController,
padding: const EdgeInsets.only(right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
proposal.summary,
style: const TextStyle(fontStyle: FontStyle.italic),
),
if (proposal.items.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Matériel individuel :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
...proposal.items.map((item) {
final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement');
return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isAlt ? Icons.swap_horiz : Icons.add_circle_outline,
size: 14,
color: isAlt ? Colors.orange : Colors.indigo,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${item.equipmentId} x${item.quantity}',
style: const TextStyle(fontWeight: FontWeight.w500)
),
),
],
),
);
}),
],
if (proposal.containers.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Fly-cases & Boîtes :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
...proposal.containers.map((c) {
final isPartial = c.partial;
final isSelected = _selectedContainerIds.contains(c.containerId);
return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row(
children: [
Icon(
Icons.inventory_2_outlined,
size: 14,
color: c.available == false ? Colors.red : Colors.indigo,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))),
if (c.available == false)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(Icons.block, color: Colors.red.shade700, size: 14),
),
],
),
if (isPartial) ...[
const SizedBox(height: 4),
Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
],
],
),
),
const SizedBox(width: 8),
if (isPartial)
Checkbox(
value: isSelected,
onChanged: (v) {
setState(() {
if (v == true) _selectedContainerIds.add(c.containerId);
else _selectedContainerIds.remove(c.containerId);
});
},
),
],
),
);
}),
],
],
),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _isLoading ? null : () => _confirmProposal(),
icon: const Icon(Icons.check),
label: const Text('Tout ajouter'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
),
OutlinedButton.icon(
onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true),
icon: const Icon(Icons.filter_list_off),
label: const Text('Ajouter sans alternatives'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.indigo,
side: const BorderSide(color: Colors.indigo),
),
),
],
),
],
),
);
}
}
class _AssistantChatMessage {
final bool isUser;
final String text;
const _AssistantChatMessage._({required this.isUser, required this.text});
factory _AssistantChatMessage.user(String text) {
return _AssistantChatMessage._(isUser: true, text: text);
}
factory _AssistantChatMessage.assistant(String text) {
return _AssistantChatMessage._(isUser: false, text: text);
}
}
@@ -8,7 +8,6 @@ import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart'; import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
import 'package:em2rp/views/widgets/event_form/ai_equipment_assistant_dialog.dart';
/// Section pour afficher et gérer le matériel assigné à un événement /// Section pour afficher et gérer le matériel assigné à un événement
class EventAssignedEquipmentSection extends StatefulWidget { class EventAssignedEquipmentSection extends StatefulWidget {
@@ -18,7 +17,6 @@ class EventAssignedEquipmentSection extends StatefulWidget {
final DateTime? endDate; final DateTime? endDate;
final Function(List<EventEquipment>, List<String>) onChanged; final Function(List<EventEquipment>, List<String>) onChanged;
final String? eventId; // Pour exclure l'événement actuel de la vérification final String? eventId; // Pour exclure l'événement actuel de la vérification
final String? eventTypeId;
const EventAssignedEquipmentSection({ const EventAssignedEquipmentSection({
super.key, super.key,
@@ -28,18 +26,14 @@ class EventAssignedEquipmentSection extends StatefulWidget {
required this.endDate, required this.endDate,
required this.onChanged, required this.onChanged,
this.eventId, this.eventId,
this.eventTypeId,
}); });
@override @override
State<EventAssignedEquipmentSection> createState() => State<EventAssignedEquipmentSection> createState() => _EventAssignedEquipmentSectionState();
_EventAssignedEquipmentSectionState();
} }
class _EventAssignedEquipmentSectionState class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
extends State<EventAssignedEquipmentSection> { bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
bool get _canAddMaterial =>
widget.startDate != null && widget.endDate != null;
final Map<String, EquipmentModel> _equipmentCache = {}; final Map<String, EquipmentModel> _equipmentCache = {};
final Map<String, ContainerModel> _containerCache = {}; final Map<String, ContainerModel> _containerCache = {};
bool _isLoading = true; bool _isLoading = true;
@@ -67,24 +61,19 @@ class _EventAssignedEquipmentSectionState
final equipmentProvider = context.read<EquipmentProvider>(); final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>(); final containerProvider = context.read<ContainerProvider>();
DebugLog.info( DebugLog.info('[EventAssignedEquipmentSection] Loading caches from assigned lists');
'[EventAssignedEquipmentSection] Loading caches from assigned lists');
// Toujours partir des données locales du formulaire pour éviter les décalages visuels. // Toujours partir des données locales du formulaire pour éviter les décalages visuels.
final equipmentIds = final equipmentIds = widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
widget.assignedEquipment.map((eq) => eq.equipmentId).toList(); final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
final containers =
await containerProvider.getContainersByIds(widget.assignedContainers);
final childEquipmentIds = <String>[]; final childEquipmentIds = <String>[];
for (final container in containers) { for (final container in containers) {
childEquipmentIds.addAll(container.equipmentIds); childEquipmentIds.addAll(container.equipmentIds);
} }
final allEquipmentIds = final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
<String>{...equipmentIds, ...childEquipmentIds}.toList(); final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
final equipment =
await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
_equipmentCache.clear(); _equipmentCache.clear();
_containerCache.clear(); _containerCache.clear();
@@ -121,9 +110,7 @@ class _EventAssignedEquipmentSectionState
_containerCache[containerId] = container; _containerCache[containerId] = container;
} }
} catch (e) { } catch (e) {
DebugLog.error( DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
'[EventAssignedEquipmentSection] Error loading equipment and containers',
e);
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -151,8 +138,7 @@ class _EventAssignedEquipmentSectionState
} }
Future<void> _processSelection(Map<String, SelectedItem> selection) async { Future<void> _processSelection(Map<String, SelectedItem> selection) async {
DebugLog.info( DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
'[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
// Séparer équipements et conteneurs // Séparer équipements et conteneurs
final newEquipment = <EventEquipment>[]; final newEquipment = <EventEquipment>[];
@@ -169,27 +155,23 @@ class _EventAssignedEquipmentSectionState
} }
} }
DebugLog.info( DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
'[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants // 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
if (newContainers.isNotEmpty) { if (newContainers.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>(); final containerProvider = context.read<ContainerProvider>();
final containers = final containers = await containerProvider.getContainersByIds(newContainers);
await containerProvider.getContainersByIds(newContainers);
for (var container in containers) { for (var container in containers) {
for (var childEquipmentId in container.equipmentIds) { for (var childEquipmentId in container.equipmentIds) {
// Vérifier si l'équipement enfant n'est pas déjà dans la liste // Vérifier si l'équipement enfant n'est pas déjà dans la liste
final existsInNew = final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!existsInNew) { if (!existsInNew) {
newEquipment.add(EventEquipment( newEquipment.add(EventEquipment(
equipmentId: childEquipmentId, equipmentId: childEquipmentId,
quantity: 1, quantity: 1,
)); ));
DebugLog.info( DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
'[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
} }
} }
} }
@@ -204,8 +186,7 @@ class _EventAssignedEquipmentSectionState
// Pour chaque nouvel équipement // Pour chaque nouvel équipement
for (var eq in newEquipment) { for (var eq in newEquipment) {
final existingIndex = final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
if (existingIndex != -1) { if (existingIndex != -1) {
// L'équipement existe déjà : mettre à jour la quantité // L'équipement existe déjà : mettre à jour la quantité
@@ -234,74 +215,6 @@ class _EventAssignedEquipmentSectionState
widget.onChanged(updatedEquipment, updatedContainers); widget.onChanged(updatedEquipment, updatedContainers);
} }
Future<void> _openAiAssistantDialog() async {
if (widget.startDate == null || widget.endDate == null) {
return;
}
final result = await showDialog<AiProposalResult>(
context: context,
builder: (context) => AiEquipmentAssistantDialog(
startDate: widget.startDate!,
endDate: widget.endDate!,
eventTypeId: widget.eventTypeId,
excludeEventId: widget.eventId,
currentAssignedEquipment: widget.assignedEquipment,
),
);
if (result == null) {
return;
}
_applyAiProposal(result);
}
void _applyAiProposal(AiProposalResult result) async {
final existingById = {
for (final equipment in widget.assignedEquipment)
equipment.equipmentId: equipment,
};
final updatedEquipment = result.equipment.map((proposed) {
final existing = existingById[proposed.equipmentId];
if (existing == null) {
return proposed;
}
return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale);
}).toList();
// 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants
if (result.containerIds.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.getContainersByIds(result.containerIds);
for (var container in containers) {
for (var childEquipmentId in container.equipmentIds) {
// Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition)
final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!exists) {
updatedEquipment.add(EventEquipment(
equipmentId: childEquipmentId,
quantity: 1,
rationale: 'Inclus dans ${container.id}',
));
DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}');
}
}
}
}
final updatedContainers = [...widget.assignedContainers];
for (final containerId in result.containerIds) {
if (!updatedContainers.contains(containerId)) {
updatedContainers.add(containerId);
}
}
widget.onChanged(updatedEquipment, updatedContainers);
}
void _removeEquipment(String equipmentId) { void _removeEquipment(String equipmentId) {
final updated = widget.assignedEquipment final updated = widget.assignedEquipment
.where((eq) => eq.equipmentId != equipmentId) .where((eq) => eq.equipmentId != equipmentId)
@@ -318,8 +231,9 @@ class _EventAssignedEquipmentSectionState
final container = _containerCache[containerId]; final container = _containerCache[containerId];
// Retirer le conteneur de la liste // Retirer le conteneur de la liste
final updatedContainers = final updatedContainers = widget.assignedContainers
widget.assignedContainers.where((id) => id != containerId).toList(); .where((id) => id != containerId)
.toList();
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container // 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
final updatedEquipment = <EventEquipment>[]; final updatedEquipment = <EventEquipment>[];
@@ -338,10 +252,8 @@ class _EventAssignedEquipmentSectionState
// 1. Ne sont PAS dans le container supprimé OU // 1. Ne sont PAS dans le container supprimé OU
// 2. Sont dans le container supprimé MAIS aussi dans un autre container // 2. Sont dans le container supprimé MAIS aussi dans un autre container
for (var eq in widget.assignedEquipment) { for (var eq in widget.assignedEquipment) {
final isInRemovedContainer = final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
container.equipmentIds.contains(eq.equipmentId); final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
final isInOtherContainer =
equipmentIdsInOtherContainers.contains(eq.equipmentId);
if (!isInRemovedContainer || isInOtherContainer) { if (!isInRemovedContainer || isInOtherContainer) {
updatedEquipment.add(eq); updatedEquipment.add(eq);
@@ -359,8 +271,7 @@ class _EventAssignedEquipmentSectionState
_containerCache.remove(containerId); _containerCache.remove(containerId);
// Nettoyer le cache uniquement pour les équipements effectivement supprimés // Nettoyer le cache uniquement pour les équipements effectivement supprimés
if (container != null) { if (container != null) {
final remainingEquipmentIds = final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
updatedEquipment.map((eq) => eq.equipmentId).toSet();
for (var equipmentId in container.equipmentIds) { for (var equipmentId in container.equipmentIds) {
if (!remainingEquipmentIds.contains(equipmentId)) { if (!remainingEquipmentIds.contains(equipmentId)) {
_equipmentCache.remove(equipmentId); _equipmentCache.remove(equipmentId);
@@ -390,8 +301,7 @@ class _EventAssignedEquipmentSectionState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final totalItems = final totalItems = widget.assignedEquipment.length + widget.assignedContainers.length;
widget.assignedEquipment.length + widget.assignedContainers.length;
return Card( return Card(
elevation: 2, elevation: 2,
@@ -440,25 +350,15 @@ class _EventAssignedEquipmentSectionState
], ],
), ),
), ),
ActionChip(
onPressed: _canAddMaterial ? _openAiAssistantDialog : null,
avatar: const Icon(Icons.auto_fix_high, size: 18),
label: const Text('Assistant IA'),
),
const SizedBox(width: 8),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _canAddMaterial ? _openSelectionDialog : null, onPressed: _canAddMaterial ? _openSelectionDialog : null,
icon: Icon(Icons.add, icon: Icon(Icons.add, color: _canAddMaterial ? Colors.white : Colors.grey),
color: _canAddMaterial ? Colors.white : Colors.grey),
label: Text( label: Text(
'Ajouter', 'Ajouter',
style: TextStyle( style: TextStyle(color: _canAddMaterial ? Colors.white : Colors.grey),
color: _canAddMaterial ? Colors.white : Colors.grey),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _canAddMaterial backgroundColor: _canAddMaterial ? AppColors.rouge : Colors.grey.shade300,
? AppColors.rouge
: Colors.grey.shade300,
), ),
), ),
], ],
@@ -612,8 +512,7 @@ class _EventAssignedEquipmentSectionState
} }
return Padding( return Padding(
padding: padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -638,8 +537,7 @@ class _EventAssignedEquipmentSectionState
color: Colors.grey.shade600, color: Colors.grey.shade600,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
eq.category eq.category.getIcon(size: 16, color: eq.category.color),
.getIcon(size: 16, color: eq.category.color),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -664,8 +562,7 @@ class _EventAssignedEquipmentSectionState
); );
} }
Widget _buildEquipmentItem( Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
EquipmentModel? equipment, EventEquipment eventEq) {
if (equipment == null) { if (equipment == null) {
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -688,15 +585,17 @@ class _EventAssignedEquipmentSectionState
} }
final isConsumable = equipment.category == EquipmentCategory.consumable || final isConsumable = equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable; equipment.category == EquipmentCategory.cable;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2), backgroundColor: equipment.category.color.withValues(alpha: 0.2),
child: equipment.category child: equipment.category.getIconForAvatar(
.getIconForAvatar(size: 24, color: equipment.category.color), size: 24,
color: equipment.category.color
),
), ),
title: Text( title: Text(
equipment.id, equipment.id,
@@ -735,3 +634,4 @@ class _EventAssignedEquipmentSectionState
); );
} }
} }
+1
View File
@@ -17,6 +17,7 @@ dependencies:
cloud_functions: ^6.0.4 cloud_functions: ^6.0.4
google_sign_in: ^7.2.0 google_sign_in: ^7.2.0
firebase_storage: ^13.0.3 firebase_storage: ^13.0.3
shared_preferences: ^2.0.15
# State Management # State Management
provider: ^6.1.2 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.1.23",
"updateUrl": "https://app.em2events.fr", "updateUrl": "https://app.em2events.fr",
"forceUpdate": true, "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.", "releaseNotes": "Optimisation du lancement de l'application et amélioration de la gestion du cache.",
"timestamp": "2026-03-12T20:11:54.548Z" "timestamp": "2026-05-05T09:52:18.860Z"
} }