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