Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bbc77ffc8 | |||
| 19d3dcef69 | |||
| 32a279e0ae | |||
| 7258509528 | |||
| 7fc28f4374 | |||
| af5ecaeee1 | |||
| eac103491f | |||
| 0551f0b9c1 | |||
| 89ab3673c4 | |||
| cf13b4a986 | |||
| 3f80d9318b | |||
| 84c882ac0b | |||
| ecf4a5cede | |||
| 6737ad80e4 | |||
| afa2c35c90 | |||
| 36b420639d | |||
| 9bd4b29967 |
@@ -1,3 +1,4 @@
|
|||||||
|
test_audio_tts.js,1772996026925,be4d2d713c256578bc16646116e3e81fc2627a1d89e45b211318b51e3612f259
|
||||||
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
|
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
|
||||||
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
|
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
|
||||||
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
|
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
|
||||||
@@ -22,8 +23,8 @@ assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d6
|
|||||||
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
|
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
|
||||||
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
|
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
|
||||||
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
||||||
assets/assets/sounds/ok.mp3,1771938119844,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3
|
assets/assets/sounds/ok.mp3,1772996026461,cb452794752fa5e7622b2bd9413e9245464788be3f88cc838a7c9716f87f82a3
|
||||||
assets/assets/sounds/error.mp3,1771938125144,5e1974fa40050421304357c75e834ab5f7c8ba7a61acfbb5885ed913afc0fc0b
|
assets/assets/sounds/error.mp3,1772996026458,5e1974fa40050421304357c75e834ab5f7c8ba7a61acfbb5885ed913afc0fc0b
|
||||||
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
|
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
|
||||||
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
|
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
|
||||||
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
|
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
|
||||||
@@ -33,17 +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,1772532792027,2b3f91e827bc27a1901342a048b1bd81d0aabc50935717f9851e1a3ad6cb7411
|
version.json,1779745850580,c83e8cef9f09921b50bea3e26017c353fb516d339f57fbd0a8d3696f1ffc0e42
|
||||||
test_audio_tts.js,1772532705302,d7b70556456d3b5e7832506b2dafe31480d94db8d0027b89c1633cc9b5c5bdae
|
index.html,1779745856220,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
index.html,1772532797157,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
flutter_bootstrap.js,1779745856203,79bfcfd09b63ba083702fd55c660d283686d9571b49febd8dcab49abbdf6f683
|
||||||
flutter_bootstrap.js,1772532797146,ca3df8691f4db5962ed165489bd051dfd31307628ab4f1ee68842dc747d39fd9
|
flutter_service_worker.js,1779745934512,3d18931ea97b2eeeba61c4fe7c0c8d736cc42ef9b8c2a6e4ec21e83e14e351ae
|
||||||
flutter_service_worker.js,1772532894886,9ce6b8d9f09c957b763a8d3db3baf03c96d4f84e805f6d629294749d9966cfad
|
assets/FontManifest.json,1779745931038,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/FontManifest.json,1772532889954,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
assets/AssetManifest.bin.json,1779745931038,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
assets/AssetManifest.json,1772532889954,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
assets/AssetManifest.bin,1779745931038,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
assets/AssetManifest.bin.json,1772532889954,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
assets/AssetManifest.json,1779745931038,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
assets/AssetManifest.bin,1772532889954,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
assets/shaders/ink_sparkle.frag,1779745931235,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/shaders/ink_sparkle.frag,1772532890224,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779745933681,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1772532893514,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/fonts/MaterialIcons-Regular.otf,1779745933686,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1772532893530,71c7128cf890cf3e18fffca405a98480f174bb3fa79d20c575b473d36c8c3093
|
assets/NOTICES,1779745931041,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
||||||
assets/NOTICES,1772532889955,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74
|
main.dart.js,1779745928953,60d92269024a5be234c7da2ebb889584e20c66a262b28f6d531a3f90c83767b3
|
||||||
main.dart.js,1772532888607,df89975075062e0983691b8997b9e4a1ae4b4d5dfe6c06ca5b42ffa5407fdd3f
|
|
||||||
|
|||||||
@@ -45,3 +45,6 @@ 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
|
||||||
|
functions/.env.local
|
||||||
|
|||||||
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## 25/05/2026
|
||||||
|
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
|
||||||
|
|
||||||
|
## 04/05/2026
|
||||||
|
Optimisation du lancement de l'application et amélioration de la gestion du cache.
|
||||||
|
|
||||||
|
## 22/04/2026
|
||||||
|
Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement
|
||||||
|
|
||||||
|
## 30/03/2026
|
||||||
|
Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.
|
||||||
|
|
||||||
|
## 24/03/2026
|
||||||
|
Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.
|
||||||
|
|
||||||
|
## 12/03/2026bis
|
||||||
|
Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.
|
||||||
|
|
||||||
|
## 12/03/2026
|
||||||
|
Ajout d'une page de statistiques détaillées pour les équipements et les événements.
|
||||||
|
|
||||||
|
## 10/03/2026
|
||||||
|
Migration vers Google Cloud TTS exclusif pour une compatibilité maximale sur tous les navigateurs. Suppression du TTS local (Web Speech API) qui causait des problèmes de compatibilité sur certaines configurations (notamment Chromium/Linux).
|
||||||
|
|
||||||
|
Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS. Résolution bug d'affichage des événements pour les membres CREW
|
||||||
|
|
||||||
## 24/02/2026
|
## 24/02/2026
|
||||||
Ajout de la gestion des maintenance et synthèse vocale
|
Ajout de la gestion des maintenance et synthèse vocale
|
||||||
|
|
||||||
|
|||||||
@@ -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="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Cloud Function: generateTTS
|
||||||
|
* Génère de l'audio TTS avec Google Cloud Text-to-Speech
|
||||||
|
* Avec système de cache dans Firebase Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
const textToSpeech = require('@google-cloud/text-to-speech');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
|
||||||
|
* @param {string} text - Texte à hasher
|
||||||
|
* @return {string} Hash MD5
|
||||||
|
*/
|
||||||
|
function generateCacheKey(text, voiceConfig = {}) {
|
||||||
|
const cacheString = JSON.stringify({
|
||||||
|
text,
|
||||||
|
lang: voiceConfig.languageCode || 'fr-FR',
|
||||||
|
voice: voiceConfig.name || 'fr-FR-Standard-B',
|
||||||
|
});
|
||||||
|
return crypto.createHash('md5').update(cacheString).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère l'audio TTS et le stocke dans Firebase Storage
|
||||||
|
* @param {string} text - Texte à synthétiser
|
||||||
|
* @param {object} storage - Instance Firebase Storage
|
||||||
|
* @param {object} bucket - Bucket Firebase Storage
|
||||||
|
* @param {object} voiceConfig - Configuration de la voix (optionnel)
|
||||||
|
* @return {Promise<{audioUrl: string, cached: boolean}>}
|
||||||
|
*/
|
||||||
|
async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||||
|
try {
|
||||||
|
// Validation du texte
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
throw new Error('Text cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > 5000) {
|
||||||
|
throw new Error('Text too long (max 5000 characters)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration par défaut de la voix
|
||||||
|
const defaultVoiceConfig = {
|
||||||
|
languageCode: 'fr-FR',
|
||||||
|
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
|
||||||
|
ssmlGender: 'MALE',
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig };
|
||||||
|
|
||||||
|
// Générer la clé de cache
|
||||||
|
const cacheKey = generateCacheKey(text, finalVoiceConfig);
|
||||||
|
const fileName = `tts-cache/${cacheKey}.mp3`;
|
||||||
|
const file = bucket.file(fileName);
|
||||||
|
|
||||||
|
// Vérifier si le fichier existe déjà dans le cache
|
||||||
|
const [exists] = await file.exists();
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
|
||||||
|
|
||||||
|
// Générer une URL signée valide 7 jours
|
||||||
|
const [url] = await file.getSignedUrl({
|
||||||
|
action: 'read',
|
||||||
|
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioUrl: url,
|
||||||
|
cached: true,
|
||||||
|
cacheKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
|
||||||
|
cacheKey,
|
||||||
|
text: text.substring(0, 50),
|
||||||
|
voice: finalVoiceConfig.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer le client Text-to-Speech
|
||||||
|
const client = new textToSpeech.TextToSpeechClient();
|
||||||
|
|
||||||
|
// Configuration de la requête
|
||||||
|
const request = {
|
||||||
|
input: { text: text },
|
||||||
|
voice: finalVoiceConfig,
|
||||||
|
audioConfig: {
|
||||||
|
audioEncoding: 'MP3',
|
||||||
|
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
|
||||||
|
pitch: -2.0, // Voix un peu plus grave
|
||||||
|
volumeGainDb: 0.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appeler l'API Google Cloud TTS
|
||||||
|
const [response] = await client.synthesizeSpeech(request);
|
||||||
|
|
||||||
|
if (!response.audioContent) {
|
||||||
|
throw new Error('No audio content returned from TTS API');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[generateTTS] ✓ Audio generated', {
|
||||||
|
size: response.audioContent.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sauvegarder dans Firebase Storage
|
||||||
|
await file.save(response.audioContent, {
|
||||||
|
metadata: {
|
||||||
|
contentType: 'audio/mpeg',
|
||||||
|
metadata: {
|
||||||
|
text: text.substring(0, 100), // Premier 100 caractères pour debug
|
||||||
|
voice: finalVoiceConfig.name,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('[generateTTS] ✓ Audio cached', { fileName });
|
||||||
|
|
||||||
|
// Générer une URL signée
|
||||||
|
const [url] = await file.getSignedUrl({
|
||||||
|
action: 'read',
|
||||||
|
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioUrl: url,
|
||||||
|
cached: false,
|
||||||
|
cacheKey,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[generateTTS] ✗ Error', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
text: text?.substring(0, 50),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateTTS, generateCacheKey };
|
||||||
|
|
||||||
+423
-44
@@ -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");
|
||||||
@@ -16,6 +19,8 @@ const { Storage } = require('@google-cloud/storage');
|
|||||||
// Utilitaires
|
// Utilitaires
|
||||||
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 { handleAiEquipmentProposal } = require('./aiEquipmentProposal');
|
||||||
|
|
||||||
// Initialisation sécurisée
|
// Initialisation sécurisée
|
||||||
if (!admin.apps.length) {
|
if (!admin.apps.length) {
|
||||||
@@ -32,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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -202,30 +214,52 @@ exports.deleteEquipment = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { equipmentId } = req.body.data;
|
const { equipmentId, forceDelete = false } = req.body.data;
|
||||||
|
|
||||||
if (!equipmentId) {
|
if (!equipmentId) {
|
||||||
res.status(400).json({ error: 'Equipment ID is required' });
|
res.status(400).json({ error: 'Equipment ID is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier si l'équipement est utilisé dans des événements actifs
|
// Vérifier si l'équipement est utilisé dans des événements à venir
|
||||||
const eventsSnapshot = await db.collection('events')
|
const eventsSnapshot = await db.collection('events')
|
||||||
.where('status', '!=', 'CANCELLED')
|
.where('status', '!=', 'CANCELLED')
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const upcomingEvents = [];
|
||||||
|
|
||||||
for (const eventDoc of eventsSnapshot.docs) {
|
for (const eventDoc of eventsSnapshot.docs) {
|
||||||
const eventData = eventDoc.data();
|
const eventData = eventDoc.data();
|
||||||
const assignedEquipment = eventData.assignedEquipment || [];
|
const assignedEquipment = eventData.assignedEquipment || [];
|
||||||
|
|
||||||
if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
|
if (!assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventStart = null;
|
||||||
|
if (eventData.StartDateTime) {
|
||||||
|
eventStart = eventData.StartDateTime.toDate
|
||||||
|
? eventData.StartDateTime.toDate()
|
||||||
|
: new Date(eventData.StartDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventStart && eventStart > now) {
|
||||||
|
upcomingEvents.push({
|
||||||
|
eventId: eventDoc.id,
|
||||||
|
eventName: eventData.Name || '',
|
||||||
|
startDate: eventStart.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcomingEvents.length > 0 && !forceDelete) {
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
error: 'Cannot delete equipment: it is assigned to active events',
|
error: 'FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events',
|
||||||
eventId: eventDoc.id
|
upcomingEvents,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await db.collection('equipments').doc(equipmentId).delete();
|
await db.collection('equipments').doc(equipmentId).delete();
|
||||||
|
|
||||||
@@ -1863,6 +1897,116 @@ exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const normalizeSearchText = (value) => {
|
||||||
|
return (value || '')
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventStartDate = (eventData) => {
|
||||||
|
const startValue = eventData.StartDateTime;
|
||||||
|
|
||||||
|
if (!startValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startValue.toDate) {
|
||||||
|
return startValue.toDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDate = new Date(startValue);
|
||||||
|
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventWorkforceUids = (eventData) => {
|
||||||
|
if (!eventData.workforce || !Array.isArray(eventData.workforce)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventData.workforce
|
||||||
|
.map((userRef) => {
|
||||||
|
if (userRef && userRef.id) {
|
||||||
|
return userRef.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof userRef === 'string' && userRef.startsWith('users/')) {
|
||||||
|
return userRef.split('/')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((uid) => uid !== null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeEventSearchResult = (doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(data),
|
||||||
|
workforce: getEventWorkforceUids(data),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EVENTS - Search
|
||||||
|
// ============================================================================
|
||||||
|
exports.searchEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const { userId, query, limit = 20 } = req.body.data || {};
|
||||||
|
const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20;
|
||||||
|
|
||||||
|
const normalizedQuery = normalizeSearchText(query);
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
res.status(200).json({ events: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
|
||||||
|
|
||||||
|
let eventsSnapshot;
|
||||||
|
if (canViewAll) {
|
||||||
|
eventsSnapshot = await db.collection('events').get();
|
||||||
|
} else {
|
||||||
|
const userRef = db.collection('users').doc(userId || decodedToken.uid);
|
||||||
|
eventsSnapshot = await db.collection('events')
|
||||||
|
.where('workforce', 'array-contains', userRef)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingEvents = eventsSnapshot.docs
|
||||||
|
.filter((doc) => {
|
||||||
|
const eventData = doc.data();
|
||||||
|
const startDate = getEventStartDate(eventData);
|
||||||
|
const searchableText = normalizeSearchText([
|
||||||
|
eventData.Name,
|
||||||
|
eventData.Description,
|
||||||
|
eventData.Address,
|
||||||
|
startDate ? startDate.toLocaleString('fr-FR') : '',
|
||||||
|
startDate ? startDate.toISOString() : '',
|
||||||
|
].join(' '));
|
||||||
|
|
||||||
|
return searchableText.includes(normalizedQuery);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const startA = getEventStartDate(a.data()) || new Date(0);
|
||||||
|
const startB = getEventStartDate(b.data()) || new Date(0);
|
||||||
|
return startA.getTime() - startB.getTime();
|
||||||
|
})
|
||||||
|
.slice(0, maxResults)
|
||||||
|
.map((doc) => serializeEventSearchResult(doc));
|
||||||
|
|
||||||
|
res.status(200).json({ events: matchingEvents });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error searching events:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
* 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é
|
||||||
@@ -3824,18 +3968,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);
|
||||||
@@ -3860,20 +4083,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) {
|
||||||
@@ -3881,32 +4094,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,
|
||||||
@@ -3914,6 +4145,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);
|
||||||
@@ -4193,3 +4486,89 @@ exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TEXT-TO-SPEECH - Generate TTS Audio
|
||||||
|
// ============================================================================
|
||||||
|
// Options HTTP spécifiques pour TTS avec CORS activé
|
||||||
|
const ttsHttpOptions = {
|
||||||
|
cors: true, // Activer CORS automatique
|
||||||
|
invoker: 'public',
|
||||||
|
region: 'europe-west9',
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Authentification utilisateur
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
logger.info('[generateTTSV2] Request from user:', {
|
||||||
|
uid: decodedToken.uid,
|
||||||
|
email: decodedToken.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupération des paramètres
|
||||||
|
const { text, voiceConfig } = req.body.data || {};
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!text) {
|
||||||
|
res.status(400).json({ error: 'Text parameter is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > 5000) {
|
||||||
|
res.status(400).json({ error: 'Text too long (max 5000 characters)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Génération de l'audio avec cache
|
||||||
|
const bucketName = admin.storage().bucket().name;
|
||||||
|
const bucket = storage.bucket(bucketName);
|
||||||
|
|
||||||
|
const result = await generateTTS(text, storage, bucket, voiceConfig);
|
||||||
|
|
||||||
|
logger.info('[generateTTSV2] ✓ Success', {
|
||||||
|
cached: result.cached,
|
||||||
|
cacheKey: result.cacheKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
audioUrl: result.audioUrl,
|
||||||
|
cached: result.cached,
|
||||||
|
cacheKey: result.cacheKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[generateTTSV2] ✗ Error:', {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestion des erreurs spécifiques
|
||||||
|
if (error.code === 'PERMISSION_DENIED') {
|
||||||
|
res.status(403).json({ error: 'Permission denied. Check Google Cloud TTS API is enabled.' });
|
||||||
|
} else if (error.code === 'QUOTA_EXCEEDED') {
|
||||||
|
res.status(429).json({ error: 'TTS quota exceeded. Try again later.' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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
+34
-32
@@ -7,11 +7,13 @@
|
|||||||
"name": "functions",
|
"name": "functions",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.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"
|
||||||
},
|
},
|
||||||
@@ -772,12 +774,32 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@google-cloud/text-to-speech": {
|
||||||
|
"version": "5.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google-cloud/text-to-speech/-/text-to-speech-5.8.1.tgz",
|
||||||
|
"integrity": "sha512-HXyZBtfQq+ETSLwWV/k3zFRWSzt+KEfiC5/OqXNNUed+lU/LEyN0CsqqEmkFfkL8BPsVIMAK2xiYCaDsKENukg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"google-gax": "^4.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"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",
|
||||||
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
"@js-sdsl/ordered-map": "^4.4.2"
|
"@js-sdsl/ordered-map": "^4.4.2"
|
||||||
@@ -791,7 +813,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
||||||
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash.camelcase": "^4.3.0",
|
"lodash.camelcase": "^4.3.0",
|
||||||
"long": "^5.0.0",
|
"long": "^5.0.0",
|
||||||
@@ -1310,7 +1331,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||||
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
@@ -1631,8 +1651,7 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
@@ -1845,7 +1864,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -1855,7 +1873,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2379,7 +2396,6 @@
|
|||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@@ -2412,7 +2428,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2425,7 +2440,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
@@ -2727,7 +2741,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -2831,7 +2844,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -3352,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": {
|
||||||
@@ -3373,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": {
|
||||||
@@ -3381,6 +3394,9 @@
|
|||||||
},
|
},
|
||||||
"@as-integrations/express4": {
|
"@as-integrations/express4": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"graphql": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3576,7 +3592,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -3715,7 +3730,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz",
|
||||||
"integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==",
|
"integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.10.9",
|
"@grpc/grpc-js": "^1.10.9",
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
@@ -3743,7 +3757,6 @@
|
|||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
@@ -4093,7 +4106,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5110,8 +5122,7 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.clonedeep": {
|
"node_modules/lodash.clonedeep": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
@@ -5490,7 +5501,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
@@ -5843,7 +5853,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
||||||
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
|
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"protobufjs": "^7.2.5"
|
"protobufjs": "^7.2.5"
|
||||||
},
|
},
|
||||||
@@ -6035,7 +6044,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6517,7 +6525,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -6532,7 +6539,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -6997,7 +7003,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -7035,7 +7040,6 @@
|
|||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -7052,7 +7056,6 @@
|
|||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^8.0.1",
|
"cliui": "^8.0.1",
|
||||||
@@ -7071,7 +7074,6 @@
|
|||||||
"version": "21.1.1",
|
"version": "21.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.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,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,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.14';
|
static const String version = '1.2.1';
|
||||||
|
|
||||||
/// 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';
|
||||||
|
|||||||
+83
-173
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:em2rp/providers/users_provider.dart';
|
import 'package:em2rp/providers/users_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
@@ -15,13 +17,12 @@ import 'package:em2rp/views/maintenance_management_page.dart';
|
|||||||
import 'package:em2rp/views/container_form_page.dart';
|
import 'package:em2rp/views/container_form_page.dart';
|
||||||
import 'package:em2rp/views/container_detail_page.dart';
|
import 'package:em2rp/views/container_detail_page.dart';
|
||||||
import 'package:em2rp/views/event_preparation_page.dart';
|
import 'package:em2rp/views/event_preparation_page.dart';
|
||||||
|
import 'package:em2rp/views/event_statistics_page.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:em2rp/services/app_initializer.dart';
|
||||||
import 'firebase_options.dart';
|
|
||||||
import 'utils/colors.dart';
|
import 'utils/colors.dart';
|
||||||
import 'views/my_account_page.dart';
|
import 'views/my_account_page.dart';
|
||||||
import 'views/user_management_page.dart';
|
import 'views/user_management_page.dart';
|
||||||
@@ -29,36 +30,21 @@ import 'package:provider/provider.dart';
|
|||||||
import 'providers/local_user_provider.dart';
|
import 'providers/local_user_provider.dart';
|
||||||
import 'views/reset_password_page.dart';
|
import 'views/reset_password_page.dart';
|
||||||
import 'config/env.dart';
|
import 'config/env.dart';
|
||||||
import 'services/update_service.dart';
|
import 'utils/app_start_gate.dart';
|
||||||
import 'views/widgets/common/update_dialog.dart';
|
import 'views/widgets/common/startup_splash_screen.dart';
|
||||||
import 'config/api_config.dart';
|
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'views/widgets/common/update_dialog.dart';
|
|
||||||
|
|
||||||
void main() async {
|
void main() {
|
||||||
|
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(
|
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Configuration des émulateurs en mode développement
|
|
||||||
if (ApiConfig.isDevelopment) {
|
|
||||||
print('🔧 Mode développement activé - Utilisation des émulateurs');
|
|
||||||
|
|
||||||
// Configurer l'émulateur Auth
|
|
||||||
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
|
|
||||||
print('✓ Auth émulateur configuré: localhost:9199');
|
|
||||||
|
|
||||||
// Configurer l'émulateur Firestore
|
|
||||||
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
|
|
||||||
print('✓ Firestore émulateur configuré: localhost:8088');
|
|
||||||
}
|
|
||||||
|
|
||||||
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
|
||||||
|
ChangeNotifierProvider<AppInitializer>(
|
||||||
|
create: (_) => AppInitializer(),
|
||||||
|
),
|
||||||
// LocalUserProvider pour la gestion de l'authentification
|
// LocalUserProvider pour la gestion de l'authentification
|
||||||
ChangeNotifierProvider<LocalUserProvider>(
|
ChangeNotifierProvider<LocalUserProvider>(
|
||||||
create: (context) => LocalUserProvider()),
|
create: (context) => LocalUserProvider()),
|
||||||
@@ -96,11 +82,67 @@ void main() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
late final Future<void> _startupFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startupFuture = _bootstrapApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _bootstrapApp() async {
|
||||||
|
final initializer = context.read<AppInitializer>();
|
||||||
|
final localAuthProvider = context.read<LocalUserProvider>();
|
||||||
|
|
||||||
|
await initializer.initialize();
|
||||||
|
|
||||||
|
// Attendre la première valeur d'authentification avant toute décision
|
||||||
|
// de navigation, afin d'éviter un flash de la page login.
|
||||||
|
await FirebaseAuth.instance.authStateChanges().first;
|
||||||
|
|
||||||
|
if (FirebaseAuth.instance.currentUser != null) {
|
||||||
|
unawaited(
|
||||||
|
localAuthProvider.loadUserData().catchError((e) {
|
||||||
|
print('User data bootstrap failed: $e');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// En développement, on garde la connexion automatique existante.
|
||||||
|
if (Env.isDevelopment) {
|
||||||
|
await localAuthProvider.signInWithEmailAndPassword(
|
||||||
|
Env.devAdminEmail,
|
||||||
|
Env.devAdminPassword,
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
localAuthProvider.loadUserData().catchError((e) {
|
||||||
|
print('Dev user bootstrap failed: $e');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<void>(
|
||||||
|
future: _startupFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: StartupSplashScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'EM2 Hub',
|
title: 'EM2 Hub',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
@@ -137,15 +179,15 @@ class MyApp extends StatelessWidget {
|
|||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
initialRoute: '/',
|
|
||||||
routes: {
|
routes: {
|
||||||
'/': (context) => const AutoLoginWrapper(),
|
|
||||||
'/login': (context) => const LoginPage(),
|
'/login': (context) => const LoginPage(),
|
||||||
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
|
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
|
||||||
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
|
'/calendar': (context) => const AuthGuard(
|
||||||
|
allowWhileLoading: true, child: CalendarPage()),
|
||||||
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
||||||
'/user_management': (context) => const AuthGuard(
|
'/user_management': (context) => const AuthGuard(
|
||||||
requiredPermission: "view_all_users", child: UserManagementPage()),
|
requiredPermission: "view_all_users",
|
||||||
|
child: UserManagementPage()),
|
||||||
'/reset_password': (context) {
|
'/reset_password': (context) {
|
||||||
final args = ModalRoute.of(context)!.settings.arguments
|
final args = ModalRoute.of(context)!.settings.arguments
|
||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>;
|
||||||
@@ -173,14 +215,16 @@ class MyApp extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
'/container_detail': (context) {
|
'/container_detail': (context) {
|
||||||
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
|
final container = ModalRoute.of(context)!.settings.arguments
|
||||||
|
as ContainerModel;
|
||||||
return AuthGuard(
|
return AuthGuard(
|
||||||
requiredPermission: "view_equipment",
|
requiredPermission: "view_equipment",
|
||||||
child: ContainerDetailPage(container: container),
|
child: ContainerDetailPage(container: container),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
'/event_preparation': (context) {
|
'/event_preparation': (context) {
|
||||||
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
final args = ModalRoute.of(context)!.settings.arguments
|
||||||
|
as Map<String, dynamic>;
|
||||||
final event = args['event'] as EventModel;
|
final event = args['event'] as EventModel;
|
||||||
return AuthGuard(
|
return AuthGuard(
|
||||||
child: EventPreparationPage(
|
child: EventPreparationPage(
|
||||||
@@ -188,147 +232,13 @@ class MyApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
'/event_statistics': (context) => const AuthGuard(
|
||||||
|
requiredPermission: 'generate_reports',
|
||||||
|
child: EventStatisticsPage()),
|
||||||
|
},
|
||||||
|
home: const AppStartGate(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AutoLoginWrapper extends StatefulWidget {
|
|
||||||
const AutoLoginWrapper({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AutoLoginWrapper> createState() => _AutoLoginWrapperState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// Attendre la fin du premier build avant de naviguer
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_autoLogin();
|
|
||||||
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
|
|
||||||
_checkForUpdateDelayed();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie les mises à jour après un délai
|
|
||||||
Future<void> _checkForUpdateDelayed() async {
|
|
||||||
try {
|
|
||||||
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
|
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final updateInfo = await UpdateService.checkForUpdate();
|
|
||||||
|
|
||||||
if (updateInfo != null && mounted) {
|
|
||||||
// Attendre encore un peu pour être sûr que le bon contexte est disponible
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: !updateInfo.forceUpdate,
|
|
||||||
builder: (context) => UpdateDialog(updateInfo: updateInfo),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[AutoLoginWrapper] Error checking for update: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _autoLogin() async {
|
|
||||||
PerformanceMonitor.start('App.autoLogin');
|
|
||||||
try {
|
|
||||||
final localAuthProvider =
|
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
|
|
||||||
// Vérifier si l'utilisateur est déjà connecté
|
|
||||||
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
|
||||||
PerformanceMonitor.start('App.signIn');
|
|
||||||
// Connexion automatique en mode développement
|
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
|
||||||
Env.devAdminEmail,
|
|
||||||
Env.devAdminPassword,
|
|
||||||
);
|
|
||||||
PerformanceMonitor.end('App.signIn');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
|
||||||
// En Flutter Web, on peut vérifier window.location.hash
|
|
||||||
final currentUri = Uri.base;
|
|
||||||
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
|
|
||||||
|
|
||||||
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
|
||||||
|
|
||||||
// Navigation immédiate sans attendre le chargement des données
|
|
||||||
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
|
||||||
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
|
||||||
Navigator.of(context).pushReplacementNamed(fragment);
|
|
||||||
} else {
|
|
||||||
// Route par défaut : calendrier
|
|
||||||
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
|
||||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
|
||||||
}
|
|
||||||
|
|
||||||
PerformanceMonitor.end('App.autoLogin');
|
|
||||||
PerformanceMonitor.printSummary();
|
|
||||||
|
|
||||||
// Charger les données utilisateur en arrière-plan
|
|
||||||
localAuthProvider.loadUserData().catchError((e) {
|
|
||||||
print('Error loading user data: $e');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Auto login failed: $e');
|
|
||||||
PerformanceMonitor.end('App.autoLogin');
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.of(context).pushReplacementNamed('/login');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// Logo de l'application
|
|
||||||
Image.asset(
|
|
||||||
'assets/logos/RectangleLogoBlack.png',
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return const Icon(
|
|
||||||
Icons.event_available,
|
|
||||||
size: 80,
|
|
||||||
color: AppColors.rouge,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
const CircularProgressIndicator(
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
const Text(
|
|
||||||
'Chargement...',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.grey,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class AlertModel {
|
|||||||
|
|
||||||
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime _parseDate(dynamic value) {
|
DateTime parseDate(dynamic value) {
|
||||||
if (value == null) return DateTime.now();
|
if (value == null) return DateTime.now();
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
@@ -174,13 +174,13 @@ class AlertModel {
|
|||||||
eventId: map['eventId'],
|
eventId: map['eventId'],
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||||
createdAt: _parseDate(map['createdAt']),
|
createdAt: parseDate(map['createdAt']),
|
||||||
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
dueDate: map['dueDate'] != null ? parseDate(map['dueDate']) : null,
|
||||||
actionUrl: map['actionUrl'],
|
actionUrl: map['actionUrl'],
|
||||||
isRead: map['isRead'] ?? false,
|
isRead: map['isRead'] ?? false,
|
||||||
isResolved: map['isResolved'] ?? false,
|
isResolved: map['isResolved'] ?? false,
|
||||||
resolution: map['resolution'],
|
resolution: map['resolution'],
|
||||||
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
resolvedAt: map['resolvedAt'] != null ? parseDate(map['resolvedAt']) : null,
|
||||||
resolvedByUserId: map['resolvedByUserId'],
|
resolvedByUserId: map['resolvedByUserId'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ class ContainerModel {
|
|||||||
/// Factory depuis Firestore
|
/// Factory depuis Firestore
|
||||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime? _parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
@@ -270,8 +270,8 @@ class ContainerModel {
|
|||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
eventId: map['eventId'],
|
eventId: map['eventId'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
history: history,
|
history: history,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -351,7 +351,7 @@ class ContainerHistoryEntry {
|
|||||||
|
|
||||||
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
||||||
// Helper pour parser la date
|
// Helper pour parser la date
|
||||||
DateTime _parseDate(dynamic value) {
|
DateTime parseDate(dynamic value) {
|
||||||
if (value == null) return DateTime.now();
|
if (value == null) return DateTime.now();
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
@@ -359,7 +359,7 @@ class ContainerHistoryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ContainerHistoryEntry(
|
return ContainerHistoryEntry(
|
||||||
timestamp: _parseDate(map['timestamp']),
|
timestamp: parseDate(map['timestamp']),
|
||||||
action: map['action'] ?? '',
|
action: map['action'] ?? '',
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
previousValue: map['previousValue'],
|
previousValue: map['previousValue'],
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ class EquipmentModel {
|
|||||||
|
|
||||||
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime? _parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
@@ -416,13 +416,13 @@ class EquipmentModel {
|
|||||||
length: map['length']?.toDouble(),
|
length: map['length']?.toDouble(),
|
||||||
width: map['width']?.toDouble(),
|
width: map['width']?.toDouble(),
|
||||||
height: map['height']?.toDouble(),
|
height: map['height']?.toDouble(),
|
||||||
purchaseDate: _parseDate(map['purchaseDate']),
|
purchaseDate: parseDate(map['purchaseDate']),
|
||||||
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
|
nextMaintenanceDate: parseDate(map['nextMaintenanceDate']),
|
||||||
maintenanceIds: maintenanceIds,
|
maintenanceIds: maintenanceIds,
|
||||||
imageUrl: map['imageUrl'],
|
imageUrl: map['imageUrl'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -347,7 +353,7 @@ class EventModel {
|
|||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
try {
|
try {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime _parseDate(dynamic value, DateTime defaultValue) {
|
DateTime parseDate(dynamic value, DateTime defaultValue) {
|
||||||
if (value == null) return defaultValue;
|
if (value == null) return defaultValue;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
||||||
@@ -370,8 +376,8 @@ class EventModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gestion sécurisée des timestamps avec support ISO string
|
// Gestion sécurisée des timestamps avec support ISO string
|
||||||
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
|
final DateTime startDate = parseDate(map['StartDateTime'], DateTime.now());
|
||||||
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
final DateTime endDate = parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
||||||
|
|
||||||
// Gestion sécurisée des documents
|
// Gestion sécurisée des documents
|
||||||
final docsRaw = map['documents'] ?? [];
|
final docsRaw = map['documents'] ?? [];
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EventStatisticsFilter {
|
||||||
|
final DateTimeRange period;
|
||||||
|
final Set<String> eventTypeIds;
|
||||||
|
final bool includeCanceled;
|
||||||
|
final Set<EventStatus> selectedStatuses;
|
||||||
|
|
||||||
|
const EventStatisticsFilter({
|
||||||
|
required this.period,
|
||||||
|
this.eventTypeIds = const {},
|
||||||
|
this.includeCanceled = false,
|
||||||
|
this.selectedStatuses = const {
|
||||||
|
EventStatus.confirmed,
|
||||||
|
EventStatus.waitingForApproval,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
EventStatisticsFilter copyWith({
|
||||||
|
DateTimeRange? period,
|
||||||
|
Set<String>? eventTypeIds,
|
||||||
|
bool? includeCanceled,
|
||||||
|
Set<EventStatus>? selectedStatuses,
|
||||||
|
}) {
|
||||||
|
return EventStatisticsFilter(
|
||||||
|
period: period ?? this.period,
|
||||||
|
eventTypeIds: eventTypeIds ?? this.eventTypeIds,
|
||||||
|
includeCanceled: includeCanceled ?? this.includeCanceled,
|
||||||
|
selectedStatuses: selectedStatuses ?? this.selectedStatuses,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventTypeStatistics {
|
||||||
|
final String eventTypeId;
|
||||||
|
final String eventTypeName;
|
||||||
|
final int totalEvents;
|
||||||
|
final double totalAmount;
|
||||||
|
final double validatedAmount;
|
||||||
|
final double pendingAmount;
|
||||||
|
final double canceledAmount;
|
||||||
|
|
||||||
|
const EventTypeStatistics({
|
||||||
|
required this.eventTypeId,
|
||||||
|
required this.eventTypeName,
|
||||||
|
required this.totalEvents,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.validatedAmount,
|
||||||
|
required this.pendingAmount,
|
||||||
|
required this.canceledAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class OptionStatistics {
|
||||||
|
final String optionKey;
|
||||||
|
final String optionLabel;
|
||||||
|
final int usageCount;
|
||||||
|
final int validatedUsageCount;
|
||||||
|
final int quantity;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const OptionStatistics({
|
||||||
|
required this.optionKey,
|
||||||
|
required this.optionLabel,
|
||||||
|
required this.usageCount,
|
||||||
|
required this.validatedUsageCount,
|
||||||
|
required this.quantity,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventStatisticsSummary {
|
||||||
|
final int totalEvents;
|
||||||
|
final int validatedEvents;
|
||||||
|
final int pendingEvents;
|
||||||
|
final int canceledEvents;
|
||||||
|
|
||||||
|
final double totalAmount;
|
||||||
|
final double validatedAmount;
|
||||||
|
final double pendingAmount;
|
||||||
|
final double canceledAmount;
|
||||||
|
|
||||||
|
final double baseAmount;
|
||||||
|
final double optionsAmount;
|
||||||
|
final double medianAmount;
|
||||||
|
final List<EventTypeStatistics> byEventType;
|
||||||
|
final List<OptionStatistics> topOptions;
|
||||||
|
|
||||||
|
const EventStatisticsSummary({
|
||||||
|
required this.totalEvents,
|
||||||
|
required this.validatedEvents,
|
||||||
|
required this.pendingEvents,
|
||||||
|
required this.canceledEvents,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.validatedAmount,
|
||||||
|
required this.pendingAmount,
|
||||||
|
required this.canceledAmount,
|
||||||
|
required this.baseAmount,
|
||||||
|
required this.optionsAmount,
|
||||||
|
required this.medianAmount,
|
||||||
|
required this.byEventType,
|
||||||
|
required this.topOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const empty = EventStatisticsSummary(
|
||||||
|
totalEvents: 0,
|
||||||
|
validatedEvents: 0,
|
||||||
|
pendingEvents: 0,
|
||||||
|
canceledEvents: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
validatedAmount: 0,
|
||||||
|
pendingAmount: 0,
|
||||||
|
canceledAmount: 0,
|
||||||
|
baseAmount: 0,
|
||||||
|
optionsAmount: 0,
|
||||||
|
medianAmount: 0,
|
||||||
|
byEventType: [],
|
||||||
|
topOptions: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
double get averageAmount => totalEvents == 0 ? 0 : totalAmount / totalEvents;
|
||||||
|
|
||||||
|
double get validationRate =>
|
||||||
|
totalEvents == 0 ? 0 : validatedEvents / totalEvents;
|
||||||
|
|
||||||
|
double get baseContributionRate =>
|
||||||
|
totalAmount == 0 ? 0 : baseAmount / totalAmount;
|
||||||
|
|
||||||
|
double get optionsContributionRate =>
|
||||||
|
totalAmount == 0 ? 0 : optionsAmount / totalAmount;
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ class MaintenanceModel {
|
|||||||
|
|
||||||
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
DateTime? _parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
@@ -76,15 +76,15 @@ class MaintenanceModel {
|
|||||||
id: id,
|
id: id,
|
||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
type: maintenanceTypeFromString(map['type']),
|
type: maintenanceTypeFromString(map['type']),
|
||||||
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
|
scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||||
completedDate: _parseDate(map['completedDate']),
|
completedDate: parseDate(map['completedDate']),
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
description: map['description'] ?? '',
|
description: map['description'] ?? '',
|
||||||
performedBy: map['performedBy'],
|
performedBy: map['performedBy'],
|
||||||
cost: map['cost']?.toDouble(),
|
cost: map['cost']?.toDouble(),
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
Timer? _searchDebounceTimer;
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
// Liste paginée pour la page de gestion
|
// Liste paginée pour la page de gestion
|
||||||
List<ContainerModel> _paginatedContainers = [];
|
final List<ContainerModel> _paginatedContainers = [];
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
bool _isLoadingMore = false;
|
bool _isLoadingMore = false;
|
||||||
String? _lastVisible;
|
String? _lastVisible;
|
||||||
|
|
||||||
// Cache complet pour compatibilité
|
// Cache complet pour compatibilité
|
||||||
List<ContainerModel> _containers = [];
|
final List<ContainerModel> _containers = [];
|
||||||
|
|
||||||
// Filtres et recherche
|
// Filtres et recherche
|
||||||
ContainerType? _selectedType;
|
ContainerType? _selectedType;
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
Timer? _searchDebounceTimer;
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
// Liste paginée pour la page de gestion
|
// Liste paginée pour la page de gestion
|
||||||
List<EquipmentModel> _paginatedEquipment = [];
|
final List<EquipmentModel> _paginatedEquipment = [];
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
bool _isLoadingMore = false;
|
bool _isLoadingMore = false;
|
||||||
String? _lastVisible;
|
String? _lastVisible;
|
||||||
|
|
||||||
// Cache complet pour getEquipmentsByIds et compatibilité
|
// Cache complet pour getEquipmentsByIds et compatibilité
|
||||||
List<EquipmentModel> _equipment = [];
|
final List<EquipmentModel> _equipment = [];
|
||||||
List<String> _models = [];
|
List<String> _models = [];
|
||||||
List<String> _brands = [];
|
List<String> _brands = [];
|
||||||
|
|
||||||
@@ -433,9 +433,9 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer un équipement
|
/// Supprimer un équipement
|
||||||
Future<void> deleteEquipment(String equipmentId) async {
|
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
|
||||||
try {
|
try {
|
||||||
await _dataService.deleteEquipment(equipmentId);
|
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
|
||||||
if (_usePagination) {
|
if (_usePagination) {
|
||||||
await reload();
|
await reload();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
bool _lastCanViewAll = false;
|
bool _lastCanViewAll = false;
|
||||||
|
|
||||||
// Nouveau: Cache par mois pour le lazy loading
|
// Nouveau: Cache par mois pour le lazy loading
|
||||||
Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
|
final Map<String, List<EventModel>> _eventsByMonth =
|
||||||
|
{}; // "2026-02" => [events]
|
||||||
String? _currentMonth; // Mois actuellement affiché
|
String? _currentMonth; // Mois actuellement affiché
|
||||||
|
|
||||||
List<EventModel> get events => _events;
|
List<EventModel> get events => _events;
|
||||||
@@ -28,7 +29,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
|
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
|
||||||
bool _shouldReload(String userId, bool canViewAllEvents) {
|
bool _shouldReload(String userId, bool canViewAllEvents) {
|
||||||
if (_lastLoadTime == null) return true;
|
if (_lastLoadTime == null) return true;
|
||||||
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
|
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents)
|
||||||
|
return true;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final difference = now.difference(_lastLoadTime!);
|
final difference = now.difference(_lastLoadTime!);
|
||||||
@@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Charger les événements d'un utilisateur via l'API
|
/// Charger les événements d'un utilisateur via l'API
|
||||||
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
|
Future<void> loadUserEvents(String userId,
|
||||||
|
{bool canViewAllEvents = false, bool forceReload = false}) async {
|
||||||
PerformanceMonitor.start('EventProvider.loadUserEvents');
|
PerformanceMonitor.start('EventProvider.loadUserEvents');
|
||||||
|
|
||||||
// Éviter les rechargements inutiles
|
// Éviter les rechargements inutiles
|
||||||
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
|
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
|
||||||
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
|
print(
|
||||||
|
'Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
|
||||||
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
print(
|
||||||
|
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||||
|
|
||||||
PerformanceMonitor.start('EventProvider.getEvents_API');
|
PerformanceMonitor.start('EventProvider.getEvents_API');
|
||||||
// Charger via l'API - les permissions sont vérifiées côté serveur
|
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||||
@@ -61,9 +66,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
final usersData = result['users'] as Map<String, dynamic>;
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
// Stocker les utilisateurs dans le cache
|
// Stocker les utilisateurs dans le cache
|
||||||
_usersCache = usersData.map((key, value) =>
|
_usersCache = usersData
|
||||||
MapEntry(key, value as Map<String, dynamic>)
|
.map((key, value) => MapEntry(key, value as Map<String, dynamic>));
|
||||||
);
|
|
||||||
|
|
||||||
print('Found ${eventsData.length} events from API');
|
print('Found ${eventsData.length} events from API');
|
||||||
|
|
||||||
@@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
// Parser chaque événement
|
// Parser chaque événement
|
||||||
for (var eventData in eventsData) {
|
for (var eventData in eventsData) {
|
||||||
try {
|
try {
|
||||||
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
final event =
|
||||||
|
EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
allEvents.add(event);
|
allEvents.add(event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Failed to parse event ${eventData['id']}: $e');
|
print('Failed to parse event ${eventData['id']}: $e');
|
||||||
@@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
_lastUserId = userId;
|
_lastUserId = userId;
|
||||||
_lastCanViewAll = canViewAllEvents;
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
|
||||||
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
print(
|
||||||
|
'Successfully loaded ${_events.length} events ($failedCount failed)');
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
|
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
|
||||||
Future<void> loadMonthEvents(String userId, int year, int month,
|
Future<void> loadMonthEvents(String userId, int year, int month,
|
||||||
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
|
{bool canViewAllEvents = false,
|
||||||
|
bool forceReload = false,
|
||||||
|
bool silent = false}) async {
|
||||||
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
|
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
// Vérifier le cache
|
// Vérifier le cache
|
||||||
@@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier {
|
|||||||
|
|
||||||
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
|
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
|
||||||
final result = await _dataService.getEventsByMonth(
|
final result = await _dataService.getEventsByMonth(
|
||||||
userId: userId,
|
userId: userId, year: year, month: month);
|
||||||
year: year,
|
|
||||||
month: month
|
|
||||||
);
|
|
||||||
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
|
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
|
||||||
|
|
||||||
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
final usersData = result['users'] as Map<String, dynamic>;
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
|
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
|
||||||
_usersCache.addAll(
|
_usersCache.addAll(usersData
|
||||||
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
|
.map((key, value) => MapEntry(key, value as Map<String, dynamic>)));
|
||||||
);
|
|
||||||
|
|
||||||
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
|
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
|
||||||
|
|
||||||
@@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
// Parser les événements
|
// Parser les événements
|
||||||
for (var eventData in eventsData) {
|
for (var eventData in eventsData) {
|
||||||
try {
|
try {
|
||||||
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
final event =
|
||||||
|
EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
monthEvents.add(event);
|
monthEvents.add(event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
|
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
|
||||||
@@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
_lastUserId = userId;
|
_lastUserId = userId;
|
||||||
_lastCanViewAll = canViewAllEvents;
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
|
||||||
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey (${failedCount} failed)');
|
print(
|
||||||
|
'[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier {
|
|||||||
/// Précharger les mois adjacents en arrière-plan
|
/// Précharger les mois adjacents en arrière-plan
|
||||||
void preloadAdjacentMonths(String userId, int year, int month,
|
void preloadAdjacentMonths(String userId, int year, int month,
|
||||||
{bool canViewAllEvents = false}) {
|
{bool canViewAllEvents = false}) {
|
||||||
|
|
||||||
// Mois précédent
|
// Mois précédent
|
||||||
final prevMonth = month == 1 ? 12 : month - 1;
|
final prevMonth = month == 1 ? 12 : month - 1;
|
||||||
final prevYear = month == 1 ? year - 1 : year;
|
final prevYear = month == 1 ? year - 1 : year;
|
||||||
@@ -220,9 +224,20 @@ class EventProvider with ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vide entièrement le cache (mois + métadonnées) pour forcer un rechargement complet
|
||||||
|
void clearAllCache() {
|
||||||
|
_eventsByMonth.clear();
|
||||||
|
_lastLoadTime = null;
|
||||||
|
_lastUserId = null;
|
||||||
|
_currentMonth = null;
|
||||||
|
print('[EventProvider] Cache entièrement vidé');
|
||||||
|
}
|
||||||
|
|
||||||
/// Recharger les événements (utilise le dernier userId)
|
/// Recharger les événements (utilise le dernier userId)
|
||||||
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
Future<void> refreshEvents(String userId,
|
||||||
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
|
{bool canViewAllEvents = false}) async {
|
||||||
|
await loadUserEvents(userId,
|
||||||
|
canViewAllEvents: canViewAllEvents, forceReload: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer un événement spécifique par ID
|
/// Récupérer un événement spécifique par ID
|
||||||
@@ -234,6 +249,41 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recherche des événements accessibles à l'utilisateur.
|
||||||
|
Future<List<EventModel>> searchEvents({
|
||||||
|
required String userId,
|
||||||
|
required String query,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
final trimmedQuery = query.trim();
|
||||||
|
if (trimmedQuery.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _dataService.searchEvents(
|
||||||
|
userId: userId,
|
||||||
|
query: trimmedQuery,
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
final events = <EventModel>[];
|
||||||
|
for (final eventData in result) {
|
||||||
|
try {
|
||||||
|
final eventId = eventData['id'] as String?;
|
||||||
|
if (eventId == null || eventId.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.add(EventModel.fromMap(eventData, eventId));
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to parse searched event ${eventData['id']}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
/// Ajouter un nouvel événement
|
/// Ajouter un nouvel événement
|
||||||
Future<void> addEvent(EventModel event) async {
|
Future<void> addEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
@@ -241,7 +291,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
_events.add(event);
|
_events.add(event);
|
||||||
|
|
||||||
// Ajouter dans le cache par mois
|
// Ajouter dans le cache par mois
|
||||||
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
final monthKey =
|
||||||
|
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
if (_eventsByMonth.containsKey(monthKey)) {
|
if (_eventsByMonth.containsKey(monthKey)) {
|
||||||
_eventsByMonth[monthKey]!.add(event);
|
_eventsByMonth[monthKey]!.add(event);
|
||||||
}
|
}
|
||||||
@@ -263,8 +314,10 @@ class EventProvider with ChangeNotifier {
|
|||||||
_events[index] = event;
|
_events[index] = event;
|
||||||
|
|
||||||
// Mettre à jour dans le cache par mois
|
// Mettre à jour dans le cache par mois
|
||||||
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
|
final oldMonthKey =
|
||||||
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
'${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
final newMonthKey =
|
||||||
|
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
|
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
|
||||||
if (oldMonthKey != newMonthKey) {
|
if (oldMonthKey != newMonthKey) {
|
||||||
@@ -277,7 +330,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// Même mois, juste mettre à jour
|
// Même mois, juste mettre à jour
|
||||||
if (_eventsByMonth.containsKey(newMonthKey)) {
|
if (_eventsByMonth.containsKey(newMonthKey)) {
|
||||||
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
|
final monthIndex = _eventsByMonth[newMonthKey]!
|
||||||
|
.indexWhere((e) => e.id == event.id);
|
||||||
if (monthIndex != -1) {
|
if (monthIndex != -1) {
|
||||||
_eventsByMonth[newMonthKey]![monthIndex] = event;
|
_eventsByMonth[newMonthKey]![monthIndex] = event;
|
||||||
}
|
}
|
||||||
@@ -299,7 +353,8 @@ class EventProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Trouver l'événement pour obtenir sa date avant de le supprimer
|
// Trouver l'événement pour obtenir sa date avant de le supprimer
|
||||||
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
|
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
|
||||||
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
|
final monthKey =
|
||||||
|
'${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
// Supprimer de _events
|
// Supprimer de _events
|
||||||
_events.removeWhere((event) => event.id == eventId);
|
_events.removeWhere((event) => event.id == eventId);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
|
|||||||
class LocalUserProvider with ChangeNotifier {
|
class LocalUserProvider with ChangeNotifier {
|
||||||
UserModel? _currentUser;
|
UserModel? _currentUser;
|
||||||
RoleModel? _currentRole;
|
RoleModel? _currentRole;
|
||||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
FirebaseAuth? _auth;
|
||||||
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
||||||
final DataService _dataService = DataService(apiService);
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Charge les données de l'utilisateur actuel via Cloud Function
|
/// Charge les données de l'utilisateur actuel via Cloud Function
|
||||||
Future<void> loadUserData({bool forceReload = false}) async {
|
Future<void> loadUserData({bool forceReload = false}) async {
|
||||||
if (_auth.currentUser == null) {
|
// Si FirebaseAuth n'est pas encore disponible
|
||||||
|
final FirebaseAuth auth;
|
||||||
|
try {
|
||||||
|
auth = _getAuthInstance();
|
||||||
|
} catch (e) {
|
||||||
|
print('Auth instance not ready in loadUserData: $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.currentUser == null) {
|
||||||
print('No current user in Auth');
|
print('No current user in Auth');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap léger : rendre l'UID disponible tout de suite pour les écrans
|
||||||
|
// qui en ont besoin, même si le profil complet n'est pas encore chargé.
|
||||||
|
if (_currentUser == null) {
|
||||||
|
final firebaseUser = auth.currentUser!;
|
||||||
|
_currentUser = UserModel(
|
||||||
|
uid: firebaseUser.uid,
|
||||||
|
email: firebaseUser.email ?? '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
role: 'USER',
|
||||||
|
phoneNumber: '',
|
||||||
|
profilePhotoUrl: firebaseUser.photoURL ?? '',
|
||||||
|
);
|
||||||
|
_currentRole = RoleModel(
|
||||||
|
id: 'USER',
|
||||||
|
name: '',
|
||||||
|
permissions: const [],
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// Éviter les rechargements inutiles
|
// Éviter les rechargements inutiles
|
||||||
if (!forceReload && !_shouldReloadUserData()) {
|
if (!forceReload && !_shouldReloadUserData()) {
|
||||||
print('Using cached user data');
|
print('Using cached user data');
|
||||||
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_isLoadingUserData = true;
|
_isLoadingUserData = true;
|
||||||
PerformanceMonitor.start('LocalUserProvider.loadUserData');
|
PerformanceMonitor.start('LocalUserProvider.loadUserData');
|
||||||
print('Loading user data for: ${_auth.currentUser!.uid}');
|
print('Loading user data for: ${_auth!.currentUser!.uid}');
|
||||||
try {
|
try {
|
||||||
// Utiliser la Cloud Function getCurrentUser
|
// Utiliser la Cloud Function getCurrentUser
|
||||||
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
|
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
|
||||||
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
Future<UserCredential> signInWithEmailAndPassword(
|
Future<UserCredential> signInWithEmailAndPassword(
|
||||||
String email, String password) async {
|
String email, String password) async {
|
||||||
try {
|
try {
|
||||||
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
final auth = _getAuthInstance();
|
||||||
|
UserCredential userCredential = await auth.signInWithEmailAndPassword(
|
||||||
email: email, password: password);
|
email: email, password: password);
|
||||||
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
|
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
|
||||||
// pour ne pas bloquer la navigation
|
// pour ne pas bloquer la navigation
|
||||||
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Déconnexion
|
/// Déconnexion
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
await _auth.signOut();
|
try {
|
||||||
|
final auth = _getAuthInstance();
|
||||||
|
await auth.signOut();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error during signOut: $e');
|
||||||
|
}
|
||||||
clearUser();
|
clearUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FirebaseAuth _getAuthInstance() {
|
||||||
|
try {
|
||||||
|
_auth ??= FirebaseAuth.instance;
|
||||||
|
return _auth!;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('[LocalUserProvider] FirebaseAuth.instance access error: $e\n$st');
|
||||||
|
throw Exception('FirebaseAuth not available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Vérifie si l'utilisateur a une permission spécifique
|
/// Vérifie si l'utilisateur a une permission spécifique
|
||||||
bool hasPermission(String permission) {
|
bool hasPermission(String permission) {
|
||||||
return _currentRole?.permissions.contains(permission) ?? false;
|
return _currentRole?.permissions.contains(permission) ?? false;
|
||||||
|
|||||||
@@ -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 {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
FirebaseFirestore get _firestore => FirebaseFirestore.instance;
|
||||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
FirebaseAuth get _auth => FirebaseAuth.instance;
|
||||||
|
|
||||||
/// Stream des alertes pour l'utilisateur connecté
|
/// Stream des alertes pour l'utilisateur connecté
|
||||||
Stream<List<AlertModel>> getAlertsStream() {
|
Stream<List<AlertModel>> getAlertsStream() {
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService {
|
|||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[API] Error during request: $functionName', e);
|
DebugLog.error('[API] Error during request: $functionName', e);
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../firebase_options.dart';
|
||||||
|
import '../config/api_config.dart';
|
||||||
|
import 'cache_service.dart';
|
||||||
|
|
||||||
|
/// Service responsable des initialisations lourdes en tâche de fond.
|
||||||
|
///
|
||||||
|
/// Objectif : réduire au maximum le travail synchrone dans main(),
|
||||||
|
/// afficher immédiatement une UI minimale, puis effectuer l'init asynchrone.
|
||||||
|
class AppInitializer with ChangeNotifier {
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool _isInitializing = false;
|
||||||
|
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get isInitializing => _isInitializing;
|
||||||
|
|
||||||
|
final CacheService cacheService = CacheService();
|
||||||
|
|
||||||
|
/// Démarre l'initialisation asynchrone. Idempotent.
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized || _isInitializing) return;
|
||||||
|
_isInitializing = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialiser Firebase
|
||||||
|
await Firebase.initializeApp(
|
||||||
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configurer les émulateurs en dev si demandé
|
||||||
|
if (ApiConfig.isDevelopment) {
|
||||||
|
try {
|
||||||
|
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
|
||||||
|
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignorer si non supporté
|
||||||
|
if (kDebugMode) print('Emulator setup failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiser le cache local sans bloquer l'écran de démarrage.
|
||||||
|
unawaited(cacheService.init());
|
||||||
|
|
||||||
|
// Précharger des assets critiques de façon asynchrone
|
||||||
|
unawaited(_preloadAssets());
|
||||||
|
|
||||||
|
// TODO: lancer ici d'autres initialisations non bloquantes
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
_isInitializing = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e, st) {
|
||||||
|
if (kDebugMode) print('AppInitializer failed: $e\n$st');
|
||||||
|
_isInitializing = false;
|
||||||
|
// Ne rethrow pas pour éviter de planter l'app; laisser l'UI gérer les erreurs.
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _preloadAssets() async {
|
||||||
|
try {
|
||||||
|
// Charger quelques assets en mémoire pour rendre l'affichage initial fluide
|
||||||
|
await rootBundle.load('assets/logos/RectangleLogoBlack.png');
|
||||||
|
await rootBundle.load('assets/logos/SquareLogoWhite.png');
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) print('Preload assets failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Service simple de cache local basé sur SharedPreferences.
|
||||||
|
///
|
||||||
|
/// Fonctionne sur mobile et sur Flutter Web pour conserver des données
|
||||||
|
/// locales légères quand cela apporte une vraie valeur.
|
||||||
|
class CacheService {
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ready() => _prefs != null;
|
||||||
|
|
||||||
|
Future<void> setJson(String key, Map<String, dynamic> value) async {
|
||||||
|
if (_prefs == null) return;
|
||||||
|
await _prefs!.setString(key, jsonEncode(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? getJson(String key) {
|
||||||
|
if (_prefs == null) return null;
|
||||||
|
final s = _prefs!.getString(key);
|
||||||
|
if (s == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(s) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) print('CacheService getJson error: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setString(String key, String value) async {
|
||||||
|
if (_prefs == null) return;
|
||||||
|
await _prefs!.setString(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getString(String key) => _prefs?.getString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
import 'package:em2rp/config/api_config.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// Service de Text-to-Speech utilisant Google Cloud TTS via Cloud Function
|
||||||
|
/// Avec système de cache pour optimiser les performances et réduire les coûts
|
||||||
|
class CloudTextToSpeechService {
|
||||||
|
static final Map<String, String> _audioCache = {};
|
||||||
|
static final Map<String, web.HTMLAudioElement> _audioPlayers = {};
|
||||||
|
|
||||||
|
/// Générer l'audio TTS via Cloud Function
|
||||||
|
/// Retourne l'URL de l'audio (mise en cache automatiquement côté serveur)
|
||||||
|
static Future<String?> generateAudio(String text) async {
|
||||||
|
try {
|
||||||
|
// Vérifier le cache local d'abord
|
||||||
|
if (_audioCache.containsKey(text)) {
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Local cache HIT: "${text.substring(0, 30)}..."');
|
||||||
|
return _audioCache[text];
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] Generating audio for: "$text"');
|
||||||
|
|
||||||
|
// Récupérer le token d'authentification
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
DebugLog.error('[CloudTTS] User not authenticated');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = await user.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
DebugLog.error('[CloudTTS] Failed to get auth token');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer la requête
|
||||||
|
final url = '${ApiConfig.baseUrl}/generateTTSV2';
|
||||||
|
final headers = {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
final body = json.encode({
|
||||||
|
'data': {
|
||||||
|
'text': text,
|
||||||
|
'voiceConfig': {
|
||||||
|
'languageCode': 'fr-FR',
|
||||||
|
'name': 'fr-FR-Standard-B', // Voix masculine gratuite
|
||||||
|
'ssmlGender': 'MALE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] Calling Cloud Function...');
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
// Appeler la Cloud Function
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
final duration = DateTime.now().difference(startTime).inMilliseconds;
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final audioUrl = data['audioUrl'] as String?;
|
||||||
|
final cached = data['cached'] as bool? ?? false;
|
||||||
|
|
||||||
|
if (audioUrl != null) {
|
||||||
|
// Mettre en cache localement
|
||||||
|
_audioCache[text] = audioUrl;
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Audio generated - cached: $cached, duration: ${duration}ms');
|
||||||
|
|
||||||
|
return audioUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.error('[CloudTTS] Failed to generate audio', {
|
||||||
|
'status': response.statusCode,
|
||||||
|
'body': response.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[CloudTTS] Exception:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lire un audio depuis une URL
|
||||||
|
static void playAudio(String audioUrl) {
|
||||||
|
try {
|
||||||
|
DebugLog.info('[CloudTTS] Playing audio...');
|
||||||
|
|
||||||
|
// Créer ou réutiliser un HTMLAudioElement
|
||||||
|
final player = _audioPlayers[audioUrl] ?? web.HTMLAudioElement();
|
||||||
|
if (!_audioPlayers.containsKey(audioUrl)) {
|
||||||
|
player.src = audioUrl;
|
||||||
|
_audioPlayers[audioUrl] = player;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurer le volume
|
||||||
|
player.volume = 1.0;
|
||||||
|
|
||||||
|
// Écouter les événements
|
||||||
|
player.onEnded.listen((_) {
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Playback finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
player.onError.listen((event) {
|
||||||
|
DebugLog.error('[CloudTTS] ✗ Playback error:', event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lire l'audio (pas de await avec package:web)
|
||||||
|
player.play();
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Playback started');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[CloudTTS] Error playing audio:', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer et lire l'audio en une seule opération
|
||||||
|
static Future<void> speak(String text) async {
|
||||||
|
try {
|
||||||
|
final audioUrl = await generateAudio(text);
|
||||||
|
|
||||||
|
if (audioUrl != null) {
|
||||||
|
playAudio(audioUrl);
|
||||||
|
} else {
|
||||||
|
DebugLog.error('[CloudTTS] Failed to generate audio for speech');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[CloudTTS] Error in speak:', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrêter tous les audios en cours
|
||||||
|
static void stopAll() {
|
||||||
|
for (final player in _audioPlayers.values) {
|
||||||
|
try {
|
||||||
|
player.pause();
|
||||||
|
player.currentTime = 0;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignorer les erreurs de pause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DebugLog.info('[CloudTTS] All players stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer le cache
|
||||||
|
static void clearCache() {
|
||||||
|
_audioCache.clear();
|
||||||
|
_audioPlayers.clear();
|
||||||
|
DebugLog.info('[CloudTTS] Cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pré-charger des audios fréquemment utilisés
|
||||||
|
static Future<void> preloadCommonPhrases() async {
|
||||||
|
final phrases = [
|
||||||
|
'Équipement scanné',
|
||||||
|
'Flight case',
|
||||||
|
'Conteneur',
|
||||||
|
'Validé',
|
||||||
|
'Erreur',
|
||||||
|
];
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] Preloading ${phrases.length} common phrases...');
|
||||||
|
|
||||||
|
for (final phrase in phrases) {
|
||||||
|
try {
|
||||||
|
await generateAudio(phrase);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.warning('[CloudTTS] Failed to preload: $phrase - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[CloudTTS] ✓ Preload complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,8 @@ class DataService {
|
|||||||
if (eventTypes == null) return [];
|
if (eventTypes == null) return [];
|
||||||
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la récupération des types d\'événements: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des types d\'événements: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,15 +56,18 @@ class DataService {
|
|||||||
try {
|
try {
|
||||||
final data = <String, dynamic>{'eventId': eventId};
|
final data = <String, dynamic>{'eventId': eventId};
|
||||||
|
|
||||||
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment;
|
if (assignedEquipment != null)
|
||||||
if (preparationStatus != null) data['preparationStatus'] = preparationStatus;
|
data['assignedEquipment'] = assignedEquipment;
|
||||||
|
if (preparationStatus != null)
|
||||||
|
data['preparationStatus'] = preparationStatus;
|
||||||
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
||||||
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
||||||
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
||||||
|
|
||||||
await _apiService.call('updateEventEquipment', data);
|
await _apiService.call('updateEventEquipment', data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +81,13 @@ class DataService {
|
|||||||
final data = <String, dynamic>{'equipmentId': equipmentId};
|
final data = <String, dynamic>{'equipmentId': equipmentId};
|
||||||
|
|
||||||
if (status != null) data['status'] = status;
|
if (status != null) data['status'] = status;
|
||||||
if (availableQuantity != null) data['availableQuantity'] = availableQuantity;
|
if (availableQuantity != null)
|
||||||
|
data['availableQuantity'] = availableQuantity;
|
||||||
|
|
||||||
await _apiService.call('updateEquipmentStatusOnly', data);
|
await _apiService.call('updateEquipmentStatusOnly', data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +112,8 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Crée un équipement
|
/// Crée un équipement
|
||||||
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
|
Future<void> createEquipment(
|
||||||
|
String equipmentId, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
// S'assurer que l'ID est dans les données
|
// S'assurer que l'ID est dans les données
|
||||||
final equipmentData = Map<String, dynamic>.from(data);
|
final equipmentData = Map<String, dynamic>.from(data);
|
||||||
@@ -119,7 +126,8 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Met à jour un équipement
|
/// Met à jour un équipement
|
||||||
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
|
Future<void> updateEquipment(
|
||||||
|
String equipmentId, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
await _apiService.call('updateEquipment', {
|
await _apiService.call('updateEquipment', {
|
||||||
'equipmentId': equipmentId,
|
'equipmentId': equipmentId,
|
||||||
@@ -131,18 +139,26 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Supprime un équipement
|
/// Supprime un équipement
|
||||||
Future<void> deleteEquipment(String equipmentId) async {
|
Future<void> deleteEquipment(String equipmentId,
|
||||||
|
{bool forceDelete = false}) async {
|
||||||
try {
|
try {
|
||||||
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId});
|
await _apiService.call('deleteEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'forceDelete': forceDelete,
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
|
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les événements utilisant un type d'événement donné
|
/// Récupère les événements utilisant un type d'événement donné
|
||||||
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async {
|
Future<List<Map<String, dynamic>>> getEventsByEventType(
|
||||||
|
String eventTypeId) async {
|
||||||
try {
|
try {
|
||||||
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
final result = await _apiService
|
||||||
|
.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||||
final events = result['events'] as List<dynamic>?;
|
final events = result['events'] as List<dynamic>?;
|
||||||
if (events == null) return [];
|
if (events == null) return [];
|
||||||
return events.map((e) => e as Map<String, dynamic>).toList();
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||||
@@ -211,7 +227,7 @@ class DataService {
|
|||||||
/// Met à jour une option
|
/// Met à jour une option
|
||||||
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
final requestData = {'optionId': optionId, ...data};
|
final requestData = {'optionId': optionId, 'data': data};
|
||||||
await _apiService.call('updateOption', requestData);
|
await _apiService.call('updateOption', requestData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
||||||
@@ -271,7 +287,8 @@ class DataService {
|
|||||||
final events = result['events'] as List<dynamic>? ?? [];
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
final users = result['users'] as Map<String, dynamic>? ?? {};
|
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
print('[DataService] Events loaded for $year-$month: ${events.length} events');
|
print(
|
||||||
|
'[DataService] Events loaded for $year-$month: ${events.length} events');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||||
@@ -279,7 +296,32 @@ class DataService {
|
|||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DataService] Error getting events by month: $e');
|
print('[DataService] Error getting events by month: $e');
|
||||||
throw Exception('Erreur lors de la récupération des événements du mois: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des événements du mois: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +341,8 @@ class DataService {
|
|||||||
throw Exception('Event not found');
|
throw Exception('Event not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
print(
|
||||||
|
'[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'event': event,
|
'event': event,
|
||||||
@@ -308,7 +351,8 @@ class DataService {
|
|||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DataService] Error getting event with details: $e');
|
print('[DataService] Error getting event with details: $e');
|
||||||
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,11 +376,13 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère plusieurs équipements par leurs IDs
|
/// Récupère plusieurs équipements par leurs IDs
|
||||||
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async {
|
Future<List<Map<String, dynamic>>> getEquipmentsByIds(
|
||||||
|
List<String> equipmentIds) async {
|
||||||
try {
|
try {
|
||||||
if (equipmentIds.isEmpty) return [];
|
if (equipmentIds.isEmpty) return [];
|
||||||
|
|
||||||
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
|
print(
|
||||||
|
'[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||||
final result = await _apiService.call('getEquipmentsByIds', {
|
final result = await _apiService.call('getEquipmentsByIds', {
|
||||||
'equipmentIds': equipmentIds,
|
'equipmentIds': equipmentIds,
|
||||||
});
|
});
|
||||||
@@ -366,11 +412,13 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère plusieurs containers par leurs IDs
|
/// Récupère plusieurs containers par leurs IDs
|
||||||
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
|
Future<List<Map<String, dynamic>>> getContainersByIds(
|
||||||
|
List<String> containerIds) async {
|
||||||
try {
|
try {
|
||||||
if (containerIds.isEmpty) return [];
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
print('[DataService] Getting containers by IDs: ${containerIds.length} items');
|
print(
|
||||||
|
'[DataService] Getting containers by IDs: ${containerIds.length} items');
|
||||||
final result = await _apiService.call('getContainersByIds', {
|
final result = await _apiService.call('getContainersByIds', {
|
||||||
'containerIds': containerIds,
|
'containerIds': containerIds,
|
||||||
});
|
});
|
||||||
@@ -415,7 +463,8 @@ class DataService {
|
|||||||
params['searchQuery'] = searchQuery;
|
params['searchQuery'] = searchQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
final result =
|
||||||
|
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
'getEquipmentsPaginated',
|
'getEquipmentsPaginated',
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
@@ -423,14 +472,16 @@ class DataService {
|
|||||||
return {
|
return {
|
||||||
'equipments': (result['equipments'] as List<dynamic>?)
|
'equipments': (result['equipments'] as List<dynamic>?)
|
||||||
?.map((e) => e as Map<String, dynamic>)
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
.toList() ?? [],
|
.toList() ??
|
||||||
|
[],
|
||||||
'hasMore': result['hasMore'] as bool? ?? false,
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
'lastVisible': result['lastVisible'] as String?,
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
'total': result['total'] as int? ?? 0,
|
'total': result['total'] as int? ?? 0,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
|
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
|
||||||
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération paginée des équipements: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +511,8 @@ class DataService {
|
|||||||
params['searchQuery'] = searchQuery;
|
params['searchQuery'] = searchQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
final result =
|
||||||
|
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
'getContainersPaginated',
|
'getContainersPaginated',
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
@@ -468,14 +520,16 @@ class DataService {
|
|||||||
return {
|
return {
|
||||||
'containers': (result['containers'] as List<dynamic>?)
|
'containers': (result['containers'] as List<dynamic>?)
|
||||||
?.map((e) => e as Map<String, dynamic>)
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
.toList() ?? [],
|
.toList() ??
|
||||||
|
[],
|
||||||
'hasMore': result['hasMore'] as bool? ?? false,
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
'lastVisible': result['lastVisible'] as String?,
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
'total': result['total'] as int? ?? 0,
|
'total': result['total'] as int? ?? 0,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[DataService] Error in getContainersPaginated', e);
|
DebugLog.error('[DataService] Error in getContainersPaginated', e);
|
||||||
throw Exception('Erreur lors de la récupération paginée des containers: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération paginée des containers: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +553,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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -512,7 +716,8 @@ class DataService {
|
|||||||
return result['user'] as Map<String, dynamic>;
|
return result['user'] as Map<String, dynamic>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DataService] Error getting current user: $e');
|
print('[DataService] Error getting current user: $e');
|
||||||
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +798,8 @@ class DataService {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la récupération des équipements en conflit: $e');
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des équipements en conflit: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,7 +808,8 @@ class DataService {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Récupère toutes les maintenances
|
/// Récupère toutes les maintenances
|
||||||
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
|
Future<List<Map<String, dynamic>>> getMaintenances(
|
||||||
|
{String? equipmentId}) async {
|
||||||
try {
|
try {
|
||||||
final data = <String, dynamic>{};
|
final data = <String, dynamic>{};
|
||||||
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
||||||
@@ -619,14 +826,16 @@ class DataService {
|
|||||||
/// Supprime une maintenance
|
/// Supprime une maintenance
|
||||||
Future<void> deleteMaintenance(String maintenanceId) async {
|
Future<void> deleteMaintenance(String maintenanceId) async {
|
||||||
try {
|
try {
|
||||||
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
await _apiService
|
||||||
|
.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les containers contenant un équipement
|
/// Récupère les containers contenant un équipement
|
||||||
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
|
Future<List<Map<String, dynamic>>> getContainersByEquipment(
|
||||||
|
String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final result = await _apiService.call('getContainersByEquipment', {
|
final result = await _apiService.call('getContainersByEquipment', {
|
||||||
'equipmentId': equipmentId,
|
'equipmentId': equipmentId,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
|
|||||||
|
|
||||||
/// Service d'envoi d'emails via Cloud Functions
|
/// Service d'envoi d'emails via Cloud Functions
|
||||||
class EmailService {
|
class EmailService {
|
||||||
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
|
FirebaseFunctions get _functions => FirebaseFunctions.instanceFor(region: 'europe-west9');
|
||||||
|
|
||||||
/// Envoie un email d'alerte à un utilisateur
|
/// Envoie un email d'alerte à un utilisateur
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -4,6 +4,44 @@ import 'package:em2rp/services/api_service.dart';
|
|||||||
class EventPreparationService {
|
class EventPreparationService {
|
||||||
final ApiService _apiService = apiService;
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
|
/// Retourne true si l'équipement était absent du flux événementiel.
|
||||||
|
///
|
||||||
|
/// Cas typique: matériel jamais emporté au départ, donc absent au retour,
|
||||||
|
/// mais qui ne doit jamais être classé en [LOST].
|
||||||
|
static bool isEquipmentNotTakenToEvent({
|
||||||
|
required bool isMissingAtReturn,
|
||||||
|
required bool isLoaded,
|
||||||
|
required bool isMissingAtLoading,
|
||||||
|
int? quantityAtLoading,
|
||||||
|
}) {
|
||||||
|
if (!isMissingAtReturn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final loadedQuantity = quantityAtLoading ?? 0;
|
||||||
|
return !isLoaded || isMissingAtLoading || loadedQuantity <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne true uniquement si l'équipement doit être classé perdu.
|
||||||
|
static bool shouldMarkEquipmentAsLost({
|
||||||
|
required bool isReturnValidationStep,
|
||||||
|
required bool isMissingAtReturn,
|
||||||
|
required bool isLoaded,
|
||||||
|
required bool isMissingAtLoading,
|
||||||
|
int? quantityAtLoading,
|
||||||
|
}) {
|
||||||
|
if (!isReturnValidationStep || !isMissingAtReturn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isEquipmentNotTakenToEvent(
|
||||||
|
isMissingAtReturn: isMissingAtReturn,
|
||||||
|
isLoaded: isLoaded,
|
||||||
|
isMissingAtLoading: isMissingAtLoading,
|
||||||
|
quantityAtLoading: quantityAtLoading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// === PRÉPARATION ===
|
// === PRÉPARATION ===
|
||||||
|
|
||||||
/// Valider un équipement individuel en préparation
|
/// Valider un équipement individuel en préparation
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/event_statistics_models.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EventStatisticsService {
|
||||||
|
const EventStatisticsService();
|
||||||
|
|
||||||
|
static const double _taxRatio = 1.2;
|
||||||
|
|
||||||
|
EventStatisticsSummary buildSummary({
|
||||||
|
required List<EventModel> events,
|
||||||
|
required EventStatisticsFilter filter,
|
||||||
|
required Map<String, String> eventTypeNames,
|
||||||
|
}) {
|
||||||
|
final filteredEvents =
|
||||||
|
events.where((event) => _matchesFilter(event, filter)).toList();
|
||||||
|
|
||||||
|
if (filteredEvents.isEmpty) {
|
||||||
|
return EventStatisticsSummary.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedEvents = 0;
|
||||||
|
var pendingEvents = 0;
|
||||||
|
var canceledEvents = 0;
|
||||||
|
|
||||||
|
var validatedAmount = 0.0;
|
||||||
|
var pendingAmount = 0.0;
|
||||||
|
var canceledAmount = 0.0;
|
||||||
|
|
||||||
|
var baseAmount = 0.0;
|
||||||
|
var optionsAmount = 0.0;
|
||||||
|
|
||||||
|
final eventAmounts = <double>[];
|
||||||
|
final byType = <String, _EventTypeAccumulator>{};
|
||||||
|
final optionStats = <String, _OptionAccumulator>{};
|
||||||
|
|
||||||
|
for (final event in filteredEvents) {
|
||||||
|
final base = _toHtAmount(event.basePrice);
|
||||||
|
final optionTotal = _computeOptionsTotal(event);
|
||||||
|
final amount = base + optionTotal;
|
||||||
|
final isValidated = event.status == EventStatus.confirmed;
|
||||||
|
|
||||||
|
eventAmounts.add(amount);
|
||||||
|
baseAmount += base;
|
||||||
|
optionsAmount += optionTotal;
|
||||||
|
|
||||||
|
switch (event.status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
validatedEvents += 1;
|
||||||
|
validatedAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
pendingEvents += 1;
|
||||||
|
pendingAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
canceledEvents += 1;
|
||||||
|
canceledAmount += amount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final eventTypeId = event.eventTypeId;
|
||||||
|
final eventTypeName = eventTypeNames[eventTypeId] ?? 'Type inconnu';
|
||||||
|
final typeAccumulator = byType.putIfAbsent(
|
||||||
|
eventTypeId,
|
||||||
|
() => _EventTypeAccumulator(
|
||||||
|
eventTypeId: eventTypeId, eventTypeName: eventTypeName),
|
||||||
|
);
|
||||||
|
typeAccumulator.totalEvents += 1;
|
||||||
|
typeAccumulator.totalAmount += amount;
|
||||||
|
switch (event.status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
typeAccumulator.validatedAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
typeAccumulator.pendingAmount += amount;
|
||||||
|
break;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
typeAccumulator.canceledAmount += amount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final rawOption in event.options) {
|
||||||
|
final optionPrice = _toHtAmount(_toDouble(rawOption['price']));
|
||||||
|
final optionQuantity = _toInt(rawOption['quantity'], fallback: 1);
|
||||||
|
if (optionPrice == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final optionId = (rawOption['id'] ??
|
||||||
|
rawOption['code'] ??
|
||||||
|
rawOption['name'] ??
|
||||||
|
'option')
|
||||||
|
.toString();
|
||||||
|
final optionLabel = _buildOptionLabel(rawOption, optionId);
|
||||||
|
final optionAmount = optionPrice * optionQuantity;
|
||||||
|
|
||||||
|
final optionAccumulator = optionStats.putIfAbsent(
|
||||||
|
optionId,
|
||||||
|
() =>
|
||||||
|
_OptionAccumulator(optionKey: optionId, optionLabel: optionLabel),
|
||||||
|
);
|
||||||
|
optionAccumulator.usageCount += 1;
|
||||||
|
if (isValidated) {
|
||||||
|
optionAccumulator.validatedUsageCount += 1;
|
||||||
|
}
|
||||||
|
optionAccumulator.quantity += optionQuantity;
|
||||||
|
optionAccumulator.totalAmount += optionAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final byEventType = byType.values
|
||||||
|
.map((accumulator) => EventTypeStatistics(
|
||||||
|
eventTypeId: accumulator.eventTypeId,
|
||||||
|
eventTypeName: accumulator.eventTypeName,
|
||||||
|
totalEvents: accumulator.totalEvents,
|
||||||
|
totalAmount: accumulator.totalAmount,
|
||||||
|
validatedAmount: accumulator.validatedAmount,
|
||||||
|
pendingAmount: accumulator.pendingAmount,
|
||||||
|
canceledAmount: accumulator.canceledAmount,
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.totalAmount.compareTo(a.totalAmount));
|
||||||
|
|
||||||
|
final topOptions = optionStats.values
|
||||||
|
.map((accumulator) => OptionStatistics(
|
||||||
|
optionKey: accumulator.optionKey,
|
||||||
|
optionLabel: accumulator.optionLabel,
|
||||||
|
usageCount: accumulator.usageCount,
|
||||||
|
validatedUsageCount: accumulator.validatedUsageCount,
|
||||||
|
quantity: accumulator.quantity,
|
||||||
|
totalAmount: accumulator.totalAmount,
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final validatedComparison =
|
||||||
|
b.validatedUsageCount.compareTo(a.validatedUsageCount);
|
||||||
|
if (validatedComparison != 0) {
|
||||||
|
return validatedComparison;
|
||||||
|
}
|
||||||
|
return b.totalAmount.compareTo(a.totalAmount);
|
||||||
|
});
|
||||||
|
|
||||||
|
return EventStatisticsSummary(
|
||||||
|
totalEvents: filteredEvents.length,
|
||||||
|
validatedEvents: validatedEvents,
|
||||||
|
pendingEvents: pendingEvents,
|
||||||
|
canceledEvents: canceledEvents,
|
||||||
|
totalAmount: validatedAmount + pendingAmount + canceledAmount,
|
||||||
|
validatedAmount: validatedAmount,
|
||||||
|
pendingAmount: pendingAmount,
|
||||||
|
canceledAmount: canceledAmount,
|
||||||
|
baseAmount: baseAmount,
|
||||||
|
optionsAmount: optionsAmount,
|
||||||
|
medianAmount: _computeMedian(eventAmounts),
|
||||||
|
byEventType: byEventType,
|
||||||
|
topOptions: topOptions.take(8).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesFilter(EventModel event, EventStatisticsFilter filter) {
|
||||||
|
if (!_overlapsRange(event, filter.period)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter.selectedStatuses.contains(event.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.eventTypeIds.isNotEmpty &&
|
||||||
|
!filter.eventTypeIds.contains(event.eventTypeId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _overlapsRange(EventModel event, DateTimeRange range) {
|
||||||
|
return !event.endDateTime.isBefore(range.start) &&
|
||||||
|
!event.startDateTime.isAfter(range.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _computeOptionsTotal(EventModel event) {
|
||||||
|
return event.options.fold<double>(0.0, (sum, option) {
|
||||||
|
final optionPrice = _toHtAmount(_toDouble(option['price']));
|
||||||
|
final optionQuantity = _toInt(option['quantity'], fallback: 1);
|
||||||
|
return sum + (optionPrice * optionQuantity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
double _toHtAmount(double storedAmount) {
|
||||||
|
return storedAmount / _taxRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _toDouble(dynamic value) {
|
||||||
|
if (value == null) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if (value is num) {
|
||||||
|
return value.toDouble();
|
||||||
|
}
|
||||||
|
return double.tryParse(value.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _toInt(dynamic value, {int fallback = 0}) {
|
||||||
|
if (value == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is num) {
|
||||||
|
return value.toInt();
|
||||||
|
}
|
||||||
|
return int.tryParse(value.toString()) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildOptionLabel(Map<String, dynamic> option, String fallback) {
|
||||||
|
final code = (option['code'] ?? '').toString().trim();
|
||||||
|
final name = (option['name'] ?? '').toString().trim();
|
||||||
|
|
||||||
|
if (code.isNotEmpty && name.isNotEmpty) {
|
||||||
|
return '$code - $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.isNotEmpty) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _computeMedian(List<double> values) {
|
||||||
|
if (values.isEmpty) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sorted = [...values]..sort();
|
||||||
|
final middleIndex = sorted.length ~/ 2;
|
||||||
|
|
||||||
|
if (sorted.length.isOdd) {
|
||||||
|
return sorted[middleIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventTypeAccumulator {
|
||||||
|
final String eventTypeId;
|
||||||
|
final String eventTypeName;
|
||||||
|
int totalEvents = 0;
|
||||||
|
double totalAmount = 0.0;
|
||||||
|
double validatedAmount = 0.0;
|
||||||
|
double pendingAmount = 0.0;
|
||||||
|
double canceledAmount = 0.0;
|
||||||
|
|
||||||
|
_EventTypeAccumulator({
|
||||||
|
required this.eventTypeId,
|
||||||
|
required this.eventTypeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptionAccumulator {
|
||||||
|
final String optionKey;
|
||||||
|
final String optionLabel;
|
||||||
|
int usageCount = 0;
|
||||||
|
int validatedUsageCount = 0;
|
||||||
|
int quantity = 0;
|
||||||
|
double totalAmount = 0.0;
|
||||||
|
|
||||||
|
_OptionAccumulator({
|
||||||
|
required this.optionKey,
|
||||||
|
required this.optionLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ class IcsExportService {
|
|||||||
Map<String, String>? optionNames,
|
Map<String, String>? optionNames,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
|
final timestamp = '${DateFormat('yyyyMMddTHHmmss').format(now)}Z';
|
||||||
|
|
||||||
// Récupérer les informations supplémentaires
|
// Récupérer les informations supplémentaires
|
||||||
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
|
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
|
||||||
@@ -238,7 +238,7 @@ END:VCALENDAR''';
|
|||||||
/// Formate une date au format ICS (yyyyMMddTHHmmssZ)
|
/// Formate une date au format ICS (yyyyMMddTHHmmssZ)
|
||||||
static String _formatDateForIcs(DateTime dateTime) {
|
static String _formatDateForIcs(DateTime dateTime) {
|
||||||
final utcDate = dateTime.toUtc();
|
final utcDate = dateTime.toUtc();
|
||||||
return DateFormat('yyyyMMddTHHmmss').format(utcDate) + 'Z';
|
return '${DateFormat('yyyyMMddTHHmmss').format(utcDate)}Z';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Échappe les caractères spéciaux pour le format ICS
|
/// Échappe les caractères spéciaux pour le format ICS
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:em2rp/services/cloud_text_to_speech_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service de synthèse vocale utilisant exclusivement Google Cloud TTS
|
||||||
|
/// Garantit une qualité et une compatibilité maximales sur tous les navigateurs
|
||||||
|
class SmartTextToSpeechService {
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
/// Initialiser le service
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[SmartTTS] Initializing Cloud TTS only...');
|
||||||
|
|
||||||
|
// Pré-charger les phrases courantes pour Cloud TTS
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
CloudTextToSpeechService.preloadCommonPhrases();
|
||||||
|
});
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
DebugLog.info('[SmartTTS] ✓ Initialized (Cloud TTS only)');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] Initialization error', e);
|
||||||
|
_initialized = true; // Continuer quand même
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lire un texte à haute voix avec Google Cloud TTS
|
||||||
|
static Future<void> speak(String text) async {
|
||||||
|
if (!_initialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[SmartTTS] → Using Cloud TTS');
|
||||||
|
await CloudTextToSpeechService.speak(text);
|
||||||
|
DebugLog.info('[SmartTTS] ✓ Cloud TTS succeeded');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] ✗ Cloud TTS failed', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrêter toute lecture en cours
|
||||||
|
static Future<void> stop() async {
|
||||||
|
try {
|
||||||
|
CloudTextToSpeechService.stopAll();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] Error stopping', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier si une lecture est en cours
|
||||||
|
static Future<bool> isSpeaking() async {
|
||||||
|
// Cloud TTS n'a pas de méthode native pour vérifier le statut
|
||||||
|
// Retourner false par défaut (peut être amélioré si nécessaire)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir le statut actuel
|
||||||
|
static Map<String, dynamic> getStatus() {
|
||||||
|
return {
|
||||||
|
'initialized': _initialized,
|
||||||
|
'currentStrategy': 'Cloud TTS (exclusive)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nettoyer les ressources
|
||||||
|
static Future<void> dispose() async {
|
||||||
|
try {
|
||||||
|
CloudTextToSpeechService.clearCache();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[SmartTTS] Error disposing', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import 'dart:js_interop';
|
|
||||||
import 'package:web/web.dart' as web;
|
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
|
||||||
|
|
||||||
/// Service de synthèse vocale pour lire des textes à haute voix (Web)
|
|
||||||
class TextToSpeechService {
|
|
||||||
static bool _isInitialized = false;
|
|
||||||
static bool _voicesLoaded = false;
|
|
||||||
static List<web.SpeechSynthesisVoice> _cachedVoices = [];
|
|
||||||
|
|
||||||
/// Initialiser le service TTS
|
|
||||||
static Future<void> initialize() async {
|
|
||||||
if (_isInitialized) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_isInitialized = true;
|
|
||||||
|
|
||||||
final synthesis = web.window.speechSynthesis;
|
|
||||||
|
|
||||||
// Essayer de charger les voix immédiatement
|
|
||||||
_cachedVoices = synthesis.getVoices().toDart;
|
|
||||||
|
|
||||||
if (_cachedVoices.isNotEmpty) {
|
|
||||||
_voicesLoaded = true;
|
|
||||||
DebugLog.info('[TextToSpeechService] Service initialized with ${_cachedVoices.length} voices');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sur certains navigateurs (Firefox notamment), les voix se chargent de manière asynchrone
|
|
||||||
DebugLog.info('[TextToSpeechService] Waiting for voices to load asynchronously...');
|
|
||||||
|
|
||||||
// Attendre l'événement voiceschanged (si supporté)
|
|
||||||
final voicesLoaded = await _waitForVoices(synthesis);
|
|
||||||
|
|
||||||
if (voicesLoaded) {
|
|
||||||
_cachedVoices = synthesis.getVoices().toDart;
|
|
||||||
_voicesLoaded = true;
|
|
||||||
DebugLog.info('[TextToSpeechService] ✓ Voices loaded asynchronously: ${_cachedVoices.length}');
|
|
||||||
} else {
|
|
||||||
DebugLog.warning('[TextToSpeechService] ⚠ No voices found after initialization');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[TextToSpeechService] Erreur lors de l\'initialisation', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attendre le chargement des voix (avec timeout)
|
|
||||||
static Future<bool> _waitForVoices(web.SpeechSynthesis synthesis) async {
|
|
||||||
// Essayer plusieurs fois avec des délais croissants
|
|
||||||
for (int attempt = 0; attempt < 5; attempt++) {
|
|
||||||
await Future.delayed(Duration(milliseconds: 100 * (attempt + 1)));
|
|
||||||
|
|
||||||
final voices = synthesis.getVoices().toDart;
|
|
||||||
if (voices.isNotEmpty) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugLog.info('[TextToSpeechService] Attempt ${attempt + 1}/5: No voices yet');
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lire un texte à haute voix
|
|
||||||
static Future<void> speak(String text) async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
await initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final synthesis = web.window.speechSynthesis;
|
|
||||||
|
|
||||||
DebugLog.info('[TextToSpeechService] Speaking requested: "$text"');
|
|
||||||
|
|
||||||
// Arrêter toute lecture en cours
|
|
||||||
synthesis.cancel();
|
|
||||||
|
|
||||||
// Attendre un peu pour que le cancel soit effectif
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
|
|
||||||
// Créer une nouvelle utterance
|
|
||||||
final utterance = web.SpeechSynthesisUtterance(text);
|
|
||||||
utterance.lang = 'fr-FR';
|
|
||||||
utterance.rate = 0.7;
|
|
||||||
utterance.pitch = 0.7;
|
|
||||||
utterance.volume = 1.0;
|
|
||||||
|
|
||||||
// Récupérer les voix (depuis le cache ou re-charger)
|
|
||||||
var voices = _cachedVoices;
|
|
||||||
|
|
||||||
// Si le cache est vide, essayer de recharger
|
|
||||||
if (voices.isEmpty) {
|
|
||||||
DebugLog.info('[TextToSpeechService] Cache empty, reloading voices...');
|
|
||||||
voices = synthesis.getVoices().toDart;
|
|
||||||
|
|
||||||
// Sur Firefox/Linux, les voix peuvent ne pas être disponibles immédiatement
|
|
||||||
if (voices.isEmpty && !_voicesLoaded) {
|
|
||||||
DebugLog.info('[TextToSpeechService] Waiting for voices with multiple attempts...');
|
|
||||||
|
|
||||||
// Essayer plusieurs fois avec des délais
|
|
||||||
for (int i = 0; i < 3; i++) {
|
|
||||||
await Future.delayed(Duration(milliseconds: 100 * (i + 1)));
|
|
||||||
voices = synthesis.getVoices().toDart;
|
|
||||||
|
|
||||||
if (voices.isNotEmpty) {
|
|
||||||
DebugLog.info('[TextToSpeechService] ✓ Voices loaded on attempt ${i + 1}');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour le cache
|
|
||||||
if (voices.isNotEmpty) {
|
|
||||||
_cachedVoices = voices;
|
|
||||||
_voicesLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugLog.info('[TextToSpeechService] Available voices: ${voices.length}');
|
|
||||||
|
|
||||||
if (voices.isNotEmpty) {
|
|
||||||
web.SpeechSynthesisVoice? selectedVoice;
|
|
||||||
|
|
||||||
// Lister TOUTES les voix françaises pour debug
|
|
||||||
final frenchVoices = <web.SpeechSynthesisVoice>[];
|
|
||||||
for (final voice in voices) {
|
|
||||||
final lang = voice.lang.toLowerCase();
|
|
||||||
if (lang.startsWith('fr')) {
|
|
||||||
frenchVoices.add(voice);
|
|
||||||
DebugLog.info('[TextToSpeechService] French: ${voice.name} (${voice.lang}) ${voice.localService ? 'LOCAL' : 'REMOTE'}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frenchVoices.isEmpty) {
|
|
||||||
DebugLog.warning('[TextToSpeechService] ⚠ NO French voices found!');
|
|
||||||
DebugLog.info('[TextToSpeechService] Available languages:');
|
|
||||||
for (final voice in voices.take(5)) {
|
|
||||||
DebugLog.info('[TextToSpeechService] - ${voice.name} (${voice.lang})');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stratégie de sélection: préférer les voix LOCALES (plus fiables sur Linux)
|
|
||||||
for (final voice in frenchVoices) {
|
|
||||||
if (voice.localService) {
|
|
||||||
selectedVoice = voice;
|
|
||||||
DebugLog.info('[TextToSpeechService] ✓ Selected LOCAL French voice: ${voice.name}');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si pas de voix locale, chercher une voix masculine
|
|
||||||
if (selectedVoice == null) {
|
|
||||||
for (final voice in frenchVoices) {
|
|
||||||
final name = voice.name.toLowerCase();
|
|
||||||
if (name.contains('male') ||
|
|
||||||
name.contains('homme') ||
|
|
||||||
name.contains('thomas') ||
|
|
||||||
name.contains('paul') ||
|
|
||||||
name.contains('bernard')) {
|
|
||||||
selectedVoice = voice;
|
|
||||||
DebugLog.info('[TextToSpeechService] Selected male voice: ${voice.name}');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: première voix française
|
|
||||||
selectedVoice ??= frenchVoices.isNotEmpty ? frenchVoices.first : null;
|
|
||||||
|
|
||||||
if (selectedVoice != null) {
|
|
||||||
utterance.voice = selectedVoice;
|
|
||||||
utterance.lang = selectedVoice.lang; // Utiliser la langue de la voix
|
|
||||||
DebugLog.info('[TextToSpeechService] Final voice: ${selectedVoice.name} (${selectedVoice.lang})');
|
|
||||||
} else {
|
|
||||||
DebugLog.warning('[TextToSpeechService] No French voice, using default with lang=fr-FR');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
DebugLog.warning('[TextToSpeechService] ⚠ NO voices available at all!');
|
|
||||||
DebugLog.warning('[TextToSpeechService] On Linux: install speech-dispatcher and espeak-ng');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter des événements pour le debug
|
|
||||||
utterance.onstart = (web.SpeechSynthesisEvent event) {
|
|
||||||
DebugLog.info('[TextToSpeechService] ✓ Speech started');
|
|
||||||
}.toJS;
|
|
||||||
|
|
||||||
utterance.onend = (web.SpeechSynthesisEvent event) {
|
|
||||||
DebugLog.info('[TextToSpeechService] ✓ Speech ended');
|
|
||||||
}.toJS;
|
|
||||||
|
|
||||||
utterance.onerror = (web.SpeechSynthesisErrorEvent event) {
|
|
||||||
DebugLog.error('[TextToSpeechService] ✗ Speech error: ${event.error}');
|
|
||||||
|
|
||||||
// Messages spécifiques pour aider au diagnostic
|
|
||||||
if (event.error == 'synthesis-failed') {
|
|
||||||
DebugLog.error('[TextToSpeechService] ⚠ SYNTHESIS FAILED - Common on Linux');
|
|
||||||
DebugLog.error('[TextToSpeechService] Possible causes:');
|
|
||||||
DebugLog.error('[TextToSpeechService] 1. speech-dispatcher not installed/running');
|
|
||||||
DebugLog.error('[TextToSpeechService] 2. espeak or espeak-ng not installed');
|
|
||||||
DebugLog.error('[TextToSpeechService] 3. No TTS engine configured');
|
|
||||||
DebugLog.error('[TextToSpeechService] Fix: sudo apt-get install speech-dispatcher espeak-ng');
|
|
||||||
DebugLog.error('[TextToSpeechService] Then restart browser');
|
|
||||||
} else if (event.error == 'network') {
|
|
||||||
DebugLog.error('[TextToSpeechService] Network error - online voice unavailable');
|
|
||||||
} else if (event.error == 'audio-busy') {
|
|
||||||
DebugLog.error('[TextToSpeechService] Audio system is busy');
|
|
||||||
}
|
|
||||||
}.toJS;
|
|
||||||
|
|
||||||
// Lire le texte
|
|
||||||
synthesis.speak(utterance);
|
|
||||||
DebugLog.info('[TextToSpeechService] Speech command sent');
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Arrêter la lecture en cours
|
|
||||||
static Future<void> stop() async {
|
|
||||||
try {
|
|
||||||
web.window.speechSynthesis.cancel();
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[TextToSpeechService] Erreur lors de l\'arrêt', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifier si le service est en train de lire
|
|
||||||
static Future<bool> isSpeaking() async {
|
|
||||||
try {
|
|
||||||
return web.window.speechSynthesis.speaking;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nettoyer les ressources
|
|
||||||
static Future<void> dispose() async {
|
|
||||||
try {
|
|
||||||
web.window.speechSynthesis.cancel();
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../views/login_page.dart';
|
||||||
|
import '../utils/colors.dart';
|
||||||
|
|
||||||
|
/// Gate de démarrage qui attend la restauration Firebase Auth avant
|
||||||
|
/// d'afficher soit le contenu connecté, soit la page de connexion.
|
||||||
|
class AppStartGate extends StatelessWidget {
|
||||||
|
const AppStartGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Sur le web, certaines erreurs natives (ex: cookies tiers bloqués)
|
||||||
|
// peuvent faire remonter une FirebaseException sur le stream d'auth.
|
||||||
|
// Pour éviter que StreamBuilder reçoive une erreur qui casse le build
|
||||||
|
// (TypeError JS interop), on "handleError" et on transforme l'erreur
|
||||||
|
// en une valeur nulle (pas d'utilisateur) afin de garder l'app stable.
|
||||||
|
// Accès protégé à `FirebaseAuth.instance` — sur le web certaines erreurs
|
||||||
|
// d'interop JS peuvent produire des TypeError non compatibles. Nous
|
||||||
|
// attrapons toute exception lors de l'accès et fournissons un stream
|
||||||
|
// neutre (pas d'utilisateur) afin de garder l'UI stable.
|
||||||
|
late final Stream<User?> safeAuthStream;
|
||||||
|
try {
|
||||||
|
safeAuthStream = FirebaseAuth.instance
|
||||||
|
.authStateChanges()
|
||||||
|
.handleError((error, stack) {
|
||||||
|
// Log pour debug ; ne rethrow pas
|
||||||
|
debugPrint('[AppStartGate] authStateChanges error: $error');
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
// Sur certaines configurations web l'accès à FirebaseAuth.instance
|
||||||
|
// peut échouer au niveau JS interop. On log puis on fournit un stream
|
||||||
|
// qui émet une seule valeur nulle pour indiquer "pas d'utilisateur".
|
||||||
|
debugPrint('[AppStartGate] FirebaseAuth.instance access error: $e\n$st');
|
||||||
|
safeAuthStream = Stream<User?>.value(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamBuilder<User?>(
|
||||||
|
stream: safeAuthStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const _StartupSplashScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
// En théorie handleError évite d'arriver ici, mais on garde
|
||||||
|
// une protection supplémentaire.
|
||||||
|
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
|
||||||
|
return const _StartupSplashScreen(message: 'Erreur de connexion');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.data != null) {
|
||||||
|
return const _AuthenticatedBootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
return const LoginPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthenticatedBootstrap extends StatefulWidget {
|
||||||
|
const _AuthenticatedBootstrap();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AuthenticatedBootstrap> createState() =>
|
||||||
|
_AuthenticatedBootstrapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_redirectAfterAuth();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _redirectAfterAuth() async {
|
||||||
|
final fragment = Uri.base.fragment;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||||
|
Navigator.of(context).pushReplacementNamed(fragment);
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const _StartupSplashScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StartupSplashScreen extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const _StartupSplashScreen({this.message = 'Démarrage...'});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/logos/RectangleLogoBlack.png',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.event_available,
|
||||||
|
size: 72,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(message),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,27 +1,48 @@
|
|||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
import 'package:em2rp/views/login_page.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/views/login_page.dart';
|
|
||||||
|
|
||||||
class AuthGuard extends StatelessWidget {
|
class AuthGuard extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final String? requiredPermission;
|
final String? requiredPermission;
|
||||||
|
final bool allowWhileLoading;
|
||||||
|
|
||||||
const AuthGuard({
|
const AuthGuard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.requiredPermission,
|
this.requiredPermission,
|
||||||
|
this.allowWhileLoading = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
||||||
|
final firebaseUser = FirebaseAuth.instance.currentUser;
|
||||||
|
|
||||||
// Log pour débug
|
// Log pour débug
|
||||||
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
|
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
|
||||||
|
|
||||||
|
// Si Firebase n'a pas encore restauré la session ou si le profil charge,
|
||||||
|
// afficher un écran neutre plutôt que la page de connexion.
|
||||||
|
if (firebaseUser != null &&
|
||||||
|
(localAuthProvider.currentUser == null ||
|
||||||
|
localAuthProvider.isLoadingUserData)) {
|
||||||
|
if (allowWhileLoading) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Si l'utilisateur n'est pas connecté
|
// Si l'utilisateur n'est pas connecté
|
||||||
if (localAuthProvider.currentUser == null) {
|
if (firebaseUser == null || localAuthProvider.currentUser == null) {
|
||||||
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
|
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Utilitaires partages pour la suppression d'equipement avec forcage.
|
||||||
|
class EquipmentDeleteUtils {
|
||||||
|
static const String _legacyConflictToken = 'future_event_assignment';
|
||||||
|
static const List<String> _conflictMessageTokens = [
|
||||||
|
'cannot delete equipment because it is assigned to upcoming events',
|
||||||
|
'cannot delete equipment because it is assigned to future events',
|
||||||
|
'assigned to upcoming events',
|
||||||
|
'assigned to future events',
|
||||||
|
];
|
||||||
|
|
||||||
|
static const String deleteDialogTitle = 'Confirmer la suppression';
|
||||||
|
static const String deleteDialogCancelLabel = 'Annuler';
|
||||||
|
static const String deleteDialogConfirmLabel = 'Supprimer';
|
||||||
|
static const String deleteSuccessMessage = 'Équipement supprimé avec succès';
|
||||||
|
|
||||||
|
/// Retourne [name] si renseigne, sinon [id].
|
||||||
|
static String resolveEquipmentLabel({required String id, String? name}) {
|
||||||
|
final trimmedName = name?.trim();
|
||||||
|
if (trimmedName == null || trimmedName.isEmpty) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return trimmedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit le message de confirmation de suppression d'un equipement.
|
||||||
|
static String buildSingleDeleteConfirmationMessage(String equipmentLabel) {
|
||||||
|
return 'Voulez-vous vraiment supprimer "$equipmentLabel" ?\n\n'
|
||||||
|
'Cette action est irréversible.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit le message de confirmation de suppression multiple.
|
||||||
|
static String buildBulkDeleteConfirmationMessage(int selectedCount) {
|
||||||
|
return 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?\n\n'
|
||||||
|
'Cette action est irréversible.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit le message de succes de suppression multiple.
|
||||||
|
static String buildBulkDeleteSuccessMessage(int deletedCount) {
|
||||||
|
return '$deletedCount équipement(s) supprimé(s) avec succès';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit un message d'erreur de suppression homogene.
|
||||||
|
static String buildDeleteErrorMessage(Object error) {
|
||||||
|
return 'Erreur lors de la suppression : $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indique si l'erreur correspond a un conflit de suppression 409.
|
||||||
|
static bool isFutureAssignmentDeleteConflict(Object error) {
|
||||||
|
if (error is ApiException && !error.isConflict) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedMessage = _normalizeErrorMessage(error);
|
||||||
|
if (normalizedMessage.contains(_legacyConflictToken)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _conflictMessageTokens.any(normalizedMessage.contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche la confirmation de suppression forcee.
|
||||||
|
static Future<bool> showForceDeleteDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String equipmentLabel,
|
||||||
|
}) async {
|
||||||
|
final shouldForceDelete = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Équipement utilisé dans un événement à venir'),
|
||||||
|
content: Text(
|
||||||
|
'"$equipmentLabel" est assigné à au moins un événement à venir.\n\n'
|
||||||
|
'Voulez-vous forcer la suppression ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Forcer la suppression'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldForceDelete == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute une suppression, puis propose un forcage en cas de conflit 409.
|
||||||
|
static Future<bool> deleteWithFutureAssignmentCheck({
|
||||||
|
required BuildContext context,
|
||||||
|
required String equipmentLabel,
|
||||||
|
required Future<void> Function({bool forceDelete}) deleteEquipment,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await deleteEquipment(forceDelete: false);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isFutureAssignmentDeleteConflict(error)) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final shouldForceDelete = await showForceDeleteDialog(
|
||||||
|
context,
|
||||||
|
equipmentLabel: equipmentLabel,
|
||||||
|
);
|
||||||
|
if (!shouldForceDelete) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteEquipment(forceDelete: true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeErrorMessage(Object error) {
|
||||||
|
if (error is ApiException) {
|
||||||
|
return error.message.toLowerCase();
|
||||||
|
}
|
||||||
|
return error.toString().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart';
|
|||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class FirebaseStorageManager {
|
class FirebaseStorageManager {
|
||||||
final FirebaseStorage _storage = FirebaseStorage.instance;
|
FirebaseStorage get _storage => FirebaseStorage.instance;
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
|
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
|
||||||
/// Pour le Web, on fixe l'extension .jpg.
|
/// Pour le Web, on fixe l'extension .jpg.
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class PerformanceMonitor {
|
|||||||
static void printSummary() {
|
static void printSummary() {
|
||||||
if (!_enabled || _results.isEmpty) return;
|
if (!_enabled || _results.isEmpty) return;
|
||||||
|
|
||||||
print('\n' + '=' * 60);
|
print('\n${'=' * 60}');
|
||||||
print('PERFORMANCE SUMMARY');
|
print('PERFORMANCE SUMMARY');
|
||||||
print('=' * 60);
|
print('=' * 60);
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ class PerformanceMonitor {
|
|||||||
Duration.zero,
|
Duration.zero,
|
||||||
(sum, duration) => sum + duration,
|
(sum, duration) => sum + duration,
|
||||||
);
|
);
|
||||||
print('${'=' * 60}');
|
print('=' * 60);
|
||||||
print('TOTAL: ${total.inMilliseconds}ms');
|
print('TOTAL: ${total.inMilliseconds}ms');
|
||||||
print('=' * 60 + '\n');
|
print('=' * 60 + '\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import '../providers/local_user_provider.dart';
|
import '../providers/local_user_provider.dart';
|
||||||
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
|
|||||||
passwordController.text,
|
passwordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
|
// --- Étape 2: Charger les données utilisateur en arrière-plan ---
|
||||||
await localAuthProvider.loadUserData();
|
unawaited(
|
||||||
|
localAuthProvider.loadUserData().catchError((e) {
|
||||||
|
debugPrint('Erreur chargement profil après connexion : $e');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Vérifier si le contexte est toujours valide
|
// Vérifier si le contexte est toujours valide
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
|
||||||
if (localAuthProvider.currentUser != null) {
|
|
||||||
// Utiliser pushReplacementNamed pour une transition propre
|
|
||||||
Navigator.of(context, rootNavigator: true)
|
Navigator.of(context, rootNavigator: true)
|
||||||
.pushReplacementNamed('/calendar');
|
.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.)
|
||||||
|
|||||||
+755
-100
File diff suppressed because it is too large
Load Diff
@@ -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,7 +102,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,
|
||||||
@@ -169,7 +170,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
|
|
||||||
// Type
|
// Type
|
||||||
DropdownButtonFormField<ContainerType>(
|
DropdownButtonFormField<ContainerType>(
|
||||||
value: _selectedType,
|
initialValue: _selectedType,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Type de container *',
|
labelText: 'Type de container *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -194,7 +195,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
|
|
||||||
// Statut
|
// Statut
|
||||||
DropdownButtonFormField<EquipmentStatus>(
|
DropdownButtonFormField<EquipmentStatus>(
|
||||||
value: _selectedStatus,
|
initialValue: _selectedStatus,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Statut *',
|
labelText: 'Statut *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -257,7 +258,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 +281,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 +301,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 +321,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 +457,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 +470,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +546,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 +585,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 +594,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 +609,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,28 +646,92 @@ class _EquipmentSelectorDialog extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState();
|
State<_EquipmentSelectorDialog> createState() =>
|
||||||
|
_EquipmentSelectorDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
EquipmentCategory? _filterCategory;
|
EquipmentCategory? _filterCategory;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
late Set<String> _tempSelectedIds;
|
late Set<String> _tempSelectedIds;
|
||||||
|
|
||||||
|
final List<EquipmentModel> _paginatedEquipments = [];
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
bool _hasMoreEquipments = true;
|
||||||
|
String? _lastEquipmentId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Créer une copie temporaire des IDs sélectionnés
|
// Créer une copie temporaire des IDs sélectionnés
|
||||||
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_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(
|
||||||
@@ -701,6 +781,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = '';
|
_searchQuery = '';
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -709,6 +790,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = value;
|
_searchQuery = value;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -726,10 +808,12 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = null;
|
_filterCategory = null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: _filterCategory == null ? Colors.white : Colors.black,
|
color:
|
||||||
|
_filterCategory == null ? Colors.white : Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -743,10 +827,13 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = selected ? category : null;
|
_filterCategory = selected ? category : null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: _filterCategory == category ? Colors.white : Colors.black,
|
color: _filterCategory == category
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -760,7 +847,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(
|
||||||
@@ -778,44 +865,22 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
|
|
||||||
// Liste des équipements
|
// Liste des équipements
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StreamBuilder<List<EquipmentModel>>(
|
child: _paginatedEquipments.isEmpty && !_isLoadingMore
|
||||||
stream: widget.equipmentProvider.equipmentStream,
|
? 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 Center(child: Text('Erreur: ${snapshot.error}'));
|
|
||||||
}
|
|
||||||
|
|
||||||
var equipment = snapshot.data ?? [];
|
|
||||||
|
|
||||||
// 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(
|
return const Center(
|
||||||
child: Text('Aucun équipement trouvé'),
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
final item = _paginatedEquipments[index];
|
||||||
itemCount: equipment.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = equipment[index];
|
|
||||||
final isSelected = _tempSelectedIds.contains(item.id);
|
final isSelected = _tempSelectedIds.contains(item.id);
|
||||||
|
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
@@ -855,8 +920,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
activeColor: AppColors.rouge,
|
activeColor: AppColors.rouge,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:em2rp/utils/colors.dart';
|
|||||||
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
import 'package:em2rp/views/widgets/data_management/event_types_management.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
@@ -32,6 +33,23 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
icon: Icons.file_download,
|
icon: Icons.file_download,
|
||||||
widget: const EventsExport(),
|
widget: const EventsExport(),
|
||||||
),
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Statistiques evenements',
|
||||||
|
icon: Icons.bar_chart,
|
||||||
|
widget: const PermissionGate(
|
||||||
|
requiredPermissions: ['generate_reports'],
|
||||||
|
fallback: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Vous n\'avez pas les permissions necessaires pour voir les statistiques.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: EventStatisticsTab(),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,7 +96,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Menu horizontal en mobile
|
// Menu horizontal en mobile
|
||||||
Container(
|
SizedBox(
|
||||||
height: 60,
|
height: 60,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
@@ -143,7 +161,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.rouge.withOpacity(0.1),
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -177,7 +195,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
selectedTileColor: AppColors.rouge.withOpacity(0.1),
|
selectedTileColor: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
onTap: () => setState(() => _selectedIndex = index),
|
onTap: () => setState(() => _selectedIndex = index),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
|
|||||||
import 'package:em2rp/services/equipment_service.dart';
|
import 'package:em2rp/services/equipment_service.dart';
|
||||||
import 'package:em2rp/services/qr_code_service.dart';
|
import 'package:em2rp/services/qr_code_service.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:em2rp/utils/equipment_delete_utils.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/views/equipment_form_page.dart';
|
import 'package:em2rp/views/equipment_form_page.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
||||||
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
|
|
||||||
Future<void> _loadMaintenances() async {
|
Future<void> _loadMaintenances() async {
|
||||||
try {
|
try {
|
||||||
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
|
final maintenances = await _equipmentService
|
||||||
|
.getMaintenancesForEquipment(widget.equipment.id);
|
||||||
setState(() {
|
setState(() {
|
||||||
_maintenances = maintenances;
|
_maintenances = maintenances;
|
||||||
_isLoadingMaintenances = false;
|
_isLoadingMaintenances = false;
|
||||||
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 3. Notes
|
// 3. Notes
|
||||||
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
|
if (widget.equipment.notes != null &&
|
||||||
|
widget.equipment.notes!.isNotEmpty) ...[
|
||||||
EquipmentNotesSection(notes: widget.equipment.notes!),
|
EquipmentNotesSection(notes: widget.equipment.notes!),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void _showQRCode() {
|
void _showQRCode() {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
|
||||||
|
.trim(),
|
||||||
style: TextStyle(color: Colors.grey[700]),
|
style: TextStyle(color: Colors.grey[700]),
|
||||||
),
|
),
|
||||||
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
|
if (widget.equipment.subCategory != null &&
|
||||||
|
widget.equipment.subCategory!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'📁 ${widget.equipment.subCategory}',
|
'📁 ${widget.equipment.subCategory}',
|
||||||
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
content:
|
||||||
|
Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _deleteEquipment() {
|
void _deleteEquipment() {
|
||||||
|
final pageContext = context;
|
||||||
|
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||||
|
id: widget.equipment.id,
|
||||||
|
name: widget.equipment.name,
|
||||||
|
);
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: pageContext,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
|
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
||||||
|
equipmentLabel,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: const Text('Annuler'),
|
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// Fermer le dialog
|
// Fermer le dialog
|
||||||
Navigator.pop(context);
|
Navigator.pop(dialogContext);
|
||||||
|
|
||||||
// Capturer le ScaffoldMessenger avant la suppression
|
// Capturer le ScaffoldMessenger avant la suppression
|
||||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(pageContext);
|
||||||
|
final provider = pageContext.read<EquipmentProvider>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context
|
final deleted =
|
||||||
.read<EquipmentProvider>()
|
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
|
||||||
.deleteEquipment(widget.equipment.id);
|
context: pageContext,
|
||||||
|
equipmentLabel: equipmentLabel,
|
||||||
|
deleteEquipment: ({bool forceDelete = false}) {
|
||||||
|
return provider.deleteEquipment(
|
||||||
|
widget.equipment.id,
|
||||||
|
forceDelete: forceDelete,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!deleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Revenir à la page précédente
|
// Revenir à la page précédente
|
||||||
navigator.pop();
|
navigator.pop();
|
||||||
@@ -455,19 +477,23 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
// Afficher le snackbar (même si le widget est démonté)
|
// Afficher le snackbar (même si le widget est démonté)
|
||||||
scaffoldMessenger.showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Équipement supprimé avec succès'),
|
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Afficher l'erreur
|
// Afficher l'erreur
|
||||||
scaffoldMessenger.showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e')),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child: const Text('Supprimer'),
|
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -271,7 +271,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<EquipmentCategory>(
|
child: DropdownButtonFormField<EquipmentCategory>(
|
||||||
value: _selectedCategory,
|
initialValue: _selectedCategory,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Catégorie *',
|
labelText: 'Catégorie *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -299,7 +299,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<EquipmentStatus>(
|
child: DropdownButtonFormField<EquipmentStatus>(
|
||||||
value: _selectedStatus,
|
initialValue: _selectedStatus,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Statut *',
|
labelText: 'Statut *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
|||||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
import 'package:em2rp/utils/equipment_delete_utils.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
@@ -28,7 +29,6 @@ class EquipmentManagementPage extends StatefulWidget {
|
|||||||
_EquipmentManagementPageState();
|
_EquipmentManagementPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||||
with SelectionModeMixin<EquipmentManagementPage> {
|
with SelectionModeMixin<EquipmentManagementPage> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
@@ -66,7 +66,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
if (_scrollController.hasClients &&
|
if (_scrollController.hasClients &&
|
||||||
_scrollController.position.pixels >=
|
_scrollController.position.pixels >=
|
||||||
_scrollController.position.maxScrollExtent - 300) {
|
_scrollController.position.maxScrollExtent - 300) {
|
||||||
|
|
||||||
// Vérifier qu'on peut charger plus
|
// Vérifier qu'on peut charger plus
|
||||||
if (provider.hasMore && !provider.isLoadingMore) {
|
if (provider.hasMore && !provider.isLoadingMore) {
|
||||||
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
||||||
@@ -76,7 +75,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
_isLoadingMore = false;
|
_isLoadingMore = false;
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
_isLoadingMore = false;
|
_isLoadingMore = false;
|
||||||
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
DebugLog.error(
|
||||||
|
'[EquipmentManagementPage] Error loading next page', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildEquipmentList() {
|
Widget _buildEquipmentList() {
|
||||||
return Consumer<EquipmentProvider>(
|
return Consumer<EquipmentProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
DebugLog.info(
|
||||||
|
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||||
|
|
||||||
// Afficher l'indicateur de chargement initial uniquement
|
// Afficher l'indicateur de chargement initial uniquement
|
||||||
if (provider.isLoading && provider.equipment.isEmpty) {
|
if (provider.isLoading && provider.equipment.isEmpty) {
|
||||||
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
|
DebugLog.info(
|
||||||
|
'[EquipmentManagementPage] Showing initial loading indicator');
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
|
DebugLog.info(
|
||||||
|
'[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||||
|
|
||||||
// Calculer le nombre total d'items (équipements + indicateur de chargement)
|
// Calculer le nombre total d'items (équipements + indicateur de chargement)
|
||||||
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
|
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
|
||||||
@@ -540,7 +543,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
activeColor: AppColors.rouge,
|
activeColor: AppColors.rouge,
|
||||||
)
|
)
|
||||||
: CircleAvatar(
|
: CircleAvatar(
|
||||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
backgroundColor:
|
||||||
|
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,
|
||||||
@@ -568,12 +572,14 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||||
.trim()
|
.trim()
|
||||||
.isNotEmpty
|
.isNotEmpty
|
||||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||||
|
.trim()
|
||||||
: 'Marque/Modèle non défini',
|
: 'Marque/Modèle non défini',
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||||
),
|
),
|
||||||
// Afficher la sous-catégorie si elle existe
|
// Afficher la sous-catégorie si elle existe
|
||||||
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
if (equipment.subCategory != null &&
|
||||||
|
equipment.subCategory!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'📁 ${equipment.subCategory}',
|
'📁 ${equipment.subCategory}',
|
||||||
@@ -615,7 +621,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
tooltip: 'QR Code',
|
tooltip: 'QR Code',
|
||||||
onPressed: () => showDialog(
|
onPressed: () => showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => QRCodeDialog.forEquipment(equipment),
|
builder: (context) =>
|
||||||
|
QRCodeDialog.forEquipment(equipment),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Bouton Modifier (permission required)
|
// Bouton Modifier (permission required)
|
||||||
@@ -642,8 +649,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
? () => toggleItemSelection(equipment.id)
|
? () => toggleItemSelection(equipment.id)
|
||||||
: () => _viewEquipmentDetails(equipment),
|
: () => _viewEquipmentDetails(equipment),
|
||||||
),
|
),
|
||||||
)
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuantityDisplay(EquipmentModel equipment) {
|
Widget _buildQuantityDisplay(EquipmentModel equipment) {
|
||||||
@@ -705,7 +711,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
void _createNewEquipment() {
|
void _createNewEquipment() {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
@@ -726,39 +731,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _deleteEquipment(EquipmentModel equipment) {
|
void _deleteEquipment(EquipmentModel equipment) {
|
||||||
|
final pageContext = context;
|
||||||
|
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||||
|
id: equipment.id,
|
||||||
|
name: equipment.name,
|
||||||
|
);
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: pageContext,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||||
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
|
content: Text(
|
||||||
|
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
||||||
|
equipmentLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: const Text('Annuler'),
|
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(dialogContext);
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||||
|
final provider = pageContext.read<EquipmentProvider>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context
|
final deleted =
|
||||||
.read<EquipmentProvider>()
|
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
|
||||||
.deleteEquipment(equipment.id);
|
context: pageContext,
|
||||||
if (mounted) {
|
equipmentLabel: equipmentLabel,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
deleteEquipment: ({bool forceDelete = false}) {
|
||||||
|
return provider.deleteEquipment(
|
||||||
|
equipment.id,
|
||||||
|
forceDelete: forceDelete,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!deleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scaffoldMessenger.showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Équipement supprimé avec succès')),
|
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
scaffoldMessenger.showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e')),
|
content: Text(
|
||||||
|
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child: const Text('Supprimer'),
|
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -768,46 +798,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
void _deleteSelectedEquipment() async {
|
void _deleteSelectedEquipment() async {
|
||||||
if (!hasSelection) return;
|
if (!hasSelection) return;
|
||||||
|
|
||||||
|
final pageContext = context;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: pageContext,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
|
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
|
||||||
|
selectedCount,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: const Text('Annuler'),
|
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(dialogContext);
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||||
|
final provider = pageContext.read<EquipmentProvider>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final provider = context.read<EquipmentProvider>();
|
final equipmentById = {
|
||||||
|
for (final equipment
|
||||||
|
in provider.equipment)
|
||||||
|
equipment.id: equipment,
|
||||||
|
};
|
||||||
|
|
||||||
|
var deletedCount = 0;
|
||||||
for (final id in selectedIds) {
|
for (final id in selectedIds) {
|
||||||
await provider.deleteEquipment(id);
|
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||||
|
id: id,
|
||||||
|
name: equipmentById[id]?.name,
|
||||||
|
);
|
||||||
|
final deleted = await EquipmentDeleteUtils
|
||||||
|
.deleteWithFutureAssignmentCheck(
|
||||||
|
context: pageContext,
|
||||||
|
equipmentLabel: label,
|
||||||
|
deleteEquipment: ({bool forceDelete = false}) {
|
||||||
|
return provider.deleteEquipment(
|
||||||
|
id,
|
||||||
|
forceDelete: forceDelete,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (deleted) {
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
disableSelectionMode();
|
disableSelectionMode();
|
||||||
if (mounted) {
|
scaffoldMessenger.showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'$selectedCount équipement(s) supprimé(s) avec succès'),
|
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
|
||||||
|
deletedCount,
|
||||||
|
),
|
||||||
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
scaffoldMessenger.showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e')),
|
content: Text(
|
||||||
|
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child: const Text('Supprimer'),
|
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -853,7 +915,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
|
builder: (context) =>
|
||||||
|
QRCodeDialog.forEquipment(selectedEquipment.first),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1046,7 +1109,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
|
await context
|
||||||
|
.read<EquipmentProvider>()
|
||||||
|
.updateEquipment(updatedEquipment);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -1184,7 +1249,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
content: Text(
|
||||||
|
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import 'package:em2rp/services/data_service.dart';
|
|||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/services/qr_code_processing_service.dart';
|
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/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';
|
||||||
@@ -49,18 +50,18 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
late final DataService _dataService;
|
late final DataService _dataService;
|
||||||
late final QRCodeProcessingService _qrCodeService;
|
late final QRCodeProcessingService _qrCodeService;
|
||||||
|
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
final Map<String, ContainerModel> _containerCache = {};
|
||||||
Map<String, int> _returnedQuantities = {};
|
final Map<String, int> _returnedQuantities = {};
|
||||||
|
|
||||||
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
||||||
Map<String, bool> _localValidationState = {};
|
final Map<String, bool> _localValidationState = {};
|
||||||
|
|
||||||
// Gestion des quantités par étape
|
// Gestion des quantités par étape
|
||||||
Map<String, int> _quantitiesAtPreparation = {};
|
final Map<String, int> _quantitiesAtPreparation = {};
|
||||||
Map<String, int> _quantitiesAtLoading = {};
|
final Map<String, int> _quantitiesAtLoading = {};
|
||||||
Map<String, int> _quantitiesAtUnloading = {};
|
final Map<String, int> _quantitiesAtUnloading = {};
|
||||||
Map<String, int> _quantitiesAtReturn = {};
|
final Map<String, int> _quantitiesAtReturn = {};
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isValidating = false;
|
bool _isValidating = false;
|
||||||
@@ -121,8 +122,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialiser le service de synthèse vocale
|
// Initialiser le service de synthèse vocale hybride
|
||||||
TextToSpeechService.initialize();
|
SmartTextToSpeechService.initialize();
|
||||||
|
|
||||||
// Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay)
|
// Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay)
|
||||||
AudioFeedbackService.unlockAudio();
|
AudioFeedbackService.unlockAudio();
|
||||||
@@ -164,7 +165,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
_manualCodeController.dispose();
|
_manualCodeController.dispose();
|
||||||
_manualCodeFocusNode.dispose();
|
_manualCodeFocusNode.dispose();
|
||||||
TextToSpeechService.stop();
|
SmartTextToSpeechService.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -1212,9 +1234,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
Future<void> _announceNextItem() async {
|
Future<void> _announceNextItem() async {
|
||||||
final nextItem = _findNextItemToScan();
|
final nextItem = _findNextItemToScan();
|
||||||
if (nextItem != null) {
|
if (nextItem != null) {
|
||||||
await TextToSpeechService.speak('Prochain item: $nextItem');
|
await SmartTextToSpeechService.speak('Prochain item: $nextItem');
|
||||||
} else {
|
} else {
|
||||||
await TextToSpeechService.speak('Tous les items sont validés');
|
await SmartTextToSpeechService.speak('Tous les items sont validés');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
||||||
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EventStatisticsPage extends StatelessWidget {
|
||||||
|
const EventStatisticsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PermissionGate(
|
||||||
|
requiredPermissions: const ['generate_reports'],
|
||||||
|
fallback: const Scaffold(
|
||||||
|
appBar: CustomAppBar(title: 'Acces refuse'),
|
||||||
|
body: Center(
|
||||||
|
child: Text(
|
||||||
|
'Vous n\'avez pas les permissions necessaires pour acceder aux statistiques.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Scaffold(
|
||||||
|
appBar: CustomAppBar(title: 'Statistiques evenements'),
|
||||||
|
drawer: MainDrawer(currentPage: '/event_statistics'),
|
||||||
|
body: EventStatisticsTab(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:em2rp/utils/debug_log.dart';
|
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ class EventDetailsDocuments extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return Dialog(
|
return Dialog(
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
height: MediaQuery.of(context).size.height * 0.8,
|
height: MediaQuery.of(context).size.height * 0.8,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
+9
-7
@@ -7,8 +7,9 @@ import 'package:em2rp/views/event_add_page.dart';
|
|||||||
import 'package:em2rp/services/ics_export_service.dart';
|
import 'package:em2rp/services/ics_export_service.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'dart:html' as html;
|
import 'package:web/web.dart' as web;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
|
||||||
class EventDetailsHeader extends StatefulWidget {
|
class EventDetailsHeader extends StatefulWidget {
|
||||||
final EventModel event;
|
final EventModel event;
|
||||||
@@ -180,12 +181,13 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
|
|||||||
|
|
||||||
// Créer un blob et télécharger le fichier
|
// Créer un blob et télécharger le fichier
|
||||||
final bytes = utf8.encode(icsContent);
|
final bytes = utf8.encode(icsContent);
|
||||||
final blob = html.Blob([bytes], 'text/calendar');
|
final blob = web.Blob([bytes.toJS].toJS, web.BlobPropertyBag(type: 'text/calendar'));
|
||||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
final url = web.URL.createObjectURL(blob);
|
||||||
html.AnchorElement(href: url)
|
final anchor = web.document.createElement('a') as web.HTMLAnchorElement;
|
||||||
..setAttribute('download', fileName)
|
anchor.href = url;
|
||||||
..click();
|
anchor.download = fileName;
|
||||||
html.Url.revokeObjectUrl(url);
|
anchor.click();
|
||||||
|
web.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
+10
@@ -26,6 +26,16 @@ class _EventStatusButtonState extends State<EventStatusButton> {
|
|||||||
EventStatus? _optimisticStatus;
|
EventStatus? _optimisticStatus;
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(EventStatusButton oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// Réinitialiser le statut optimiste si on affiche un nouvel événement
|
||||||
|
if (oldWidget.event.id != widget.event.id) {
|
||||||
|
_optimisticStatus = null;
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _changeStatus(EventStatus newStatus) async {
|
Future<void> _changeStatus(EventStatus newStatus) async {
|
||||||
if ((widget.event.status == newStatus) || _loading) return;
|
if ((widget.event.status == newStatus) || _loading) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import 'package:em2rp/models/event_model.dart';
|
|||||||
import 'package:em2rp/utils/calendar_utils.dart';
|
import 'package:em2rp/utils/calendar_utils.dart';
|
||||||
|
|
||||||
class MonthView extends StatelessWidget {
|
class MonthView extends StatelessWidget {
|
||||||
|
static const double _calendarPadding = 8.0;
|
||||||
|
static const double _headerHeight = 52.0;
|
||||||
|
static const double _headerVerticalPadding = 16.0;
|
||||||
|
static const double _daysOfWeekHeight = 16.0;
|
||||||
|
|
||||||
final DateTime focusedDay;
|
final DateTime focusedDay;
|
||||||
final DateTime? selectedDay;
|
final DateTime? selectedDay;
|
||||||
final CalendarFormat calendarFormat;
|
final CalendarFormat calendarFormat;
|
||||||
@@ -30,11 +35,17 @@ class MonthView extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final rowHeight = (constraints.maxHeight - 100) / 6;
|
final rowCount = _computeRowCount(focusedDay);
|
||||||
|
final availableHeight = constraints.maxHeight -
|
||||||
|
(_calendarPadding * 2) -
|
||||||
|
_headerHeight -
|
||||||
|
_headerVerticalPadding -
|
||||||
|
_daysOfWeekHeight;
|
||||||
|
final rowHeight = availableHeight / rowCount;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: constraints.maxHeight,
|
height: constraints.maxHeight,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(_calendarPadding),
|
||||||
child: TableCalendar(
|
child: TableCalendar(
|
||||||
firstDay: DateTime.utc(2020, 1, 1),
|
firstDay: DateTime.utc(2020, 1, 1),
|
||||||
lastDay: DateTime.utc(2030, 12, 31),
|
lastDay: DateTime.utc(2030, 12, 31),
|
||||||
@@ -42,6 +53,7 @@ class MonthView extends StatelessWidget {
|
|||||||
calendarFormat: calendarFormat,
|
calendarFormat: calendarFormat,
|
||||||
startingDayOfWeek: StartingDayOfWeek.monday,
|
startingDayOfWeek: StartingDayOfWeek.monday,
|
||||||
locale: 'fr_FR',
|
locale: 'fr_FR',
|
||||||
|
daysOfWeekHeight: _daysOfWeekHeight,
|
||||||
availableCalendarFormats: const {
|
availableCalendarFormats: const {
|
||||||
CalendarFormat.month: 'Mois',
|
CalendarFormat.month: 'Mois',
|
||||||
CalendarFormat.week: 'Semaine',
|
CalendarFormat.week: 'Semaine',
|
||||||
@@ -132,10 +144,9 @@ class MonthView extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildDayCell(DateTime day, bool isSelected, {bool isToday = false}) {
|
Widget _buildDayCell(DateTime day, bool isSelected, {bool isToday = false}) {
|
||||||
final dayEvents = CalendarUtils.getEventsForDay(day, events);
|
final dayEvents = CalendarUtils.getEventsForDay(day, events);
|
||||||
|
final statusCounts = _getStatusCounts(dayEvents);
|
||||||
final textColor =
|
final textColor =
|
||||||
isSelected ? Colors.white : (isToday ? AppColors.rouge : null);
|
isSelected ? Colors.white : (isToday ? AppColors.rouge : null);
|
||||||
final badgeColor = isSelected ? Colors.white : AppColors.rouge;
|
|
||||||
final badgeTextColor = isSelected ? AppColors.rouge : Colors.white;
|
|
||||||
|
|
||||||
BoxDecoration decoration;
|
BoxDecoration decoration;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -161,42 +172,35 @@ class MonthView extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.all(4),
|
margin: const EdgeInsets.all(4),
|
||||||
decoration: decoration,
|
decoration: decoration,
|
||||||
child: Stack(
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Row(
|
||||||
top: 4,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
left: 4,
|
children: [
|
||||||
child: Text(
|
Text(
|
||||||
day.day.toString(),
|
day.day.toString(),
|
||||||
style: TextStyle(color: textColor),
|
style: TextStyle(color: textColor),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
if (dayEvents.isNotEmpty)
|
Expanded(
|
||||||
Positioned(
|
child: Align(
|
||||||
top: 4,
|
alignment: Alignment.topRight,
|
||||||
right: 4,
|
child: Wrap(
|
||||||
child: Container(
|
spacing: 4,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
runSpacing: 2,
|
||||||
decoration: BoxDecoration(
|
alignment: WrapAlignment.end,
|
||||||
color: badgeColor,
|
children: _buildStatusBadges(statusCounts),
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
dayEvents.length.toString(),
|
|
||||||
style: TextStyle(
|
|
||||||
color: badgeTextColor,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (dayEvents.isNotEmpty)
|
if (dayEvents.isNotEmpty) ...[
|
||||||
Positioned(
|
const SizedBox(height: 4),
|
||||||
bottom: 2,
|
Expanded(
|
||||||
left: 2,
|
|
||||||
right: 2,
|
|
||||||
top: 28,
|
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -207,10 +211,86 @@ class MonthView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<EventStatus, int> _getStatusCounts(List<EventModel> dayEvents) {
|
||||||
|
final counts = <EventStatus, int>{
|
||||||
|
EventStatus.confirmed: 0,
|
||||||
|
EventStatus.waitingForApproval: 0,
|
||||||
|
EventStatus.canceled: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final event in dayEvents) {
|
||||||
|
counts[event.status] = (counts[event.status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildStatusBadges(Map<EventStatus, int> statusCounts) {
|
||||||
|
final badges = <Widget>[];
|
||||||
|
|
||||||
|
void addBadge({
|
||||||
|
required EventStatus status,
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Color textColor,
|
||||||
|
required String tooltipLabel,
|
||||||
|
}) {
|
||||||
|
final count = statusCounts[status] ?? 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
badges.add(
|
||||||
|
Tooltip(
|
||||||
|
message: '$count $tooltipLabel',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
count.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addBadge(
|
||||||
|
status: EventStatus.confirmed,
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
textColor: Colors.white,
|
||||||
|
tooltipLabel:
|
||||||
|
'validé${(statusCounts[EventStatus.confirmed] ?? 0) > 1 ? 's' : ''}',
|
||||||
|
);
|
||||||
|
addBadge(
|
||||||
|
status: EventStatus.waitingForApproval,
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
textColor: Colors.black,
|
||||||
|
tooltipLabel: 'en attente',
|
||||||
|
);
|
||||||
|
addBadge(
|
||||||
|
status: EventStatus.canceled,
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
textColor: Colors.white,
|
||||||
|
tooltipLabel:
|
||||||
|
'annulé${(statusCounts[EventStatus.canceled] ?? 0) > 1 ? 's' : ''}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEventItem(
|
Widget _buildEventItem(
|
||||||
EventModel event, bool isSelected, DateTime currentDay) {
|
EventModel event, bool isSelected, DateTime currentDay) {
|
||||||
Color color;
|
Color color;
|
||||||
@@ -228,7 +308,6 @@ class MonthView extends StatelessWidget {
|
|||||||
icon = Icons.close;
|
icon = Icons.close;
|
||||||
break;
|
break;
|
||||||
case EventStatus.waitingForApproval:
|
case EventStatus.waitingForApproval:
|
||||||
default:
|
|
||||||
color = Colors.amber;
|
color = Colors.amber;
|
||||||
textColor = Colors.black;
|
textColor = Colors.black;
|
||||||
icon = Icons.hourglass_empty;
|
icon = Icons.hourglass_empty;
|
||||||
@@ -243,7 +322,8 @@ class MonthView extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.only(bottom: 2),
|
margin: const EdgeInsets.only(bottom: 2),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? color.withAlpha(220) : color.withOpacity(0.18),
|
color:
|
||||||
|
isSelected ? color.withAlpha(220) : color.withValues(alpha: 0.18),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -282,4 +362,13 @@ class MonthView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calcule le nombre de rangées affichées pour le mois de [focusedDay]
|
||||||
|
/// (calendrier commençant le lundi : offset = weekday - 1)
|
||||||
|
int _computeRowCount(DateTime focusedDay) {
|
||||||
|
final firstOfMonth = DateTime(focusedDay.year, focusedDay.month, 1);
|
||||||
|
final daysInMonth = DateTime(focusedDay.year, focusedDay.month + 1, 0).day;
|
||||||
|
final offset = (firstOfMonth.weekday - 1) % 7; // 0 = lundi, 6 = dimanche
|
||||||
|
return ((daysInMonth + offset) / 7).ceil();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class _UserFilterDropdownState extends State<UserFilterDropdown> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:web/web.dart' as web;
|
||||||
import 'package:em2rp/services/audio_feedback_service.dart';
|
import 'package:em2rp/services/audio_feedback_service.dart';
|
||||||
import 'package:em2rp/services/text_to_speech_service.dart';
|
import 'package:em2rp/services/smart_text_to_speech_service.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
/// Bouton de diagnostic pour tester l'audio et le TTS
|
/// Bouton de diagnostic pour tester l'audio et le TTS
|
||||||
@@ -59,11 +59,11 @@ class AudioDiagnosticButton extends StatelessWidget {
|
|||||||
DebugLog.info('[AudioDiagnostic] Platform: ${web.window.navigator.platform}');
|
DebugLog.info('[AudioDiagnostic] Platform: ${web.window.navigator.platform}');
|
||||||
DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}');
|
DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}');
|
||||||
|
|
||||||
await TextToSpeechService.initialize();
|
await SmartTextToSpeechService.initialize();
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
DebugLog.info('[AudioDiagnostic] Speaking test phrase...');
|
DebugLog.info('[AudioDiagnostic] Speaking test phrase...');
|
||||||
await TextToSpeechService.speak('Test de synthèse vocale. Un, deux, trois.');
|
await SmartTextToSpeechService.speak('Test de synthèse vocale. Un, deux, trois.');
|
||||||
|
|
||||||
DebugLog.info('[AudioDiagnostic] ========== TTS TEST END ==========');
|
DebugLog.info('[AudioDiagnostic] ========== TTS TEST END ==========');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
class StartupSplashScreen extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
const StartupSplashScreen({super.key, this.message = 'Démarrage...'});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/logos/RectangleLogoBlack.png',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.event_available,
|
||||||
|
size: 72,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/event_statistics_models.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/event_statistics_service.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
enum _AmountDisplayMode { ht, ttc }
|
||||||
|
|
||||||
|
enum _DatePreset { currentMonth, previousMonth, currentYear, previousYear }
|
||||||
|
|
||||||
|
class EventStatisticsTab extends StatefulWidget {
|
||||||
|
const EventStatisticsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventStatisticsTab> createState() => _EventStatisticsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventStatisticsTabState extends State<EventStatisticsTab> {
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
final EventStatisticsService _statisticsService =
|
||||||
|
const EventStatisticsService();
|
||||||
|
|
||||||
|
final NumberFormat _currencyFormat =
|
||||||
|
NumberFormat.currency(locale: 'fr_FR', symbol: 'EUR ');
|
||||||
|
final NumberFormat _percentFormat = NumberFormat.percentPattern('fr_FR');
|
||||||
|
|
||||||
|
DateTimeRange _selectedPeriod = _initialPeriod();
|
||||||
|
final Set<String> _selectedEventTypeIds = {};
|
||||||
|
final Set<EventStatus> _selectedStatuses = {
|
||||||
|
EventStatus.confirmed,
|
||||||
|
EventStatus.waitingForApproval,
|
||||||
|
};
|
||||||
|
_AmountDisplayMode _amountDisplayMode = _AmountDisplayMode.ht;
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
List<EventModel> _events = [];
|
||||||
|
Map<String, String> _eventTypeNames = {};
|
||||||
|
EventStatisticsSummary _summary = EventStatisticsSummary.empty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTimeRange _initialPeriod() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return DateTimeRange(
|
||||||
|
start: DateTime(now.year, now.month, 1),
|
||||||
|
end: DateTime(now.year, now.month + 1, 0, 23, 59, 59),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStatistics() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final localUserProvider =
|
||||||
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final userId = localUserProvider.uid;
|
||||||
|
|
||||||
|
final results = await Future.wait([
|
||||||
|
_dataService.getEvents(userId: userId),
|
||||||
|
_dataService.getEventTypes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final eventsResult = results[0] as Map<String, dynamic>;
|
||||||
|
final eventTypesResult = results[1] as List<Map<String, dynamic>>;
|
||||||
|
final eventsData = eventsResult['events'] as List<Map<String, dynamic>>;
|
||||||
|
|
||||||
|
final parsedEvents = <EventModel>[];
|
||||||
|
for (final eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
parsedEvents
|
||||||
|
.add(EventModel.fromMap(eventData, eventData['id'] as String));
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed rows and continue to keep the dashboard available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final eventTypeNames = <String, String>{};
|
||||||
|
for (final eventType in eventTypesResult) {
|
||||||
|
final id = (eventType['id'] ?? '').toString();
|
||||||
|
if (id.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eventTypeNames[id] = (eventType['name'] ?? id).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_events = parsedEvents;
|
||||||
|
_eventTypeNames = eventTypeNames;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
_rebuildSummary();
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = 'Erreur lors du chargement des statistiques: $error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildSummary() {
|
||||||
|
final filter = EventStatisticsFilter(
|
||||||
|
period: _selectedPeriod,
|
||||||
|
eventTypeIds: _selectedEventTypeIds,
|
||||||
|
includeCanceled: _selectedStatuses.contains(EventStatus.canceled),
|
||||||
|
selectedStatuses: _selectedStatuses,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_summary = _statisticsService.buildSummary(
|
||||||
|
events: _events,
|
||||||
|
filter: filter,
|
||||||
|
eventTypeNames: _eventTypeNames,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDateRange() async {
|
||||||
|
final selectedRange = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2035),
|
||||||
|
initialDateRange: _selectedPeriod,
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: AppColors.rouge,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedRange == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedPeriod = DateTimeRange(
|
||||||
|
start: selectedRange.start,
|
||||||
|
end: DateTime(
|
||||||
|
selectedRange.end.year,
|
||||||
|
selectedRange.end.month,
|
||||||
|
selectedRange.end.day,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
_rebuildSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetFilters() {
|
||||||
|
setState(() {
|
||||||
|
_selectedPeriod = _initialPeriod();
|
||||||
|
_selectedEventTypeIds.clear();
|
||||||
|
_selectedStatuses.clear();
|
||||||
|
_selectedStatuses.addAll({
|
||||||
|
EventStatus.confirmed,
|
||||||
|
EventStatus.waitingForApproval,
|
||||||
|
});
|
||||||
|
_amountDisplayMode = _AmountDisplayMode.ht;
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatCurrency(double value) => _currencyFormat.format(value);
|
||||||
|
|
||||||
|
String _formatPercent(double value) => _percentFormat.format(value);
|
||||||
|
|
||||||
|
String get _amountUnitLabel =>
|
||||||
|
_amountDisplayMode == _AmountDisplayMode.ht ? 'HT' : 'TTC';
|
||||||
|
|
||||||
|
double _toDisplayAmount(double htAmount) {
|
||||||
|
if (_amountDisplayMode == _AmountDisplayMode.ttc) {
|
||||||
|
return htAmount * 1.2;
|
||||||
|
}
|
||||||
|
return htAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAmount(double htAmount) =>
|
||||||
|
_formatCurrency(_toDisplayAmount(htAmount));
|
||||||
|
|
||||||
|
String _presetLabel(_DatePreset preset) {
|
||||||
|
switch (preset) {
|
||||||
|
case _DatePreset.currentMonth:
|
||||||
|
return 'Ce mois-ci';
|
||||||
|
case _DatePreset.previousMonth:
|
||||||
|
return 'Mois dernier';
|
||||||
|
case _DatePreset.currentYear:
|
||||||
|
return 'Cette année';
|
||||||
|
case _DatePreset.previousYear:
|
||||||
|
return 'Année dernière';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeRange _rangeForMonth(int year, int month) {
|
||||||
|
return DateTimeRange(
|
||||||
|
start: DateTime(year, month, 1),
|
||||||
|
end: DateTime(year, month + 1, 0, 23, 59, 59),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeRange _rangeForYear(int year) {
|
||||||
|
return DateTimeRange(
|
||||||
|
start: DateTime(year, 1, 1),
|
||||||
|
end: DateTime(year, 12, 31, 23, 59, 59),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeRange _rangeForPreset(_DatePreset preset) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case _DatePreset.currentMonth:
|
||||||
|
return _rangeForMonth(now.year, now.month);
|
||||||
|
case _DatePreset.previousMonth:
|
||||||
|
return _rangeForMonth(now.year, now.month - 1);
|
||||||
|
case _DatePreset.currentYear:
|
||||||
|
return _rangeForYear(now.year);
|
||||||
|
case _DatePreset.previousYear:
|
||||||
|
return _rangeForYear(now.year - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isPresetSelected(_DatePreset preset) {
|
||||||
|
final presetRange = _rangeForPreset(preset);
|
||||||
|
return _selectedPeriod.start == presetRange.start &&
|
||||||
|
_selectedPeriod.end == presetRange.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyDatePreset(_DatePreset preset) {
|
||||||
|
setState(() {
|
||||||
|
_selectedPeriod = _rangeForPreset(preset);
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_errorMessage != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _loadStatistics,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadStatistics,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildFiltersCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildSummaryCards(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildByTypeSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTopOptionsSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFiltersCard() {
|
||||||
|
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.filter_alt, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Filtres',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ToggleButtons(
|
||||||
|
isSelected: [
|
||||||
|
_amountDisplayMode == _AmountDisplayMode.ht,
|
||||||
|
_amountDisplayMode == _AmountDisplayMode.ttc,
|
||||||
|
],
|
||||||
|
onPressed: (index) {
|
||||||
|
setState(() {
|
||||||
|
_amountDisplayMode = index == 0
|
||||||
|
? _AmountDisplayMode.ht
|
||||||
|
: _AmountDisplayMode.ttc;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
children: const [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text('HT'),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text('TTC'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _resetFilters,
|
||||||
|
icon: const Icon(Icons.restart_alt),
|
||||||
|
label: const Text('Réinitialiser'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _selectDateRange,
|
||||||
|
icon: const Icon(Icons.date_range),
|
||||||
|
label: Text(
|
||||||
|
'${dateFormat.format(_selectedPeriod.start)} - ${dateFormat.format(_selectedPeriod.end)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
..._DatePreset.values.map(
|
||||||
|
(preset) => ChoiceChip(
|
||||||
|
label: Text(_presetLabel(preset)),
|
||||||
|
selected: _isPresetSelected(preset),
|
||||||
|
onSelected: (_) => _applyDatePreset(preset),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Statuts d\'événements',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Validés'),
|
||||||
|
selected: _selectedStatuses.contains(EventStatus.confirmed),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedStatuses.add(EventStatus.confirmed);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(EventStatus.confirmed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('En attente'),
|
||||||
|
selected: _selectedStatuses.contains(EventStatus.waitingForApproval),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedStatuses.add(EventStatus.waitingForApproval);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(EventStatus.waitingForApproval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Annulés'),
|
||||||
|
selected: _selectedStatuses.contains(EventStatus.canceled),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedStatuses.add(EventStatus.canceled);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(EventStatus.canceled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_eventTypeNames.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Types d\'événements',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _eventTypeNames.entries.map((entry) {
|
||||||
|
final selected = _selectedEventTypeIds.contains(entry.key);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(entry.value),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value) {
|
||||||
|
_selectedEventTypeIds.add(entry.key);
|
||||||
|
} else {
|
||||||
|
_selectedEventTypeIds.remove(entry.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_rebuildSummary();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryCards() {
|
||||||
|
final metrics = <_MetricConfig>[
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Total événements',
|
||||||
|
value: _summary.totalEvents.toString(),
|
||||||
|
subtitle: 'Sur la période sélectionnée',
|
||||||
|
icon: Icons.event,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant total',
|
||||||
|
value: _formatAmount(_summary.totalAmount),
|
||||||
|
subtitle: 'Base + options ($_amountUnitLabel)',
|
||||||
|
icon: Icons.account_balance_wallet,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant validé',
|
||||||
|
value: _formatAmount(_summary.validatedAmount),
|
||||||
|
subtitle: '${_summary.validatedEvents} événement(s) ($_amountUnitLabel)',
|
||||||
|
icon: Icons.verified,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant non validé',
|
||||||
|
value: _formatAmount(_summary.pendingAmount),
|
||||||
|
subtitle: '${_summary.pendingEvents} événement(s) ($_amountUnitLabel)',
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Montant annulé',
|
||||||
|
value: _formatAmount(_summary.canceledAmount),
|
||||||
|
subtitle: '${_summary.canceledEvents} événement(s) ($_amountUnitLabel)',
|
||||||
|
icon: Icons.cancel,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Panier moyen',
|
||||||
|
value: _formatAmount(_summary.averageAmount),
|
||||||
|
subtitle: 'Par événement ($_amountUnitLabel)',
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Panier médian',
|
||||||
|
value: _formatAmount(_summary.medianAmount),
|
||||||
|
subtitle: 'Par événement ($_amountUnitLabel)',
|
||||||
|
icon: Icons.timeline,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Pourcentage de validation',
|
||||||
|
value: _formatPercent(_summary.validationRate),
|
||||||
|
subtitle:
|
||||||
|
'${_summary.validatedEvents} validés sur ${_summary.totalEvents}',
|
||||||
|
icon: Icons.pie_chart,
|
||||||
|
),
|
||||||
|
_MetricConfig(
|
||||||
|
title: 'Base vs options',
|
||||||
|
value:
|
||||||
|
'${_formatPercent(_summary.baseContributionRate)} / ${_formatPercent(_summary.optionsContributionRate)}',
|
||||||
|
subtitle:
|
||||||
|
'Base: ${_formatAmount(_summary.baseAmount)} - Options: ${_formatAmount(_summary.optionsAmount)}',
|
||||||
|
icon: Icons.stacked_bar_chart,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'KPI période',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: metrics
|
||||||
|
.map(
|
||||||
|
(metric) => _buildMetricCard(
|
||||||
|
title: metric.title,
|
||||||
|
value: metric.value,
|
||||||
|
subtitle: metric.subtitle,
|
||||||
|
icon: metric.icon,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetricCard({
|
||||||
|
required String title,
|
||||||
|
required String value,
|
||||||
|
required String subtitle,
|
||||||
|
required IconData icon,
|
||||||
|
}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 280,
|
||||||
|
child: Card(
|
||||||
|
elevation: 1,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(color: Colors.grey.shade700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildByTypeSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Répartition par type d\'événement',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_summary.byEventType.isEmpty)
|
||||||
|
const Text(
|
||||||
|
'Aucune donnée pour la période et les filtres sélectionnés.')
|
||||||
|
else
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: DataTable(
|
||||||
|
columns: [
|
||||||
|
const DataColumn(label: Text('Type')),
|
||||||
|
const DataColumn(label: Text('Nb')),
|
||||||
|
const DataColumn(label: Text('Validé')),
|
||||||
|
const DataColumn(label: Text('Non validé')),
|
||||||
|
const DataColumn(label: Text('Annulé')),
|
||||||
|
DataColumn(label: Text('Total $_amountUnitLabel')),
|
||||||
|
],
|
||||||
|
rows: _summary.byEventType
|
||||||
|
.map(
|
||||||
|
(row) => DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(Text(row.eventTypeName)),
|
||||||
|
DataCell(Text(row.totalEvents.toString())),
|
||||||
|
DataCell(Text(_formatAmount(row.validatedAmount))),
|
||||||
|
DataCell(Text(_formatAmount(row.pendingAmount))),
|
||||||
|
DataCell(Text(_formatAmount(row.canceledAmount))),
|
||||||
|
DataCell(Text(_formatAmount(row.totalAmount))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopOptionsSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Top options',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_summary.topOptions.isEmpty)
|
||||||
|
const Text('Aucune option valorisée sur la période sélectionnée.')
|
||||||
|
else
|
||||||
|
..._summary.topOptions.map(
|
||||||
|
(option) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.add_chart, color: AppColors.rouge),
|
||||||
|
title: Text(option.optionLabel),
|
||||||
|
subtitle: Text(
|
||||||
|
'Validées ${option.validatedUsageCount} fois',
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
_formatAmount(option.totalAmount),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetricConfig {
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _MetricConfig({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
|
|
||||||
// Boutons d'action par équipement
|
// Boutons d'action par équipement
|
||||||
if (!isRemoved)
|
if (!isRemoved)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
bool shouldAutoLoadNextPage({
|
||||||
|
required bool hasMoreData,
|
||||||
|
required bool isLoadingMore,
|
||||||
|
required bool hasClients,
|
||||||
|
required double maxScrollExtent,
|
||||||
|
}) {
|
||||||
|
if (!hasMoreData || isLoadingMore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the list cannot scroll yet, preload the next page to avoid a truncated view.
|
||||||
|
return !hasClients || maxScrollExtent <= 0;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
|
||||||
|
|
||||||
/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire
|
/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire
|
||||||
class OptimizedEquipmentCard extends StatefulWidget {
|
class OptimizedEquipmentCard extends StatefulWidget {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,9 @@ import 'package:em2rp/models/equipment_model.dart';
|
|||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
|
||||||
import 'package:em2rp/services/api_service.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/equipment_conflict_dialog.dart';
|
import 'package:em2rp/views/widgets/event_form/ai_equipment_assistant_dialog.dart';
|
||||||
import 'package:em2rp/services/event_availability_service.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 {
|
||||||
@@ -21,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,
|
||||||
@@ -30,18 +28,20 @@ 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> {
|
||||||
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
bool get _canAddMaterial =>
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
widget.startDate != null && widget.endDate != null;
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
final Map<String, ContainerModel> _containerCache = {};
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,66 +67,29 @@ 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>();
|
||||||
|
|
||||||
// 🔧 FIX: Si on a un eventId, utiliser getEventWithDetails pour charger les données complètes
|
DebugLog.info(
|
||||||
if (widget.eventId != null && widget.eventId!.isNotEmpty) {
|
'[EventAssignedEquipmentSection] Loading caches from assigned lists');
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Loading event with details: ${widget.eventId}');
|
|
||||||
|
|
||||||
final result = await _dataService.getEventWithDetails(widget.eventId!);
|
// Toujours partir des données locales du formulaire pour éviter les décalages visuels.
|
||||||
final equipmentsMap = result['equipments'] as Map<String, dynamic>;
|
final equipmentIds =
|
||||||
final containersMap = result['containers'] as Map<String, dynamic>;
|
widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
||||||
|
final containers =
|
||||||
|
await containerProvider.getContainersByIds(widget.assignedContainers);
|
||||||
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Loaded ${equipmentsMap.length} equipments and ${containersMap.length} containers with details');
|
|
||||||
|
|
||||||
// Construire les caches à partir des données reçues
|
|
||||||
_equipmentCache.clear();
|
|
||||||
_containerCache.clear();
|
|
||||||
|
|
||||||
// Remplir le cache d'équipements
|
|
||||||
equipmentsMap.forEach((id, data) {
|
|
||||||
try {
|
|
||||||
_equipmentCache[id] = EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[EventAssignedEquipmentSection] Error parsing equipment $id', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remplir le cache de containers
|
|
||||||
containersMap.forEach((id, data) {
|
|
||||||
try {
|
|
||||||
_containerCache[id] = ContainerModel.fromMap(data as Map<String, dynamic>, id);
|
|
||||||
} catch (e) {
|
|
||||||
DebugLog.error('[EventAssignedEquipmentSection] Error parsing container $id', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Caches populated: ${_equipmentCache.length} equipments, ${_containerCache.length} containers');
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Mode création d'événement : charger via les providers
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Loading via providers (creation mode)');
|
|
||||||
|
|
||||||
// Extraire les IDs des équipements assignés
|
|
||||||
final equipmentIds = widget.assignedEquipment
|
|
||||||
.map((eq) => eq.equipmentId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Charger les conteneurs
|
|
||||||
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
|
|
||||||
|
|
||||||
// Extraire les IDs des équipements enfants des containers
|
|
||||||
final childEquipmentIds = <String>[];
|
final childEquipmentIds = <String>[];
|
||||||
for (var container in containers) {
|
for (final container in containers) {
|
||||||
childEquipmentIds.addAll(container.equipmentIds);
|
childEquipmentIds.addAll(container.equipmentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combiner les IDs des équipements assignés + enfants des containers
|
final allEquipmentIds =
|
||||||
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
|
<String>{...equipmentIds, ...childEquipmentIds}.toList();
|
||||||
|
final equipment =
|
||||||
|
await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
||||||
|
|
||||||
// Charger TOUS les équipements nécessaires
|
_equipmentCache.clear();
|
||||||
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
_containerCache.clear();
|
||||||
|
|
||||||
// Créer le cache des équipements
|
for (final eq in widget.assignedEquipment) {
|
||||||
for (var eq in widget.assignedEquipment) {
|
|
||||||
final equipmentItem = equipment.firstWhere(
|
final equipmentItem = equipment.firstWhere(
|
||||||
(e) => e.id == eq.equipmentId,
|
(e) => e.id == eq.equipmentId,
|
||||||
orElse: () => EquipmentModel(
|
orElse: () => EquipmentModel(
|
||||||
@@ -142,8 +105,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
_equipmentCache[eq.equipmentId] = equipmentItem;
|
_equipmentCache[eq.equipmentId] = equipmentItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Créer le cache des conteneurs
|
for (final containerId in widget.assignedContainers) {
|
||||||
for (var containerId in widget.assignedContainers) {
|
|
||||||
final container = containers.firstWhere(
|
final container = containers.firstWhere(
|
||||||
(c) => c.id == containerId,
|
(c) => c.id == containerId,
|
||||||
orElse: () => ContainerModel(
|
orElse: () => ContainerModel(
|
||||||
@@ -158,9 +120,10 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -188,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>[];
|
||||||
@@ -205,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}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +204,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
// 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é
|
||||||
@@ -263,9 +232,74 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
// Notifier le changement
|
// Notifier le changement
|
||||||
widget.onChanged(updatedEquipment, updatedContainers);
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
|
}
|
||||||
|
|
||||||
// Recharger le cache
|
Future<void> _openAiAssistantDialog() async {
|
||||||
await _loadEquipmentAndContainers();
|
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) {
|
||||||
@@ -284,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>[];
|
||||||
@@ -305,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);
|
||||||
@@ -324,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);
|
||||||
@@ -354,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,
|
||||||
@@ -403,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -491,7 +538,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
...widget.assignedContainers.map((containerId) {
|
...widget.assignedContainers.map((containerId) {
|
||||||
final container = _containerCache[containerId];
|
final container = _containerCache[containerId];
|
||||||
return _buildContainerItem(container);
|
return _buildContainerItem(container);
|
||||||
}).toList(),
|
}),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -508,7 +555,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
..._getStandaloneEquipment().map((eq) {
|
..._getStandaloneEquipment().map((eq) {
|
||||||
final equipment = _equipmentCache[eq.equipmentId];
|
final equipment = _equipmentCache[eq.equipmentId];
|
||||||
return _buildEquipmentItem(equipment, eq);
|
return _buildEquipmentItem(equipment, eq);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -520,7 +567,14 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
Widget _buildContainerItem(ContainerModel? container) {
|
Widget _buildContainerItem(ContainerModel? container) {
|
||||||
if (container == null) {
|
if (container == null) {
|
||||||
return const SizedBox.shrink();
|
return const Card(
|
||||||
|
margin: EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.inventory_2, color: Colors.grey),
|
||||||
|
title: Text('Conteneur inconnu'),
|
||||||
|
subtitle: Text('Données du conteneur indisponibles'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
@@ -558,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: [
|
||||||
@@ -583,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(
|
||||||
@@ -597,7 +653,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -608,9 +664,27 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
|
Widget _buildEquipmentItem(
|
||||||
|
EquipmentModel? equipment, EventEquipment eventEq) {
|
||||||
if (equipment == null) {
|
if (equipment == null) {
|
||||||
return const SizedBox.shrink();
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
backgroundColor: Color(0xFFE0E0E0),
|
||||||
|
child: Icon(Icons.inventory_2, color: Colors.grey),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
eventEq.equipmentId,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: const Text('Équipement indisponible dans le cache local'),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () => _removeEquipment(eventEq.equipmentId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
||||||
@@ -621,10 +695,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
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,
|
||||||
@@ -663,4 +735,3 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/models/event_type_model.dart';
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart';
|
import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart';
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ class OptionSelectorWidget extends StatefulWidget {
|
|||||||
final bool isMobile;
|
final bool isMobile;
|
||||||
final String? eventType;
|
final String? eventType;
|
||||||
|
|
||||||
const OptionSelectorWidget({
|
const OptionSelectorWidget({super.key,
|
||||||
Key? key,
|
|
||||||
this.eventType,
|
this.eventType,
|
||||||
required this.selectedOptions,
|
required this.selectedOptions,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
|
import 'package:em2rp/views/event_statistics_page.dart';
|
||||||
|
|
||||||
class MainDrawer extends StatelessWidget {
|
class MainDrawer extends StatelessWidget {
|
||||||
final String currentPage;
|
final String currentPage;
|
||||||
@@ -132,6 +133,24 @@ class MainDrawer extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PermissionGate(
|
||||||
|
requiredPermissions: const ['generate_reports'],
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.bar_chart),
|
||||||
|
title: const Text('Statistiques evenements'),
|
||||||
|
selected: currentPage == '/event_statistics',
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const EventStatisticsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ExpansionTileTheme(
|
ExpansionTileTheme(
|
||||||
data: const ExpansionTileThemeData(
|
data: const ExpansionTileThemeData(
|
||||||
iconColor: AppColors.noir,
|
iconColor: AppColors.noir,
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class _NotificationPreferencesWidgetState extends State<NotificationPreferencesW
|
|||||||
),
|
),
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde
|
onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde
|
||||||
activeColor: Theme.of(context).primaryColor,
|
activeThumbColor: Theme.of(context).primaryColor,
|
||||||
inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF
|
inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF
|
||||||
inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF
|
inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
|||||||
+5
-4
@@ -17,13 +17,14 @@ 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
|
||||||
|
|
||||||
# UI Core
|
# UI Core
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
google_fonts: ^6.1.0
|
google_fonts: ^8.0.2
|
||||||
flutter_svg: ^2.2.1
|
flutter_svg: ^2.2.1
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
flutter_slidable: ^4.0.0
|
flutter_slidable: ^4.0.0
|
||||||
@@ -37,7 +38,7 @@ dependencies:
|
|||||||
|
|
||||||
# Storage & Files
|
# Storage & Files
|
||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
file_picker: ^10.1.9
|
file_picker: ^10.1.9
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
flutter_dropzone: ^4.2.1
|
flutter_dropzone: ^4.2.1
|
||||||
@@ -47,7 +48,7 @@ dependencies:
|
|||||||
pdf: ^3.10.7
|
pdf: ^3.10.7
|
||||||
printing: ^5.11.1
|
printing: ^5.11.1
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
mobile_scanner: ^5.2.3
|
mobile_scanner: ^7.2.0
|
||||||
|
|
||||||
# Network & API
|
# Network & API
|
||||||
http: ^1.1.2
|
http: ^1.1.2
|
||||||
@@ -59,7 +60,7 @@ dependencies:
|
|||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.2.1
|
flutter_local_notifications: ^20.1.0
|
||||||
|
|
||||||
|
|
||||||
# Export/Import
|
# Export/Import
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:em2rp/views/widgets/event/equipment_selection_pagination.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldAutoLoadNextPage', () {
|
||||||
|
test('returns false when there is no more data', () {
|
||||||
|
final result = shouldAutoLoadNextPage(
|
||||||
|
hasMoreData: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
hasClients: true,
|
||||||
|
maxScrollExtent: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false while a page is already loading', () {
|
||||||
|
final result = shouldAutoLoadNextPage(
|
||||||
|
hasMoreData: true,
|
||||||
|
isLoadingMore: true,
|
||||||
|
hasClients: true,
|
||||||
|
maxScrollExtent: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when list has no scroll client yet', () {
|
||||||
|
final result = shouldAutoLoadNextPage(
|
||||||
|
hasMoreData: true,
|
||||||
|
isLoadingMore: false,
|
||||||
|
hasClients: false,
|
||||||
|
maxScrollExtent: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when list is not scrollable yet', () {
|
||||||
|
final result = shouldAutoLoadNextPage(
|
||||||
|
hasMoreData: true,
|
||||||
|
isLoadingMore: false,
|
||||||
|
hasClients: true,
|
||||||
|
maxScrollExtent: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when list is scrollable', () {
|
||||||
|
final result = shouldAutoLoadNextPage(
|
||||||
|
hasMoreData: true,
|
||||||
|
isLoadingMore: false,
|
||||||
|
hasClients: true,
|
||||||
|
maxScrollExtent: 250,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.14",
|
"version": "1.2.1",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"forceUpdate": true,
|
||||||
"releaseNotes": "Ajout de la gestion des maintenance et synthèse vocale",
|
"releaseNotes": "Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.",
|
||||||
"timestamp": "2026-03-03T10:13:12.014Z"
|
"timestamp": "2026-05-25T21:50:50.578Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user