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:
@@ -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';
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DataManagementPage> {
|
||||
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<DataManagementPage> {
|
||||
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<DataManagementPage> {
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedTileColor: AppColors.rouge.withOpacity(0.1),
|
||||
selectedTileColor: AppColors.rouge.withValues(alpha: 0.1),
|
||||
onTap: () => setState(() => _selectedIndex = index),
|
||||
);
|
||||
},
|
||||
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EventDetailsHeader> {
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user