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

- Mise à jour de la version de l'application à `1.1.17` dans `app_version.dart` et `version.json`.
- Création d'un module complet de statistiques (`EventStatisticsPage`, `EventStatisticsService`, `EventStatisticsTab`) permettant de filtrer et visualiser les KPI d'événements (montants HT/TTC, panier moyen, répartition par type, top options).
- Ajout d'une entrée "Statistiques événements" dans le menu latéral (`MainDrawer`) protégée par la permission `generate_reports`.
- Migration exclusive vers Google Cloud TTS dans `SmartTextToSpeechService` et suppression de `TextToSpeechService` (Web Speech API native) pour garantir une compatibilité maximale sur tous les navigateurs.
- Mise à jour des dépendances dans `pubspec.yaml` (`google_fonts`, `flutter_secure_storage`, `mobile_scanner`, `flutter_local_notifications`).
- Migration du code d'export ICS vers `package:web` pour remplacer l'utilisation de `dart:html` obsolète.
- Mise à jour du `CHANGELOG.md` documentant les statistiques et l'évolution du service de synthèse vocale.
This commit is contained in:
ElPoyo
2026-03-12 15:05:28 +01:00
parent afa2c35c90
commit 6737ad80e4
14 changed files with 1236 additions and 389 deletions

View File

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

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

View File

@@ -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(

View File

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

View File

@@ -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,