3 Commits

Author SHA1 Message Date
ElPoyo 0551f0b9c1 feat: Mise à jour à la version 1.1.20 et amélioration de la recherche d'équipements
- Mise à jour de la version de l'application à `1.1.20` dans `app_version.dart`, `version.json` et `CHANGELOG.md`.
- Optimisation de la fonction Cloud `getEquipmentsPaginated` pour supporter la recherche par ID exact (document ID ou ID legacy) et améliorer la recherche textuelle avec filtrage par lots.
- Amélioration de la gestion des alertes dans `processEquipmentValidation.js` :
    - Ajout d'un statut `NOT_TAKEN` pour éviter les fausses alertes d'équipements perdus s'ils n'ont jamais été emportés.
    - Refonte complète du parsing des dates Firestore pour une meilleure robustesse dans les alertes.
    - Correction de la validation des quantités (vérification du type `number`).
- Ajout de méthodes statiques dans `EventPreparationService` (`shouldMarkEquipmentAsLost`, `isEquipmentNotTakenToEvent`) pour centraliser la logique de détermination du statut des équipements au retour.
- Mise à jour de `EventPreparationPage` pour intégrer le nouveau statut `NOT_TAKEN` et utiliser la logique centralisée du service de préparation.
- Mise à jour des fichiers de cache Firebase Hosting.
2026-03-30 17:12:48 +02:00
ElPoyo cf13b4a986 .env gitignore 2026-03-28 21:38:56 +01:00
ElPoyo 3f80d9318b feat: Mise à jour à la version 1.1.19 et amélioration du cache/pagination pour la sélection d'équipements
- Mise à jour de la version de l'application à `1.1.19` dans `app_version.dart` et `version.json`.
- Correction d'un bug de cache dans `EquipmentSelectionDialog` qui empêchait l'affichage de certains équipements lors de la sélection.
- Introduction d'une fonction utilitaire `shouldAutoLoadNextPage` et de tests unitaires associés pour fiabiliser le chargement automatique des données.
- Ajout d'une gestion de préchargement automatique dans `EquipmentSelectionDialog` lorsque la liste n'est pas assez longue pour activer le défilement (évite les vues tronquées).
- Amélioration de `ContainerFormPage` pour forcer le rechargement complet de la liste des équipements, évitant ainsi les conflits avec les états de pagination d'autres écrans.
- Optimisation du chargement des conflits de disponibilité et des quantités via un chargement par lots (batch).
- Nettoyage du code et amélioration de la lisibilité des fichiers `container_form_page.dart` et `equipment_selection_dialog.dart`.
2026-03-24 12:18:00 +01:00
13 changed files with 1250 additions and 707 deletions
+13 -13
View File
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1773324020831,d5cd7334d7c3a990dbff0821b9aaab39129803e306b0d96599b8adc6d4f433a6 version.json,1774883074073,049c47e9089dc5497475a6cf7733e11235bc9cfa30d458cc9a8eae761214c2b8
index.html,1773324025840,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 flutter_service_worker.js,1774883173949,00cc791f6cc0d2beb4b16cc382b049268125aa6a7c5b73cd4bc89a003fc70f3a
flutter_service_worker.js,1773324116910,8582e401e070055f59183c207cf7a7e6a9219a50f5089a24a77d91d3ff77dcbc index.html,1774883102020,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_bootstrap.js,1773324025827,74eaa66055c715df232ee96fc4114d5473f67717278fb4effa38d8b1b362e303 flutter_bootstrap.js,1774883102005,80bbca812eb76632e250fe5c6b726db647443cbabc7f90010618e6a6f445d222
assets/FontManifest.json,1773324113335,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 assets/FontManifest.json,1774883170660,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1773324113335,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 assets/AssetManifest.bin,1774883170657,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/AssetManifest.bin.json,1773324113335,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 assets/AssetManifest.bin.json,1774883170660,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1773324113335,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 assets/shaders/ink_sparkle.frag,1774883170848,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773324115847,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1774883173201,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1773324113551,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 assets/AssetManifest.json,1774883170657,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/fonts/MaterialIcons-Regular.otf,1773324115852,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c assets/fonts/MaterialIcons-Regular.otf,1774883173207,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
assets/NOTICES,1773324113339,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 assets/NOTICES,1774883170660,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
main.dart.js,1773324112059,bfc66ab7e817db63dee4b996af3dea0629c4c4e87ba91070c15b133ab5104848 main.dart.js,1774883168025,bc4bc60206728a982496fe5977f48e690fe8abdfd1167a9226de18fe0052cdcf
+2
View File
@@ -45,3 +45,5 @@ app.*.map.json
# Environment configuration with credentials # Environment configuration with credentials
lib/config/env.dev.dart lib/config/env.dev.dart
functions/.env functions/.env
.env
env.dart
+7
View File
@@ -1,6 +1,13 @@
# 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.
## 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.
+185 -36
View File
@@ -3825,18 +3825,97 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
// Convertir en majuscules pour correspondre au format Firestore // Convertir en majuscules pour correspondre au format Firestore
const category = params.category ? params.category.toUpperCase() : null; const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null; const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null; const rawSearchQuery = typeof params.searchQuery === 'string' ? params.searchQuery.trim() : '';
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, '') : null;
const sortBy = params.sortBy || 'id'; const sortBy = params.sortBy || 'id';
const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc'; const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc';
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`); logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
// Fast-path pour une recherche d'ID exact: évite le cap queryLimit lors d'une recherche précise.
if (searchQuery && !startAfterId) {
const exactIdCandidates = Array.from(new Set([
rawSearchQuery,
rawSearchQuery.toUpperCase(),
rawSearchQuery.toLowerCase()
].filter(Boolean)));
for (const candidateId of exactIdCandidates) {
const exactDoc = await db.collection('equipments').doc(candidateId).get();
if (!exactDoc.exists) {
continue;
}
const exactData = exactDoc.data() || {};
const matchesCategory = !category || exactData.category === category;
const matchesStatus = !status || exactData.status === status;
if (!matchesCategory || !matchesStatus) {
continue;
}
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
id: exactDoc.id
};
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1
});
return;
}
// Compatibilité legacy: certains documents peuvent stocker un ancien champ `id` différent du document ID.
for (const legacyId of exactIdCandidates) {
let legacyIdQuery = db.collection('equipments').where('id', '==', legacyId);
if (category) {
legacyIdQuery = legacyIdQuery.where('category', '==', category);
}
if (status) {
legacyIdQuery = legacyIdQuery.where('status', '==', status);
}
const legacySnapshot = await legacyIdQuery.limit(1).get();
if (legacySnapshot.empty) {
continue;
}
const exactDoc = legacySnapshot.docs[0];
const exactData = exactDoc.data() || {};
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
id: exactDoc.id
};
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1
});
return;
}
}
// Construire la requête Firestore // Construire la requête Firestore
let query = db.collection('equipments'); let query = db.collection('equipments');
// Si recherche textuelle, on augmente la limite pour filtrer ensuite
const queryLimit = searchQuery ? Math.min(limit * 10, 200) : limit;
// Appliquer les filtres // Appliquer les filtres
if (category) { if (category) {
query = query.where('category', '==', category); query = query.where('category', '==', category);
@@ -3861,20 +3940,10 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
} }
} }
// Limiter les résultats const timestampFields = ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'];
query = query.limit(queryLimit + 1);
const snapshot = await query.get(); const mapEquipmentDoc = (doc) => {
const data = {...(doc.data() || {})};
// Déterminer hasMore basé sur le nombre de documents Firestore
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
let equipments = docsToProcess.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment // Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) { if (!canManage) {
@@ -3882,32 +3951,50 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
delete data.rentalPrice; delete data.rentalPrice;
} }
return { const legacyId = typeof data.id === 'string' ? data.id : '';
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
// Filtrage textuel côté serveur return {
if (searchQuery) { ...helpers.serializeTimestamps(data, timestampFields),
equipments = equipments.filter(eq => { id: doc.id,
_legacyId: legacyId
};
};
const matchesSearchQuery = (equipment) => {
const searchableText = [ const searchableText = [
eq.name || '', equipment.name || '',
eq.id || '', equipment.id || '',
eq.model || '', equipment._legacyId || '',
eq.brand || '', equipment.model || '',
eq.subCategory || '' equipment.brand || '',
equipment.subCategory || ''
].join(' ').toLowerCase(); ].join(' ').toLowerCase();
return searchableText.includes(searchQuery);
}); if (searchableText.includes(searchQuery)) {
return true;
} }
// Pour la limite finale après filtrage textuel if (!compactSearchQuery) {
const limitedEquipments = equipments.slice(0, limit); 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; const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
// hasMore reste basé sur le nombre de docs Firestore, pas sur le filtrage textuel logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`); logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
res.status(200).json({ res.status(200).json({
equipments: limitedEquipments, equipments: limitedEquipments,
@@ -3915,6 +4002,68 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
lastVisible, lastVisible,
total: limitedEquipments.length total: limitedEquipments.length
}); });
return;
}
// En mode recherche, scanner la collection par lots jusqu'à obtenir `limit + 1` matchs
// afin de garantir des résultats même si les documents pertinents sont loin dans l'ordre de tri.
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
const matchedEquipments = [];
let scannedDocuments = 0;
let searchQueryRef = query;
let hasMoreMatches = false;
let hasMoreDocsToScan = true;
while (hasMoreDocsToScan && !hasMoreMatches) {
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
if (snapshot.empty) {
hasMoreDocsToScan = false;
break;
}
scannedDocuments += snapshot.docs.length;
for (const doc of snapshot.docs) {
const equipment = mapEquipmentDoc(doc);
if (!matchesSearchQuery(equipment)) {
continue;
}
matchedEquipments.push(equipment);
if (matchedEquipments.length > limit) {
hasMoreMatches = true;
break;
}
}
if (hasMoreMatches) {
break;
}
if (snapshot.docs.length < searchBatchSize) {
hasMoreDocsToScan = false;
break;
}
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
searchQueryRef = query.startAfter(lastDocInBatch);
}
const limitedEquipments = matchedEquipments
.slice(0, limit)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreMatches,
lastVisible,
total: limitedEquipments.length
});
} catch (error) { } catch (error) {
logger.error("Error fetching paginated equipments:", error); logger.error("Error fetching paginated equipments:", error);
+51 -6
View File
@@ -50,6 +50,11 @@ exports.processEquipmentValidation = onCall({
for (const equipment of equipmentList) { for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment; const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
if (status === 'NOT_TAKEN') {
continue;
}
// Cas 1: Équipement PERDU // Cas 1: Équipement PERDU
if (status === 'LOST') { if (status === 'LOST') {
const alertData = await createAlertInFirestore({ const alertData = await createAlertInFirestore({
@@ -91,7 +96,9 @@ exports.processEquipmentValidation = onCall({
} }
// Cas 3: Quantité incorrecte // Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) { const hasExpectedQuantity = typeof expectedQuantity === 'number';
const hasActualQuantity = typeof quantity === 'number';
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({ const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH', type: 'QUANTITY_MISMATCH',
severity: 'INFO', severity: 'INFO',
@@ -409,10 +416,48 @@ async function sendAlertEmails(alert, userIds) {
* Formate la date d'un événement * Formate la date d'un événement
*/ */
function formatEventDate(event) { function formatEventDate(event) {
if (event.startDate) { const rawDate =
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate); event?.StartDateTime ||
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'}); event?.startDateTime ||
} event?.startDate ||
return 'Date inconnue'; event?.eventDate;
const parsedDate = parseFirestoreDate(rawDate);
const safeDate = parsedDate || new Date();
return safeDate.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'numeric',
year: 'numeric',
});
}
function parseFirestoreDate(value) {
if (!value) {
return null;
}
if (typeof value.toDate === 'function') {
return value.toDate();
}
if (value instanceof Date) {
return value;
}
if (typeof value === 'string' || typeof value === 'number') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof value === 'object' && typeof value.seconds === 'number') {
return new Date(value.seconds * 1000);
}
if (typeof value === 'object' && typeof value._seconds === 'number') {
return new Date(value._seconds * 1000);
}
return null;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/// Configuration de la version de l'application /// Configuration de la version de l'application
class AppVersion { class AppVersion {
static const String version = '1.1.18'; static const String version = '1.1.20';
/// 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';
@@ -4,6 +4,44 @@ import 'package:em2rp/services/api_service.dart';
class EventPreparationService { class EventPreparationService {
final ApiService _apiService = apiService; final ApiService _apiService = apiService;
/// Retourne true si l'équipement était absent du flux événementiel.
///
/// Cas typique: matériel jamais emporté au départ, donc absent au retour,
/// mais qui ne doit jamais être classé en [LOST].
static bool isEquipmentNotTakenToEvent({
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isMissingAtReturn) {
return false;
}
final loadedQuantity = quantityAtLoading ?? 0;
return !isLoaded || isMissingAtLoading || loadedQuantity <= 0;
}
/// Retourne true uniquement si l'équipement doit être classé perdu.
static bool shouldMarkEquipmentAsLost({
required bool isReturnValidationStep,
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isReturnValidationStep || !isMissingAtReturn) {
return false;
}
return !isEquipmentNotTakenToEvent(
isMissingAtReturn: isMissingAtReturn,
isLoaded: isLoaded,
isMissingAtLoading: isMissingAtLoading,
quantityAtLoading: quantityAtLoading,
);
}
// === PRÉPARATION === // === PRÉPARATION ===
/// Valider un équipement individuel en préparation /// Valider un équipement individuel en préparation
+41 -17
View File
@@ -100,7 +100,6 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
child: ListView( child: ListView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
children: [ children: [
// Nom // Nom
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
@@ -257,7 +256,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
prefixIcon: Icon(Icons.scale), prefixIcon: Icon(Icons.scale),
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -279,7 +279,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Longueur (cm)', labelText: 'Longueur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -298,7 +299,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Largeur (cm)', labelText: 'Largeur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -317,7 +319,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Hauteur (cm)', labelText: 'Hauteur (cm)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.numberWithOptions(decimal: true), keyboardType:
TextInputType.numberWithOptions(decimal: true),
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) { if (double.tryParse(value) == null) {
@@ -452,6 +455,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
Future<void> _selectEquipment() async { Future<void> _selectEquipment() async {
final equipmentProvider = context.read<EquipmentProvider>(); final equipmentProvider = context.read<EquipmentProvider>();
// Toujours charger la liste complète pour éviter d'afficher uniquement
// la page paginée active d'un autre écran.
await equipmentProvider.loadEquipments();
if (!mounted) return;
await showDialog( await showDialog(
context: context, context: context,
builder: (context) => _EquipmentSelectorDialog( builder: (context) => _EquipmentSelectorDialog(
@@ -460,6 +468,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
), ),
); );
if (!mounted) return;
setState(() {}); setState(() {});
} }
@@ -535,7 +544,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e); DebugLog.error(
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
} }
} }
@@ -573,7 +583,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
}); });
// Gérer les équipements ajoutés // Gérer les équipements ajoutés
final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet()); final addedEquipment =
_selectedEquipmentIds.difference(container.equipmentIds.toSet());
for (final equipmentId in addedEquipment) { for (final equipmentId in addedEquipment) {
try { try {
await provider.addEquipmentToContainer( await provider.addEquipmentToContainer(
@@ -581,12 +592,14 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e); DebugLog.error(
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
} }
} }
// Gérer les équipements retirés // Gérer les équipements retirés
final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds); final removedEquipment =
container.equipmentIds.toSet().difference(_selectedEquipmentIds);
for (final equipmentId in removedEquipment) { for (final equipmentId in removedEquipment) {
try { try {
await provider.removeEquipmentFromContainer( await provider.removeEquipmentFromContainer(
@@ -594,7 +607,8 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e); DebugLog.error(
'Erreur lors du retrait de l\'équipement $equipmentId', e);
} }
} }
@@ -630,7 +644,8 @@ class _EquipmentSelectorDialog extends StatefulWidget {
}); });
@override @override
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState(); State<_EquipmentSelectorDialog> createState() =>
_EquipmentSelectorDialogState();
} }
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
@@ -638,12 +653,14 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
EquipmentCategory? _filterCategory; EquipmentCategory? _filterCategory;
String _searchQuery = ''; String _searchQuery = '';
late Set<String> _tempSelectedIds; late Set<String> _tempSelectedIds;
late final Future<void> _loadingFuture;
@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();
} }
@override @override
@@ -729,7 +746,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
}, },
selectedColor: AppColors.rouge, selectedColor: AppColors.rouge,
labelStyle: TextStyle( labelStyle: TextStyle(
color: _filterCategory == null ? Colors.white : Colors.black, color:
_filterCategory == null ? Colors.white : Colors.black,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -746,7 +764,9 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
}, },
selectedColor: AppColors.rouge, selectedColor: AppColors.rouge,
labelStyle: TextStyle( labelStyle: TextStyle(
color: _filterCategory == category ? Colors.white : Colors.black, color: _filterCategory == category
? Colors.white
: Colors.black,
), ),
), ),
); );
@@ -778,8 +798,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
// Liste des équipements // Liste des équipements
Expanded( Expanded(
child: StreamBuilder<List<EquipmentModel>>( child: FutureBuilder<void>(
stream: widget.equipmentProvider.equipmentStream, future: _loadingFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -789,11 +809,15 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
return Center(child: Text('Erreur: ${snapshot.error}')); return Center(child: Text('Erreur: ${snapshot.error}'));
} }
var equipment = snapshot.data ?? []; var equipment = List<EquipmentModel>.from(
widget.equipmentProvider.allEquipment,
);
// Filtrer par catégorie // Filtrer par catégorie
if (_filterCategory != null) { if (_filterCategory != null) {
equipment = equipment.where((e) => e.category == _filterCategory).toList(); equipment = equipment
.where((e) => e.category == _filterCategory)
.toList();
} }
// Filtrer par recherche // Filtrer par recherche
+27 -5
View File
@@ -13,6 +13,7 @@ import 'package:em2rp/services/qr_code_processing_service.dart';
import 'package:em2rp/services/audio_feedback_service.dart'; import 'package:em2rp/services/audio_feedback_service.dart';
import 'package:em2rp/services/smart_text_to_speech_service.dart'; import 'package:em2rp/services/smart_text_to_speech_service.dart';
import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/event_preparation_service.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart'; import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart'; import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
@@ -1097,6 +1098,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Détermine le statut d'un équipement selon l'étape actuelle /// Détermine le statut d'un équipement selon l'étape actuelle
String _determineEquipmentStatus(EventEquipment eq) { String _determineEquipmentStatus(EventEquipment eq) {
if (_isNotTakenToEventAtReturn(eq)) {
return 'NOT_TAKEN';
}
// Vérifier d'abord si l'équipement est perdu (LOST) // Vérifier d'abord si l'équipement est perdu (LOST)
if (_shouldMarkAsLost(eq)) { if (_shouldMarkAsLost(eq)) {
return 'LOST'; return 'LOST';
@@ -1118,14 +1123,31 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Vérifie si un équipement doit être marqué comme LOST /// Vérifie si un équipement doit être marqué comme LOST
bool _shouldMarkAsLost(EventEquipment eq) { bool _shouldMarkAsLost(EventEquipment eq) {
// Seulement aux étapes de retour return EventPreparationService.shouldMarkEquipmentAsLost(
if (_currentStep != PreparationStep.return_ && isReturnValidationStep: _isReturnValidationStep,
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) { isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool _isNotTakenToEventAtReturn(EventEquipment eq) {
if (!_isReturnValidationStep) {
return false; return false;
} }
// Si manquant maintenant mais PAS manquant à la préparation = LOST return EventPreparationService.isEquipmentNotTakenToEvent(
return eq.isMissingAtReturn && !eq.isMissingAtPreparation; isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool get _isReturnValidationStep {
return _currentStep == PreparationStep.return_ ||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously);
} }
/// Vérifie si un équipement est manquant à l'étape actuelle /// Vérifie si un équipement est manquant à l'étape actuelle
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
bool shouldAutoLoadNextPage({
required bool hasMoreData,
required bool isLoadingMore,
required bool hasClients,
required double maxScrollExtent,
}) {
if (!hasMoreData || isLoadingMore) {
return false;
}
// If the list cannot scroll yet, preload the next page to avoid a truncated view.
return !hasClients || maxScrollExtent <= 0;
}
@@ -0,0 +1,61 @@
import 'package:em2rp/views/widgets/event/equipment_selection_pagination.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('shouldAutoLoadNextPage', () {
test('returns false when there is no more data', () {
final result = shouldAutoLoadNextPage(
hasMoreData: false,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 100,
);
expect(result, isFalse);
});
test('returns false while a page is already loading', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: true,
hasClients: true,
maxScrollExtent: 0,
);
expect(result, isFalse);
});
test('returns true when list has no scroll client yet', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: false,
maxScrollExtent: 0,
);
expect(result, isTrue);
});
test('returns true when list is not scrollable yet', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 0,
);
expect(result, isTrue);
});
test('returns false when list is scrollable', () {
final result = shouldAutoLoadNextPage(
hasMoreData: true,
isLoadingMore: false,
hasClients: true,
maxScrollExtent: 250,
);
expect(result, isFalse);
});
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"version": "1.1.18", "version": "1.1.20",
"updateUrl": "https://app.em2events.fr", "updateUrl": "https://app.em2events.fr",
"forceUpdate": true, "forceUpdate": true,
"releaseNotes": "Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.", "releaseNotes": "Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.",
"timestamp": "2026-03-12T20:11:54.548Z" "timestamp": "2026-03-30T15:04:34.073Z"
} }