Compare commits
5 Commits
bc93f3fa9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,1773324020831,d5cd7334d7c3a990dbff0821b9aaab39129803e306b0d96599b8adc6d4f433a6
|
||||||
test_audio_tts.js,1772532705302,d7b70556456d3b5e7832506b2dafe31480d94db8d0027b89c1633cc9b5c5bdae
|
index.html,1773324025840,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
index.html,1772532797157,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
flutter_service_worker.js,1773324116910,8582e401e070055f59183c207cf7a7e6a9219a50f5089a24a77d91d3ff77dcbc
|
||||||
flutter_bootstrap.js,1772532797146,ca3df8691f4db5962ed165489bd051dfd31307628ab4f1ee68842dc747d39fd9
|
flutter_bootstrap.js,1773324025827,74eaa66055c715df232ee96fc4114d5473f67717278fb4effa38d8b1b362e303
|
||||||
flutter_service_worker.js,1772532894886,9ce6b8d9f09c957b763a8d3db3baf03c96d4f84e805f6d629294749d9966cfad
|
assets/FontManifest.json,1773324113335,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/FontManifest.json,1772532889954,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
assets/AssetManifest.json,1773324113335,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
assets/AssetManifest.json,1772532889954,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
assets/AssetManifest.bin.json,1773324113335,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
assets/AssetManifest.bin.json,1772532889954,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
assets/AssetManifest.bin,1773324113335,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
assets/AssetManifest.bin,1772532889954,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773324115847,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/shaders/ink_sparkle.frag,1772532890224,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/shaders/ink_sparkle.frag,1773324113551,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1772532893514,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/fonts/MaterialIcons-Regular.otf,1773324115852,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1772532893530,71c7128cf890cf3e18fffca405a98480f174bb3fa79d20c575b473d36c8c3093
|
assets/NOTICES,1773324113339,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
|
||||||
assets/NOTICES,1772532889955,8479783d331c9ff6d2b2e2e0a4b1705eda46ab0000b7753779fb98526ae54d74
|
main.dart.js,1773324112059,bfc66ab7e817db63dee4b996af3dea0629c4c4e87ba91070c15b133ab5104848
|
||||||
main.dart.js,1772532888607,df89975075062e0983691b8997b9e4a1ae4b4d5dfe6c06ca5b42ffa5407fdd3f
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
# Changelog - EM2RP
|
# Changelog - EM2RP
|
||||||
|
|
||||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
## 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
|
||||||
|
|||||||
146
em2rp/functions/generateTTS.js
Normal file
146
em2rp/functions/generateTTS.js
Normal file
@@ -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 };
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ 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');
|
||||||
|
|
||||||
// Initialisation sécurisée
|
// Initialisation sécurisée
|
||||||
if (!admin.apps.length) {
|
if (!admin.apps.length) {
|
||||||
@@ -4193,3 +4194,72 @@ 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
42
em2rp/functions/package-lock.json
generated
42
em2rp/functions/package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"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",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
@@ -772,12 +773,23 @@
|
|||||||
"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/@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 +803,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 +1321,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 +1641,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 +1854,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 +1863,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 +2386,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 +2418,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 +2430,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 +2731,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 +2834,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"
|
||||||
@@ -3576,7 +3578,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 +3716,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 +3743,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 +4092,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 +5108,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 +5487,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 +5839,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 +6030,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 +6511,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 +6525,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 +6989,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 +7026,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 +7042,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 +7060,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,6 +15,7 @@
|
|||||||
"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",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
|
|||||||
@@ -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.1.18';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class Env {
|
class Env {
|
||||||
static const bool isDevelopment = true;
|
static const bool isDevelopment = false;
|
||||||
|
|
||||||
// Configuration de l'auto-login en développement
|
// Configuration de l'auto-login en développement
|
||||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
static const String devAdminEmail = '';
|
||||||
static const String devAdminPassword = 'Pastis51!';
|
static const String devAdminPassword = '';
|
||||||
|
|
||||||
// URLs et endpoints
|
// URLs et endpoints
|
||||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
@@ -14,4 +14,3 @@ class Env {
|
|||||||
// Autres configurations
|
// Autres configurations
|
||||||
static const int apiTimeout = 30000; // 30 secondes
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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';
|
||||||
@@ -33,7 +34,6 @@ import 'services/update_service.dart';
|
|||||||
import 'views/widgets/common/update_dialog.dart';
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
import 'config/api_config.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() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -188,6 +188,8 @@ class MyApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
'/event_statistics': (context) => const AuthGuard(
|
||||||
|
requiredPermission: 'generate_reports', child: EventStatisticsPage()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -347,7 +347,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 +370,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'] ?? [];
|
||||||
|
|||||||
132
em2rp/lib/models/event_statistics_models.dart
Normal file
132
em2rp/lib/models/event_statistics_models.dart
Normal file
@@ -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 = [];
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class EventProvider with ChangeNotifier {
|
|||||||
bool _lastCanViewAll = false;
|
bool _lastCanViewAll = false;
|
||||||
|
|
||||||
// Nouveau: Cache par mois pour le lazy loading
|
// Nouveau: Cache par mois pour le lazy loading
|
||||||
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;
|
||||||
@@ -88,7 +88,7 @@ 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();
|
||||||
@@ -176,7 +176,7 @@ 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;
|
||||||
@@ -220,6 +220,15 @@ 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, {bool canViewAllEvents = false}) async {
|
||||||
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
|
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
|
||||||
|
|||||||
189
em2rp/lib/services/cloud_text_to_speech_service.dart
Normal file
189
em2rp/lib/services/cloud_text_to_speech_service.dart
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -211,7 +211,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');
|
||||||
|
|||||||
280
em2rp/lib/services/event_statistics_service.dart
Normal file
280
em2rp/lib/services/event_statistics_service.dart
Normal file
@@ -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';
|
||||||
|
|||||||
78
em2rp/lib/services/smart_text_to_speech_service.dart
Normal file
78
em2rp/lib/services/smart_text_to_speech_service.dart
Normal file
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:math' as math;
|
||||||
|
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/utils/performance_monitor.dart';
|
import 'package:em2rp/utils/performance_monitor.dart';
|
||||||
@@ -24,13 +26,22 @@ class CalendarPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CalendarPageState extends State<CalendarPage> {
|
class _CalendarPageState extends State<CalendarPage> {
|
||||||
|
static const double _minDetailsPaneFraction = 0.25;
|
||||||
|
static const double _maxDetailsPaneFraction = 0.5;
|
||||||
|
static const double _desktopResizeHandleWidth = 12;
|
||||||
|
static const double _minCalendarPaneWidth = 480;
|
||||||
|
static const double _minDetailsPaneWidth = 320;
|
||||||
|
|
||||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||||
DateTime _focusedDay = DateTime.now();
|
DateTime _focusedDay = DateTime.now();
|
||||||
DateTime? _selectedDay;
|
DateTime? _selectedDay;
|
||||||
EventModel? _selectedEvent;
|
EventModel? _selectedEvent;
|
||||||
bool _calendarCollapsed = false;
|
bool _calendarCollapsed = false;
|
||||||
int _selectedEventIndex = 0;
|
int _selectedEventIndex = 0;
|
||||||
String? _selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
String?
|
||||||
|
_selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
double _detailsPaneFraction = 0.35;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -46,13 +57,15 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
Future<void> _loadCurrentMonthEvents() async {
|
Future<void> _loadCurrentMonthEvents() async {
|
||||||
PerformanceMonitor.start('CalendarPage.loadCurrentMonthEvents');
|
PerformanceMonitor.start('CalendarPage.loadCurrentMonthEvents');
|
||||||
|
|
||||||
final localAuthProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
final localAuthProvider =
|
||||||
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localAuthProvider.uid;
|
final userId = localAuthProvider.uid;
|
||||||
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
||||||
|
|
||||||
if (userId != null) {
|
if (userId != null) {
|
||||||
print('[CalendarPage] Loading events for ${_focusedDay.year}-${_focusedDay.month}');
|
print(
|
||||||
|
'[CalendarPage] Loading events for ${_focusedDay.year}-${_focusedDay.month}');
|
||||||
|
|
||||||
await eventProvider.loadMonthEvents(
|
await eventProvider.loadMonthEvents(
|
||||||
userId,
|
userId,
|
||||||
@@ -79,6 +92,19 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
PerformanceMonitor.end('CalendarPage.loadCurrentMonthEvents');
|
PerformanceMonitor.end('CalendarPage.loadCurrentMonthEvents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vide le cache et recharge les événements du mois courant
|
||||||
|
Future<void> _refreshEvents() async {
|
||||||
|
if (_isRefreshing) return;
|
||||||
|
setState(() => _isRefreshing = true);
|
||||||
|
try {
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
eventProvider.clearAllCache();
|
||||||
|
await _loadCurrentMonthEvents();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isRefreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
||||||
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
|
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
|
||||||
Future<void> _loadEventsAsync() async {
|
Future<void> _loadEventsAsync() async {
|
||||||
@@ -109,7 +135,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
return start.year == now.year &&
|
return start.year == now.year &&
|
||||||
start.month == now.month &&
|
start.month == now.month &&
|
||||||
start.day == now.day;
|
start.day == now.day;
|
||||||
}).toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
}).toList()
|
||||||
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
EventModel? selected;
|
EventModel? selected;
|
||||||
DateTime? selectedDay;
|
DateTime? selectedDay;
|
||||||
@@ -121,7 +148,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
// Chercher le prochain événement à venir
|
// Chercher le prochain événement à venir
|
||||||
final futureEvents = events
|
final futureEvents = events
|
||||||
.where((e) => e.startDateTime.isAfter(now))
|
.where((e) => e.startDateTime.isAfter(now))
|
||||||
.toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
.toList()
|
||||||
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
if (futureEvents.isNotEmpty) {
|
if (futureEvents.isNotEmpty) {
|
||||||
selected = futureEvents[0];
|
selected = futureEvents[0];
|
||||||
@@ -186,21 +214,98 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _clampDetailsPaneFraction(double fraction, double totalWidth) {
|
||||||
|
if (totalWidth <= 0) {
|
||||||
|
return fraction.clamp(_minDetailsPaneFraction, _maxDetailsPaneFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
final minFractionFromPixels = _minDetailsPaneWidth / totalWidth;
|
||||||
|
final maxFractionFromPixels =
|
||||||
|
(totalWidth - _desktopResizeHandleWidth - _minCalendarPaneWidth) /
|
||||||
|
totalWidth;
|
||||||
|
|
||||||
|
final minFraction =
|
||||||
|
math.max(_minDetailsPaneFraction, minFractionFromPixels);
|
||||||
|
final maxFraction =
|
||||||
|
math.min(_maxDetailsPaneFraction, maxFractionFromPixels);
|
||||||
|
|
||||||
|
if (maxFraction < minFraction) {
|
||||||
|
return fraction.clamp(_minDetailsPaneFraction, _maxDetailsPaneFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fraction.clamp(minFraction, maxFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
|
||||||
|
if (_selectedEvent != null) {
|
||||||
|
return EventDetails(
|
||||||
|
event: _selectedEvent!,
|
||||||
|
selectedDate: _selectedDay,
|
||||||
|
events: filteredEvents,
|
||||||
|
onSelectEvent: (event, date) {
|
||||||
|
setState(() {
|
||||||
|
_selectedEvent = event;
|
||||||
|
_selectedDay = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: _selectedDay != null
|
||||||
|
? const Text('Aucun événement ne démarre à cette date')
|
||||||
|
: const Text('Sélectionnez un événement pour voir les détails'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopResizeHandle(double totalWidth) {
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeLeftRight,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
setState(() {
|
||||||
|
_detailsPaneFraction = _clampDetailsPaneFraction(
|
||||||
|
_detailsPaneFraction - (details.delta.dx / totalWidth),
|
||||||
|
totalWidth,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: SizedBox(
|
||||||
|
width: _desktopResizeHandleWidth,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 4,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
final eventProvider = Provider.of<EventProvider>(context);
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||||
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
||||||
final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events');
|
final canViewAllUserEvents =
|
||||||
|
localUserProvider.hasPermission('view_all_user_events');
|
||||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
// Appliquer le filtre utilisateur si actif
|
// Appliquer le filtre utilisateur si actif
|
||||||
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||||
|
|
||||||
// Debug logs
|
// Debug logs
|
||||||
print('[CalendarPage.build] Total events: ${eventProvider.events.length}, Filtered: ${filteredEvents.length}');
|
print(
|
||||||
|
'[CalendarPage.build] Total events: ${eventProvider.events.length}, Filtered: ${filteredEvents.length}');
|
||||||
if (eventProvider.events.isNotEmpty) {
|
if (eventProvider.events.isNotEmpty) {
|
||||||
print('[CalendarPage.build] First event: ${eventProvider.events.first.name} at ${eventProvider.events.first.startDateTime}');
|
print(
|
||||||
|
'[CalendarPage.build] First event: ${eventProvider.events.first.name} at ${eventProvider.events.first.startDateTime}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventProvider.isLoading) {
|
if (eventProvider.isLoading) {
|
||||||
@@ -214,6 +319,26 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Calendrier",
|
title: "Calendrier",
|
||||||
|
actions: [
|
||||||
|
if (_isRefreshing)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.white),
|
||||||
|
tooltip: 'Mettre à jour les événements',
|
||||||
|
onPressed: _refreshEvents,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -247,7 +372,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
),
|
),
|
||||||
// Corps du calendrier
|
// Corps du calendrier
|
||||||
Expanded(
|
Expanded(
|
||||||
child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents),
|
child: isMobile
|
||||||
|
? _buildMobileLayout(filteredEvents)
|
||||||
|
: _buildDesktopLayout(filteredEvents),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -271,37 +398,31 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
|
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final totalWidth = constraints.maxWidth;
|
||||||
|
final detailsPaneFraction =
|
||||||
|
_clampDetailsPaneFraction(_detailsPaneFraction, totalWidth);
|
||||||
|
final detailsWidth = totalWidth * detailsPaneFraction;
|
||||||
|
final calendarWidth =
|
||||||
|
totalWidth - _desktopResizeHandleWidth - detailsWidth;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Calendrier (65% de la largeur)
|
SizedBox(
|
||||||
Expanded(
|
width: calendarWidth,
|
||||||
flex: 65,
|
|
||||||
child: _buildCalendar(filteredEvents),
|
child: _buildCalendar(filteredEvents),
|
||||||
),
|
),
|
||||||
// Détails de l'événement (35% de la largeur)
|
_buildDesktopResizeHandle(totalWidth),
|
||||||
Expanded(
|
SizedBox(
|
||||||
flex: 35,
|
width: detailsWidth,
|
||||||
child: _selectedEvent != null
|
child: _buildDesktopDetailsPane(filteredEvents),
|
||||||
? EventDetails(
|
|
||||||
event: _selectedEvent!,
|
|
||||||
selectedDate: _selectedDay,
|
|
||||||
events: filteredEvents,
|
|
||||||
onSelectEvent: (event, date) {
|
|
||||||
setState(() {
|
|
||||||
_selectedEvent = event;
|
|
||||||
_selectedDay = date;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: Center(
|
|
||||||
child: _selectedDay != null
|
|
||||||
? Text('Aucun événement ne démarre à cette date')
|
|
||||||
: const Text(
|
|
||||||
'Sélectionnez un événement pour voir les détails'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
||||||
@@ -341,19 +462,23 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -200) {
|
if (details.primaryVelocity! < -200) {
|
||||||
// Swipe gauche : mois suivant
|
// Swipe gauche : mois suivant
|
||||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = newMonth;
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
_loadCurrentMonthEvents();
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = newMonth;
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
_loadCurrentMonthEvents();
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,7 +510,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = newMonth;
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
_loadCurrentMonthEvents();
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
@@ -394,7 +520,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = newMonth;
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
_loadCurrentMonthEvents();
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,11 +684,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_left,
|
icon: const Icon(Icons.chevron_left,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = newMonth;
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
_loadCurrentMonthEvents();
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -600,11 +729,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_right,
|
icon: const Icon(Icons.chevron_right,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
final newMonth =
|
||||||
|
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = newMonth;
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
_loadCurrentMonthEvents();
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -729,7 +860,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
|
|
||||||
// Charger les événements du nouveau mois si nécessaire
|
// Charger les événements du nouveau mois si nécessaire
|
||||||
if (monthChanged) {
|
if (monthChanged) {
|
||||||
print('[CalendarPage] Month changed to ${focusedDay.year}-${focusedDay.month}');
|
print(
|
||||||
|
'[CalendarPage] Month changed to ${focusedDay.year}-${focusedDay.month}');
|
||||||
_loadCurrentMonthEvents();
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -169,7 +169,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 +194,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(),
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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/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';
|
||||||
@@ -49,18 +49,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 +121,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 +164,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,9 +1212,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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
em2rp/lib/views/event_statistics_page.dart
Normal file
31
em2rp/lib/views/event_statistics_page.dart
Normal file
@@ -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';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ContainerConflictInfo {
|
|||||||
if (status == ContainerConflictStatus.complete) {
|
if (status == ContainerConflictStatus.complete) {
|
||||||
return 'Tous les équipements sont déjà utilisés';
|
return 'Tous les équipements sont déjà utilisés';
|
||||||
}
|
}
|
||||||
return '${conflictingEquipmentIds.length}/${totalChildren} équipement(s) déjà utilisé(s)';
|
return '${conflictingEquipmentIds.length}/$totalChildren équipement(s) déjà utilisé(s)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +94,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
Map<String, SelectedItem> _selectedItems = {};
|
Map<String, SelectedItem> _selectedItems = {};
|
||||||
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
|
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
|
||||||
Map<String, int> _availableQuantities = {}; // Pour consommables
|
final Map<String, int> _availableQuantities = {}; // Pour consommables
|
||||||
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
final Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
||||||
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
final Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
||||||
Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
|
final Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
|
||||||
Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
|
final Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
|
||||||
|
|
||||||
// NOUVEAU : IDs en conflit récupérés en batch
|
// NOUVEAU : IDs en conflit récupérés en batch
|
||||||
Set<String> _conflictingEquipmentIds = {};
|
Set<String> _conflictingEquipmentIds = {};
|
||||||
@@ -119,12 +119,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
bool _hasMoreContainers = true;
|
bool _hasMoreContainers = true;
|
||||||
String? _lastEquipmentId;
|
String? _lastEquipmentId;
|
||||||
String? _lastContainerId;
|
String? _lastContainerId;
|
||||||
List<EquipmentModel> _paginatedEquipments = [];
|
final List<EquipmentModel> _paginatedEquipments = [];
|
||||||
List<ContainerModel> _paginatedContainers = [];
|
final List<ContainerModel> _paginatedContainers = [];
|
||||||
|
|
||||||
// Cache pour éviter les rebuilds inutiles
|
// Cache pour éviter les rebuilds inutiles
|
||||||
List<ContainerModel> _cachedContainers = [];
|
final List<ContainerModel> _cachedContainers = [];
|
||||||
List<EquipmentModel> _cachedEquipment = [];
|
final List<EquipmentModel> _cachedEquipment = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -1047,7 +1047,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: dialogWidth.clamp(600.0, 1200.0),
|
width: dialogWidth.clamp(600.0, 1200.0),
|
||||||
height: dialogHeight.clamp(500.0, 900.0),
|
height: dialogHeight.clamp(500.0, 900.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1458,66 +1458,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header de section repliable
|
|
||||||
Widget _buildCollapsibleSectionHeader(
|
|
||||||
String title,
|
|
||||||
IconData icon,
|
|
||||||
int count,
|
|
||||||
bool isExpanded,
|
|
||||||
Function(bool) onToggle,
|
|
||||||
) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onToggle(!isExpanded),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.rouge.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.rouge.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
|
|
||||||
color: AppColors.rouge,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(icon, color: AppColors.rouge, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.rouge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.rouge,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'$count',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEquipmentCard(EquipmentModel equipment, {Key? key}) {
|
Widget _buildEquipmentCard(EquipmentModel equipment, {Key? key}) {
|
||||||
final isSelected = _selectedItems.containsKey(equipment.id);
|
final isSelected = _selectedItems.containsKey(equipment.id);
|
||||||
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
||||||
@@ -1809,7 +1749,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}) {
|
}) {
|
||||||
final displayQuantity = isSelected ? selectedItem.quantity : 0;
|
final displayQuantity = isSelected ? selectedItem.quantity : 0;
|
||||||
|
|
||||||
return Container(
|
return SizedBox(
|
||||||
width: 120,
|
width: 120,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -2369,7 +2309,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache local pour les équipements des conteneurs
|
// Cache local pour les équipements des conteneurs
|
||||||
Map<String, List<String>> _containerEquipmentCache = {};
|
final Map<String, List<String>> _containerEquipmentCache = {};
|
||||||
|
|
||||||
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
|
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
|
||||||
final isExpanded = _expandedContainers.contains(id);
|
final isExpanded = _expandedContainers.contains(id);
|
||||||
@@ -2425,7 +2365,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
|
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ 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/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 {
|
||||||
@@ -38,10 +34,8 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
|||||||
|
|
||||||
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
||||||
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
||||||
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
final Map<String, ContainerModel> _containerCache = {};
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,66 +61,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
// 🔧 FIX: Si on a un eventId, utiliser getEventWithDetails pour charger les données complètes
|
DebugLog.info('[EventAssignedEquipmentSection] Loading caches from assigned lists');
|
||||||
if (widget.eventId != null && widget.eventId!.isNotEmpty) {
|
|
||||||
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 = widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
||||||
final containersMap = result['containers'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
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);
|
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 = <String>{...equipmentIds, ...childEquipmentIds}.toList();
|
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
|
||||||
|
|
||||||
// Charger TOUS les équipements nécessaires
|
|
||||||
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
||||||
|
|
||||||
// Créer le cache des équipements
|
_equipmentCache.clear();
|
||||||
for (var eq in widget.assignedEquipment) {
|
_containerCache.clear();
|
||||||
|
|
||||||
|
for (final 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 +94,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,7 +109,6 @@ 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 {
|
||||||
@@ -263,9 +213,6 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
// Notifier le changement
|
// Notifier le changement
|
||||||
widget.onChanged(updatedEquipment, updatedContainers);
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
|
|
||||||
// Recharger le cache
|
|
||||||
await _loadEquipmentAndContainers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeEquipment(String equipmentId) {
|
void _removeEquipment(String equipmentId) {
|
||||||
@@ -491,7 +438,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 +455,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 +467,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(
|
||||||
@@ -597,7 +551,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -610,7 +564,24 @@ 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 ||
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ dependencies:
|
|||||||
|
|
||||||
# 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 +37,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 +47,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 +59,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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.14",
|
"version": "1.1.18",
|
||||||
"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": "Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.",
|
||||||
"timestamp": "2026-03-03T10:13:12.014Z"
|
"timestamp": "2026-03-12T20:11:54.548Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user