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:
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
|
||||||
|
## 12/03/2026
|
||||||
|
Ajout d'une page de statistiques détaillées pour les équipements et les événements.
|
||||||
|
|
||||||
## 10/03/2026
|
## 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
|
## 24/02/2026
|
||||||
Ajout de la gestion des maintenance et synthèse vocale
|
Ajout de la gestion des maintenance et synthèse vocale
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '1.1.14';
|
static const String version = '1.1.17';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:em2rp/views/maintenance_management_page.dart';
|
|||||||
import 'package:em2rp/views/container_form_page.dart';
|
import 'package:em2rp/views/container_form_page.dart';
|
||||||
import 'package:em2rp/views/container_detail_page.dart';
|
import 'package:em2rp/views/container_detail_page.dart';
|
||||||
import 'package:em2rp/views/event_preparation_page.dart';
|
import 'package:em2rp/views/event_preparation_page.dart';
|
||||||
|
import 'package:em2rp/views/event_statistics_page.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
@@ -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/services/cloud_text_to_speech_service.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
/// Service hybride intelligent pour le Text-to-Speech
|
/// Service de synthèse vocale utilisant exclusivement Google Cloud TTS
|
||||||
/// Essaie d'abord Web Speech API (gratuit, rapide), puis fallback vers Cloud TTS
|
/// Garantit une qualité et une compatibilité maximales sur tous les navigateurs
|
||||||
class SmartTextToSpeechService {
|
class SmartTextToSpeechService {
|
||||||
static bool _initialized = false;
|
static bool _initialized = false;
|
||||||
static bool _webSpeechWorks = true; // Optimiste par défaut
|
|
||||||
static int _webSpeechFailures = 0;
|
|
||||||
static const int _maxFailuresBeforeSwitch = 2;
|
|
||||||
|
|
||||||
/// Initialiser le service
|
/// Initialiser le service
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DebugLog.info('[SmartTTS] Initializing...');
|
DebugLog.info('[SmartTTS] Initializing Cloud TTS only...');
|
||||||
|
|
||||||
// Initialiser Web Speech API
|
// Pré-charger les phrases courantes pour Cloud TTS
|
||||||
await TextToSpeechService.initialize();
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
|
||||||
// Pré-charger les phrases courantes pour Cloud TTS en arrière-plan
|
|
||||||
// (ne bloque pas l'initialisation)
|
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
|
||||||
CloudTextToSpeechService.preloadCommonPhrases();
|
CloudTextToSpeechService.preloadCommonPhrases();
|
||||||
});
|
});
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
DebugLog.info('[SmartTTS] ✓ Initialized (Web Speech preferred)');
|
DebugLog.info('[SmartTTS] ✓ Initialized (Cloud TTS only)');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[SmartTTS] Initialization error', e);
|
DebugLog.error('[SmartTTS] Initialization error', e);
|
||||||
_initialized = true; // Continuer quand même
|
_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 {
|
static Future<void> speak(String text) async {
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
await initialize();
|
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 {
|
try {
|
||||||
|
DebugLog.info('[SmartTTS] → Using Cloud TTS');
|
||||||
await CloudTextToSpeechService.speak(text);
|
await CloudTextToSpeechService.speak(text);
|
||||||
DebugLog.info('[SmartTTS] ✓ Cloud TTS succeeded');
|
DebugLog.info('[SmartTTS] ✓ Cloud TTS succeeded');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[SmartTTS] ✗ Cloud TTS failed', e);
|
DebugLog.error('[SmartTTS] ✗ Cloud TTS failed', e);
|
||||||
|
rethrow;
|
||||||
// En dernier recours, réessayer Web Speech
|
|
||||||
if (!_webSpeechWorks) {
|
|
||||||
DebugLog.info('[SmartTTS] Last resort: trying Web Speech again');
|
|
||||||
await TextToSpeechService.speak(text);
|
|
||||||
} else {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrêter toute lecture en cours
|
/// Arrêter toute lecture en cours
|
||||||
static Future<void> stop() async {
|
static Future<void> stop() async {
|
||||||
try {
|
try {
|
||||||
await TextToSpeechService.stop();
|
|
||||||
CloudTextToSpeechService.stopAll();
|
CloudTextToSpeechService.stopAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[SmartTTS] Error stopping', e);
|
DebugLog.error('[SmartTTS] Error stopping', e);
|
||||||
@@ -112,48 +53,22 @@ class SmartTextToSpeechService {
|
|||||||
|
|
||||||
/// Vérifier si une lecture est en cours
|
/// Vérifier si une lecture est en cours
|
||||||
static Future<bool> isSpeaking() async {
|
static Future<bool> isSpeaking() async {
|
||||||
try {
|
// Cloud TTS n'a pas de méthode native pour vérifier le statut
|
||||||
return await TextToSpeechService.isSpeaking();
|
// Retourner false par défaut (peut être amélioré si nécessaire)
|
||||||
} catch (e) {
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Obtenir le statut actuel
|
/// Obtenir le statut actuel
|
||||||
static Map<String, dynamic> getStatus() {
|
static Map<String, dynamic> getStatus() {
|
||||||
return {
|
return {
|
||||||
'initialized': _initialized,
|
'initialized': _initialized,
|
||||||
'webSpeechWorks': _webSpeechWorks,
|
'currentStrategy': 'Cloud TTS (exclusive)',
|
||||||
'failures': _webSpeechFailures,
|
|
||||||
'currentStrategy': _webSpeechWorks ? 'Web Speech (primary)' : 'Cloud TTS (primary)',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nettoyer les ressources
|
/// Nettoyer les ressources
|
||||||
static Future<void> dispose() async {
|
static Future<void> dispose() async {
|
||||||
try {
|
try {
|
||||||
await TextToSpeechService.dispose();
|
|
||||||
CloudTextToSpeechService.clearCache();
|
CloudTextToSpeechService.clearCache();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[SmartTTS] Error disposing', 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/event_types_management.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
@@ -32,6 +33,23 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
icon: Icons.file_download,
|
icon: Icons.file_download,
|
||||||
widget: const EventsExport(),
|
widget: const EventsExport(),
|
||||||
),
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Statistiques evenements',
|
||||||
|
icon: Icons.bar_chart,
|
||||||
|
widget: const PermissionGate(
|
||||||
|
requiredPermissions: ['generate_reports'],
|
||||||
|
fallback: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Vous n\'avez pas les permissions necessaires pour voir les statistiques.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: EventStatisticsTab(),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -143,7 +161,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.rouge.withOpacity(0.1),
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -177,7 +195,7 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
selectedTileColor: AppColors.rouge.withOpacity(0.1),
|
selectedTileColor: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
onTap: () => setState(() => _selectedIndex = index),
|
onTap: () => setState(() => _selectedIndex = index),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
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/ics_export_service.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'dart:html' as html;
|
import 'package:web/web.dart' as web;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
|
||||||
class EventDetailsHeader extends StatefulWidget {
|
class EventDetailsHeader extends StatefulWidget {
|
||||||
final EventModel event;
|
final EventModel event;
|
||||||
@@ -180,12 +181,13 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
|
|||||||
|
|
||||||
// Créer un blob et télécharger le fichier
|
// Créer un blob et télécharger le fichier
|
||||||
final bytes = utf8.encode(icsContent);
|
final bytes = utf8.encode(icsContent);
|
||||||
final blob = html.Blob([bytes], 'text/calendar');
|
final blob = web.Blob([bytes.toJS].toJS, web.BlobPropertyBag(type: 'text/calendar'));
|
||||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
final url = web.URL.createObjectURL(blob);
|
||||||
html.AnchorElement(href: url)
|
final anchor = web.document.createElement('a') as web.HTMLAnchorElement;
|
||||||
..setAttribute('download', fileName)
|
anchor.href = url;
|
||||||
..click();
|
anchor.download = fileName;
|
||||||
html.Url.revokeObjectUrl(url);
|
anchor.click();
|
||||||
|
web.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -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:em2rp/views/widgets/image/profile_picture.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
|
import 'package:em2rp/views/event_statistics_page.dart';
|
||||||
|
|
||||||
class MainDrawer extends StatelessWidget {
|
class MainDrawer extends StatelessWidget {
|
||||||
final String currentPage;
|
final String currentPage;
|
||||||
@@ -132,6 +133,24 @@ class MainDrawer extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PermissionGate(
|
||||||
|
requiredPermissions: const ['generate_reports'],
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.bar_chart),
|
||||||
|
title: const Text('Statistiques evenements'),
|
||||||
|
selected: currentPage == '/event_statistics',
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const EventStatisticsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ExpansionTileTheme(
|
ExpansionTileTheme(
|
||||||
data: const ExpansionTileThemeData(
|
data: const ExpansionTileThemeData(
|
||||||
iconColor: AppColors.noir,
|
iconColor: AppColors.noir,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ dependencies:
|
|||||||
|
|
||||||
# UI Core
|
# UI Core
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
google_fonts: ^6.1.0
|
google_fonts: ^8.0.2
|
||||||
flutter_svg: ^2.2.1
|
flutter_svg: ^2.2.1
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
flutter_slidable: ^4.0.0
|
flutter_slidable: ^4.0.0
|
||||||
@@ -37,7 +37,7 @@ dependencies:
|
|||||||
|
|
||||||
# Storage & Files
|
# Storage & Files
|
||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
file_picker: ^10.1.9
|
file_picker: ^10.1.9
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
flutter_dropzone: ^4.2.1
|
flutter_dropzone: ^4.2.1
|
||||||
@@ -47,7 +47,7 @@ dependencies:
|
|||||||
pdf: ^3.10.7
|
pdf: ^3.10.7
|
||||||
printing: ^5.11.1
|
printing: ^5.11.1
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
mobile_scanner: ^5.2.3
|
mobile_scanner: ^7.2.0
|
||||||
|
|
||||||
# Network & API
|
# Network & API
|
||||||
http: ^1.1.2
|
http: ^1.1.2
|
||||||
@@ -59,7 +59,7 @@ dependencies:
|
|||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.2.1
|
flutter_local_notifications: ^20.1.0
|
||||||
|
|
||||||
|
|
||||||
# Export/Import
|
# Export/Import
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.14",
|
"version": "1.1.17",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"forceUpdate": true,
|
||||||
"releaseNotes": "Ajout de la gestion des maintenance et synthèse vocale",
|
"releaseNotes": "Ajout d'une page de statistiques détaillées pour les équipements et les événements.",
|
||||||
"timestamp": "2026-03-03T10:13:12.014Z"
|
"timestamp": "2026-03-12T14:00:20.817Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user