feat: mise à jour v1.1.17 et ajout du tableau de bord des statistiques d'événements

- 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.
This commit is contained in:
ElPoyo
2026-03-12 15:05:28 +01:00
parent afa2c35c90
commit 6737ad80e4
14 changed files with 1236 additions and 389 deletions

View 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,
});
}

View File

@@ -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<void> 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<void> 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<void> _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<void> _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<void> 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<bool> 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<String, dynamic> 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<void> dispose() async {
try {
await TextToSpeechService.dispose();
CloudTextToSpeechService.clearCache();
} catch (e) {
DebugLog.error('[SmartTTS] Error disposing', e);

View File

@@ -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<web.SpeechSynthesisVoice> _cachedVoices = [];
static bool _isChromium = false;
/// Initialiser le service TTS
static Future<void> 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<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');
// 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<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);
}
}
}