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