From 6737ad80e453938db537a612bf04809a082c9167 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Thu, 12 Mar 2026 15:05:28 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20mise=20=C3=A0=20jour=20v1.1.17=20et=20a?= =?UTF-8?q?jout=20du=20tableau=20de=20bord=20des=20statistiques=20d'=C3=A9?= =?UTF-8?q?v=C3=A9nements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mise à jour de la version de l'application à `1.1.17` dans `app_version.dart` et `version.json`. - Création d'un module complet de statistiques (`EventStatisticsPage`, `EventStatisticsService`, `EventStatisticsTab`) permettant de filtrer et visualiser les KPI d'événements (montants HT/TTC, panier moyen, répartition par type, top options). - Ajout d'une entrée "Statistiques événements" dans le menu latéral (`MainDrawer`) protégée par la permission `generate_reports`. - Migration exclusive vers Google Cloud TTS dans `SmartTextToSpeechService` et suppression de `TextToSpeechService` (Web Speech API native) pour garantir une compatibilité maximale sur tous les navigateurs. - Mise à jour des dépendances dans `pubspec.yaml` (`google_fonts`, `flutter_secure_storage`, `mobile_scanner`, `flutter_local_notifications`). - Migration du code d'export ICS vers `package:web` pour remplacer l'utilisation de `dart:html` obsolète. - Mise à jour du `CHANGELOG.md` documentant les statistiques et l'évolution du service de synthèse vocale. --- em2rp/CHANGELOG.md | 7 +- em2rp/lib/config/app_version.dart | 2 +- em2rp/lib/main.dart | 3 + em2rp/lib/models/event_statistics_models.dart | 132 ++++ .../services/event_statistics_service.dart | 280 +++++++ .../smart_text_to_speech_service.dart | 111 +-- .../lib/services/text_to_speech_service.dart | 273 ------- em2rp/lib/views/data_management_page.dart | 22 +- em2rp/lib/views/event_statistics_page.dart | 31 + .../event_details_header.dart | 16 +- .../data_management/event_statistics_tab.dart | 715 ++++++++++++++++++ em2rp/lib/views/widgets/nav/main_drawer.dart | 19 + em2rp/pubspec.yaml | 8 +- em2rp/web/version.json | 6 +- 14 files changed, 1236 insertions(+), 389 deletions(-) create mode 100644 em2rp/lib/models/event_statistics_models.dart create mode 100644 em2rp/lib/services/event_statistics_service.dart delete mode 100644 em2rp/lib/services/text_to_speech_service.dart create mode 100644 em2rp/lib/views/event_statistics_page.dart create mode 100644 em2rp/lib/views/widgets/data_management/event_statistics_tab.dart diff --git a/em2rp/CHANGELOG.md b/em2rp/CHANGELOG.md index af48a65..ee11f5c 100644 --- a/em2rp/CHANGELOG.md +++ b/em2rp/CHANGELOG.md @@ -2,8 +2,13 @@ Toutes les modifications notables de ce projet seront documentées dans ce fichier. +## 12/03/2026 +Ajout d'une page de statistiques détaillées pour les équipements et les événements. + ## 10/03/2026 -Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS. Resolution bug d'affichage des evenements pour les membres CREW +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 Ajout de la gestion des maintenance et synthèse vocale diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index b34d6b3..461375c 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -1,6 +1,6 @@ /// Configuration de la version de l'application class AppVersion { - static const String version = '1.1.14'; + static const String version = '1.1.17'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 4eb1fbc..5d53180 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -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_detail_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/event_model.dart'; import 'package:firebase_auth/firebase_auth.dart'; @@ -187,6 +188,8 @@ class MyApp extends StatelessWidget { ), ); }, + '/event_statistics': (context) => const AuthGuard( + requiredPermission: 'generate_reports', child: EventStatisticsPage()), }, ); } diff --git a/em2rp/lib/models/event_statistics_models.dart b/em2rp/lib/models/event_statistics_models.dart new file mode 100644 index 0000000..40dff7c --- /dev/null +++ b/em2rp/lib/models/event_statistics_models.dart @@ -0,0 +1,132 @@ +import 'package:em2rp/models/event_model.dart'; +import 'package:flutter/material.dart'; + +class EventStatisticsFilter { + final DateTimeRange period; + final Set eventTypeIds; + final bool includeCanceled; + final Set selectedStatuses; + + const EventStatisticsFilter({ + required this.period, + this.eventTypeIds = const {}, + this.includeCanceled = false, + this.selectedStatuses = const { + EventStatus.confirmed, + EventStatus.waitingForApproval, + }, + }); + + EventStatisticsFilter copyWith({ + DateTimeRange? period, + Set? eventTypeIds, + bool? includeCanceled, + Set? 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 byEventType; + final List 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; +} diff --git a/em2rp/lib/services/event_statistics_service.dart b/em2rp/lib/services/event_statistics_service.dart new file mode 100644 index 0000000..c364e52 --- /dev/null +++ b/em2rp/lib/services/event_statistics_service.dart @@ -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 events, + required EventStatisticsFilter filter, + required Map 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 = []; + final byType = {}; + final optionStats = {}; + + 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(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 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 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, + }); +} diff --git a/em2rp/lib/services/smart_text_to_speech_service.dart b/em2rp/lib/services/smart_text_to_speech_service.dart index f0c3e9d..5cb99d2 100644 --- a/em2rp/lib/services/smart_text_to_speech_service.dart +++ b/em2rp/lib/services/smart_text_to_speech_service.dart @@ -1,109 +1,50 @@ -import 'package:em2rp/services/text_to_speech_service.dart'; import 'package:em2rp/services/cloud_text_to_speech_service.dart'; import 'package:em2rp/utils/debug_log.dart'; -/// Service hybride intelligent pour le Text-to-Speech -/// Essaie d'abord Web Speech API (gratuit, rapide), puis fallback vers Cloud TTS +/// 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; - static bool _webSpeechWorks = true; // Optimiste par défaut - static int _webSpeechFailures = 0; - static const int _maxFailuresBeforeSwitch = 2; /// Initialiser le service static Future initialize() async { if (_initialized) return; try { - DebugLog.info('[SmartTTS] Initializing...'); + DebugLog.info('[SmartTTS] Initializing Cloud TTS only...'); - // Initialiser Web Speech API - await TextToSpeechService.initialize(); - - // Pré-charger les phrases courantes pour Cloud TTS en arrière-plan - // (ne bloque pas l'initialisation) - Future.delayed(const Duration(seconds: 2), () { + // Pré-charger les phrases courantes pour Cloud TTS + Future.delayed(const Duration(milliseconds: 500), () { CloudTextToSpeechService.preloadCommonPhrases(); }); _initialized = true; - DebugLog.info('[SmartTTS] ✓ Initialized (Web Speech preferred)'); + 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 stratégie intelligente + /// Lire un texte à haute voix avec Google Cloud TTS static Future speak(String text) async { if (!_initialized) { await initialize(); } - // Si Web Speech a échoué plusieurs fois, utiliser directement Cloud TTS - if (!_webSpeechWorks || _webSpeechFailures >= _maxFailuresBeforeSwitch) { - return _speakWithCloudTTS(text); - } - - // Essayer Web Speech d'abord - try { - await _speakWithWebSpeech(text); - // Si succès, réinitialiser le compteur d'échecs - _webSpeechFailures = 0; - } catch (e) { - DebugLog.warning('[SmartTTS] Web Speech failed ($e), trying Cloud TTS...'); - _webSpeechFailures++; - - // Si trop d'échecs, basculer vers Cloud TTS par défaut - if (_webSpeechFailures >= _maxFailuresBeforeSwitch) { - DebugLog.info('[SmartTTS] Switching to Cloud TTS as primary'); - _webSpeechWorks = false; - } - - // Fallback vers Cloud TTS - await _speakWithCloudTTS(text); - } - } - - /// Utiliser Web Speech API - static Future _speakWithWebSpeech(String text) async { - DebugLog.info('[SmartTTS] → Trying Web Speech API'); - - // Timeout pour détecter si ça ne marche pas - await Future.any([ - TextToSpeechService.speak(text), - Future.delayed(const Duration(seconds: 3), () { - throw Exception('Web Speech timeout'); - }), - ]); - - DebugLog.info('[SmartTTS] ✓ Web Speech succeeded'); - } - - /// Utiliser Cloud TTS - static Future _speakWithCloudTTS(String text) async { - DebugLog.info('[SmartTTS] → Using Cloud TTS'); - 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); - - // En dernier recours, réessayer Web Speech - if (!_webSpeechWorks) { - DebugLog.info('[SmartTTS] Last resort: trying Web Speech again'); - await TextToSpeechService.speak(text); - } else { - rethrow; - } + rethrow; } } /// Arrêter toute lecture en cours static Future stop() async { try { - await TextToSpeechService.stop(); CloudTextToSpeechService.stopAll(); } catch (e) { DebugLog.error('[SmartTTS] Error stopping', e); @@ -112,48 +53,22 @@ class SmartTextToSpeechService { /// Vérifier si une lecture est en cours static Future isSpeaking() async { - try { - return await TextToSpeechService.isSpeaking(); - } catch (e) { - return false; - } - } - - /// Forcer l'utilisation de Cloud TTS (pour tests ou préférence utilisateur) - static void forceCloudTTS() { - DebugLog.info('[SmartTTS] Forced to use Cloud TTS'); - _webSpeechWorks = false; - _webSpeechFailures = _maxFailuresBeforeSwitch; - } - - /// Forcer l'utilisation de Web Speech (pour tests ou préférence utilisateur) - static void forceWebSpeech() { - DebugLog.info('[SmartTTS] Forced to use Web Speech'); - _webSpeechWorks = true; - _webSpeechFailures = 0; - } - - /// Réinitialiser la stratégie (utile pour tests) - static void resetStrategy() { - DebugLog.info('[SmartTTS] Strategy reset'); - _webSpeechWorks = true; - _webSpeechFailures = 0; + // 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 getStatus() { return { 'initialized': _initialized, - 'webSpeechWorks': _webSpeechWorks, - 'failures': _webSpeechFailures, - 'currentStrategy': _webSpeechWorks ? 'Web Speech (primary)' : 'Cloud TTS (primary)', + 'currentStrategy': 'Cloud TTS (exclusive)', }; } /// Nettoyer les ressources static Future dispose() async { try { - await TextToSpeechService.dispose(); CloudTextToSpeechService.clearCache(); } catch (e) { DebugLog.error('[SmartTTS] Error disposing', e); diff --git a/em2rp/lib/services/text_to_speech_service.dart b/em2rp/lib/services/text_to_speech_service.dart deleted file mode 100644 index 563105c..0000000 --- a/em2rp/lib/services/text_to_speech_service.dart +++ /dev/null @@ -1,273 +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 _cachedVoices = []; - static bool _isChromium = false; - - /// Initialiser le service TTS - static Future initialize() async { - if (_isInitialized) return; - - try { - _isInitialized = true; - - // Détecter si on est sur Chromium - final userAgent = web.window.navigator.userAgent.toLowerCase(); - _isChromium = userAgent.contains('chrome') && !userAgent.contains('edg'); - - if (_isChromium) { - DebugLog.info('[TextToSpeechService] Chromium detected - applying workarounds'); - } - - final synthesis = web.window.speechSynthesis; - - // WORKAROUND CHROMIUM: Forcer le chargement des voix avec un speak/cancel - if (_isChromium) { - try { - final dummy = web.SpeechSynthesisUtterance(''); - synthesis.speak(dummy); - synthesis.cancel(); - DebugLog.info('[TextToSpeechService] Chromium voice loading triggered'); - } catch (e) { - DebugLog.warning('[TextToSpeechService] Chromium workaround failed: $e'); - } - } - - // 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 _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 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 = []; - 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'); - - // WORKAROUND CHROMIUM: Appeler resume() immédiatement après speak() - // Ceci est nécessaire sur Chromium/Linux pour que le TTS démarre réellement - if (_isChromium) { - await Future.delayed(const Duration(milliseconds: 100)); - synthesis.resume(); - DebugLog.info('[TextToSpeechService] Chromium resume() called'); - } - } catch (e) { - DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e); - } - } - - /// Arrêter la lecture en cours - static Future 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 isSpeaking() async { - try { - return web.window.speechSynthesis.speaking; - } catch (e) { - return false; - } - } - - /// Nettoyer les ressources - static Future dispose() async { - try { - web.window.speechSynthesis.cancel(); - } catch (e) { - DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e); - } - } -} diff --git a/em2rp/lib/views/data_management_page.dart b/em2rp/lib/views/data_management_page.dart index 98e7228..808bbf9 100644 --- a/em2rp/lib/views/data_management_page.dart +++ b/em2rp/lib/views/data_management_page.dart @@ -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/options_management.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/custom_app_bar.dart'; import 'package:em2rp/utils/permission_gate.dart'; @@ -32,6 +33,23 @@ class _DataManagementPageState extends State { icon: Icons.file_download, 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 @@ -143,7 +161,7 @@ class _DataManagementPageState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: AppColors.rouge.withOpacity(0.1), + color: AppColors.rouge.withValues(alpha: 0.1), ), child: Row( children: [ @@ -177,7 +195,7 @@ class _DataManagementPageState extends State { ), ), selected: isSelected, - selectedTileColor: AppColors.rouge.withOpacity(0.1), + selectedTileColor: AppColors.rouge.withValues(alpha: 0.1), onTap: () => setState(() => _selectedIndex = index), ); }, diff --git a/em2rp/lib/views/event_statistics_page.dart b/em2rp/lib/views/event_statistics_page.dart new file mode 100644 index 0000000..6538a75 --- /dev/null +++ b/em2rp/lib/views/event_statistics_page.dart @@ -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(), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart index 146e38d..a2e8dcb 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart @@ -7,8 +7,9 @@ import 'package:em2rp/views/event_add_page.dart'; import 'package:em2rp/services/ics_export_service.dart'; import 'package:em2rp/services/data_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:js_interop'; class EventDetailsHeader extends StatefulWidget { final EventModel event; @@ -180,12 +181,13 @@ class _EventDetailsHeaderState extends State { // Créer un blob et télécharger le fichier final bytes = utf8.encode(icsContent); - final blob = html.Blob([bytes], 'text/calendar'); - final url = html.Url.createObjectUrlFromBlob(blob); - html.AnchorElement(href: url) - ..setAttribute('download', fileName) - ..click(); - html.Url.revokeObjectUrl(url); + final blob = web.Blob([bytes.toJS].toJS, web.BlobPropertyBag(type: 'text/calendar')); + final url = web.URL.createObjectURL(blob); + final anchor = web.document.createElement('a') as web.HTMLAnchorElement; + anchor.href = url; + anchor.download = fileName; + anchor.click(); + web.URL.revokeObjectURL(url); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/em2rp/lib/views/widgets/data_management/event_statistics_tab.dart b/em2rp/lib/views/widgets/data_management/event_statistics_tab.dart new file mode 100644 index 0000000..763d479 --- /dev/null +++ b/em2rp/lib/views/widgets/data_management/event_statistics_tab.dart @@ -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 createState() => _EventStatisticsTabState(); +} + +class _EventStatisticsTabState extends State { + 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 _selectedEventTypeIds = {}; + final Set _selectedStatuses = { + EventStatus.confirmed, + EventStatus.waitingForApproval, + }; + _AmountDisplayMode _amountDisplayMode = _AmountDisplayMode.ht; + + bool _isLoading = true; + String? _errorMessage; + + List _events = []; + Map _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 _loadStatistics() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final localUserProvider = + Provider.of(context, listen: false); + final userId = localUserProvider.uid; + + final results = await Future.wait([ + _dataService.getEvents(userId: userId), + _dataService.getEventTypes(), + ]); + + final eventsResult = results[0] as Map; + final eventTypesResult = results[1] as List>; + final eventsData = eventsResult['events'] as List>; + + final parsedEvents = []; + 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 = {}; + 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 _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, + }); +} + diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index 4d826b4..0383364 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:em2rp/views/widgets/image/profile_picture.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/utils/permission_gate.dart'; +import 'package:em2rp/views/event_statistics_page.dart'; class MainDrawer extends StatelessWidget { 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( data: const ExpansionTileThemeData( iconColor: AppColors.noir, diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index 43907e8..ba728d4 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: # UI Core cupertino_icons: ^1.0.2 - google_fonts: ^6.1.0 + google_fonts: ^8.0.2 flutter_svg: ^2.2.1 cached_network_image: ^3.3.1 flutter_slidable: ^4.0.0 @@ -37,7 +37,7 @@ dependencies: # Storage & Files path_provider: ^2.1.2 - flutter_secure_storage: ^9.0.0 + flutter_secure_storage: ^10.0.0 file_picker: ^10.1.9 image_picker: ^1.1.2 flutter_dropzone: ^4.2.1 @@ -47,7 +47,7 @@ dependencies: pdf: ^3.10.7 printing: ^5.11.1 qr_flutter: ^4.1.0 - mobile_scanner: ^5.2.3 + mobile_scanner: ^7.2.0 # Network & API http: ^1.1.2 @@ -59,7 +59,7 @@ dependencies: share_plus: ^12.0.1 # Notifications - flutter_local_notifications: ^19.2.1 + flutter_local_notifications: ^20.1.0 # Export/Import diff --git a/em2rp/web/version.json b/em2rp/web/version.json index b6ec3c5..066e2f3 100644 --- a/em2rp/web/version.json +++ b/em2rp/web/version.json @@ -1,7 +1,7 @@ { - "version": "1.1.14", + "version": "1.1.17", "updateUrl": "https://app.em2events.fr", "forceUpdate": true, - "releaseNotes": "Ajout de la gestion des maintenance et synthèse vocale", - "timestamp": "2026-03-03T10:13:12.014Z" + "releaseNotes": "Ajout d'une page de statistiques détaillées pour les équipements et les événements.", + "timestamp": "2026-03-12T14:00:20.817Z" } \ No newline at end of file