feat: Ajout de l'exportation des événements au format CSV avec filtres personnalisables
This commit is contained in:
@@ -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.0.9';
|
static const String version = '1.1.4';
|
||||||
|
|
||||||
/// 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';
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import 'package:provider/provider.dart';
|
|||||||
import 'providers/local_user_provider.dart';
|
import 'providers/local_user_provider.dart';
|
||||||
import 'views/reset_password_page.dart';
|
import 'views/reset_password_page.dart';
|
||||||
import 'config/env.dart';
|
import 'config/env.dart';
|
||||||
|
import 'services/update_service.dart';
|
||||||
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
import 'config/api_config.dart';
|
import 'config/api_config.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'views/widgets/common/update_dialog.dart';
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
@@ -98,8 +100,7 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return UpdateChecker(
|
return MaterialApp(
|
||||||
child: MaterialApp(
|
|
||||||
title: 'EM2 Hub',
|
title: 'EM2 Hub',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
primarySwatch: Colors.red,
|
primarySwatch: Colors.red,
|
||||||
@@ -184,7 +185,6 @@ class MyApp extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,9 +203,38 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
// Attendre la fin du premier build avant de naviguer
|
// Attendre la fin du premier build avant de naviguer
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_autoLogin();
|
_autoLogin();
|
||||||
|
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
|
||||||
|
_checkForUpdateDelayed();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vérifie les mises à jour après un délai
|
||||||
|
Future<void> _checkForUpdateDelayed() async {
|
||||||
|
try {
|
||||||
|
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final updateInfo = await UpdateService.checkForUpdate();
|
||||||
|
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
// Attendre encore un peu pour être sûr que le bon contexte est disponible
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: !updateInfo.forceUpdate,
|
||||||
|
builder: (context) => UpdateDialog(updateInfo: updateInfo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[AutoLoginWrapper] Error checking for update: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _autoLogin() async {
|
Future<void> _autoLogin() async {
|
||||||
PerformanceMonitor.start('App.autoLogin');
|
PerformanceMonitor.start('App.autoLogin');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -80,9 +80,23 @@ class UpdateService {
|
|||||||
/// Force le rechargement de l'application (vide le cache)
|
/// Force le rechargement de l'application (vide le cache)
|
||||||
static Future<void> reloadApp() async {
|
static Future<void> reloadApp() async {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
// Pour le web, recharger la page en utilisant JavaScript
|
// Pour le web, recharger la page en vidant le cache
|
||||||
|
// Utiliser window.location.reload(true) force un rechargement depuis le serveur
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Reloading app...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// On utilise launchUrl avec le mode _self pour recharger dans la même fenêtre
|
||||||
|
// Le paramètre de cache-busting garantit un nouveau chargement
|
||||||
final url = Uri.base;
|
final url = Uri.base;
|
||||||
await launchUrl(url, webOnlyWindowName: '_self');
|
final reloadUrl = url.replace(
|
||||||
|
queryParameters: {
|
||||||
|
...url.queryParameters,
|
||||||
|
'_reload': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await launchUrl(reloadUrl, webOnlyWindowName: '_self');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
em2rp/lib/utils/web_download.dart
Normal file
7
em2rp/lib/utils/web_download.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// Fichier d'export conditionnel pour le téléchargement web
|
||||||
|
/// Utilise l'implémentation web sur le web, et le stub sur les autres plateformes
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'web_download_stub.dart'
|
||||||
|
if (dart.library.js_interop) 'web_download_web.dart';
|
||||||
|
|
||||||
6
em2rp/lib/utils/web_download_stub.dart
Normal file
6
em2rp/lib/utils/web_download_stub.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// Stub pour le téléchargement web
|
||||||
|
/// Utilisé sur les plateformes non-web (mobile, desktop)
|
||||||
|
void downloadFile(String content, String fileName) {
|
||||||
|
throw UnsupportedError('Le téléchargement web n\'est pas supporté sur cette plateforme');
|
||||||
|
}
|
||||||
|
|
||||||
29
em2rp/lib/utils/web_download_web.dart
Normal file
29
em2rp/lib/utils/web_download_web.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
/// Implémentation web du téléchargement de fichier
|
||||||
|
void downloadFile(String content, String fileName) {
|
||||||
|
final bytes = Uint8List.fromList(utf8.encode(content));
|
||||||
|
|
||||||
|
// Créer un Blob avec les données
|
||||||
|
final blob = web.Blob(
|
||||||
|
[bytes.toJS].toJS,
|
||||||
|
web.BlobPropertyBag(type: 'text/csv;charset=utf-8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Créer une URL pour le blob
|
||||||
|
final url = web.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Créer un lien de téléchargement et le cliquer
|
||||||
|
final anchor = web.document.createElement('a') as web.HTMLAnchorElement;
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
|
||||||
|
// Nettoyer l'URL
|
||||||
|
web.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +28,14 @@ class LoginViewModel extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// --- Étape 1: Connecter l'utilisateur dans Firebase Auth ---
|
// --- Étape 1: Connecter l'utilisateur dans Firebase Auth ---
|
||||||
// Appelle la méthode du provider qui gère la connexion Auth ET le chargement des données utilisateur
|
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
await localAuthProvider.signInWithEmailAndPassword(
|
||||||
emailController.text,
|
emailController.text,
|
||||||
passwordController.text,
|
passwordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
|
||||||
|
await localAuthProvider.loadUserData();
|
||||||
|
|
||||||
// Vérifier si le contexte est toujours valide
|
// Vérifier si le contexte est toujours valide
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
||||||
|
|||||||
@@ -341,16 +341,20 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -200) {
|
if (details.primaryVelocity! < -200) {
|
||||||
// Swipe gauche : mois suivant
|
// Swipe gauche : mois suivant
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -376,16 +380,22 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
if (details.primaryVelocity != null) {
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -200) {
|
if (details.primaryVelocity! < -200) {
|
||||||
// Swipe gauche : mois suivant
|
// Swipe gauche : mois suivant
|
||||||
setState(() {
|
final newMonth = DateTime(
|
||||||
_focusedDay = DateTime(
|
|
||||||
_focusedDay.year, _focusedDay.month + 1, 1);
|
_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
|
setState(() {
|
||||||
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
} else if (details.primaryVelocity! > 200) {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
// Swipe droite : mois précédent
|
// Swipe droite : mois précédent
|
||||||
setState(() {
|
final newMonth = DateTime(
|
||||||
_focusedDay = DateTime(
|
|
||||||
_focusedDay.year, _focusedDay.month - 1, 1);
|
_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
|
setState(() {
|
||||||
|
_focusedDay = newMonth;
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -547,10 +557,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_left,
|
icon: const Icon(Icons.chevron_left,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -588,10 +600,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
icon: const Icon(Icons.chevron_right,
|
icon: const Icon(Icons.chevron_right,
|
||||||
color: AppColors.rouge, size: 28),
|
color: AppColors.rouge, size: 28),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay =
|
_focusedDay = newMonth;
|
||||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
|
||||||
});
|
});
|
||||||
|
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||||
|
_loadCurrentMonthEvents();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:em2rp/utils/colors.dart';
|
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/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';
|
||||||
@@ -26,6 +27,11 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
widget: const OptionsManagement(),
|
widget: const OptionsManagement(),
|
||||||
),
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Exporter les événements',
|
||||||
|
icon: Icons.file_download,
|
||||||
|
widget: const EventsExport(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -814,6 +814,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
|
|
||||||
// Effacer le champ après traitement
|
// Effacer le champ après traitement
|
||||||
_manualCodeController.clear();
|
_manualCodeController.clear();
|
||||||
|
|
||||||
|
// Maintenir le focus sur le champ pour permettre une saisie continue
|
||||||
|
_manualCodeFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Obtenir les quantités actuelles selon l'étape
|
/// Obtenir les quantités actuelles selon l'étape
|
||||||
|
|||||||
655
em2rp/lib/views/widgets/data_management/events_export.dart
Normal file
655
em2rp/lib/views/widgets/data_management/events_export.dart
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:csv/csv.dart';
|
||||||
|
import 'package:universal_io/io.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:em2rp/utils/web_download.dart' as web_download;
|
||||||
|
|
||||||
|
|
||||||
|
class EventsExport extends StatefulWidget {
|
||||||
|
const EventsExport({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventsExport> createState() => _EventsExportState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventsExportState extends State<EventsExport> {
|
||||||
|
late final DataService _dataService;
|
||||||
|
|
||||||
|
// Filtres
|
||||||
|
DateTime? _startDate;
|
||||||
|
DateTime? _endDate;
|
||||||
|
final List<String> _selectedEventTypeIds = [];
|
||||||
|
final List<String> _selectedStatuses = [];
|
||||||
|
|
||||||
|
// Options disponibles
|
||||||
|
List<Map<String, dynamic>> _eventTypes = [];
|
||||||
|
final List<String> _availableStatuses = [
|
||||||
|
'CONFIRMED',
|
||||||
|
'WAITING_FOR_APPROVAL',
|
||||||
|
'CANCELED',
|
||||||
|
];
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _loadingEventTypes = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
_loadEventTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadEventTypes() async {
|
||||||
|
setState(() => _loadingEventTypes = true);
|
||||||
|
try {
|
||||||
|
final eventTypesData = await _dataService.getEventTypes();
|
||||||
|
setState(() {
|
||||||
|
_eventTypes = eventTypesData;
|
||||||
|
_loadingEventTypes = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur lors du chargement des types d\'événements: $e';
|
||||||
|
_loadingEventTypes = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getStatusLabel(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'CONFIRMED':
|
||||||
|
return 'Validé';
|
||||||
|
case 'WAITING_FOR_APPROVAL':
|
||||||
|
return 'En attente';
|
||||||
|
case 'CANCELED':
|
||||||
|
return 'Annulé';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportEvents() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer tous les événements via l'API
|
||||||
|
final result = await _dataService.getEvents();
|
||||||
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Parser les événements
|
||||||
|
List<EventModel> events = [];
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
events.add(event);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Erreur lors du parsing de l\'événement ${eventData['id']}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer les filtres
|
||||||
|
events = _applyFilters(events);
|
||||||
|
|
||||||
|
if (events.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Aucun événement ne correspond aux critères de filtrage.';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le CSV
|
||||||
|
final csv = await _generateCSV(events, usersData);
|
||||||
|
|
||||||
|
// Sauvegarder et partager le fichier
|
||||||
|
await _saveAndShareCSV(csv);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Export réussi : ${events.length} événement(s) exporté(s)'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur lors de l\'export: $e';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EventModel> _applyFilters(List<EventModel> events) {
|
||||||
|
return events.where((event) {
|
||||||
|
// Filtre par date
|
||||||
|
if (_startDate != null && event.endDateTime.isBefore(_startDate!)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_endDate != null && event.startDateTime.isAfter(_endDate!)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par type d'événement
|
||||||
|
if (_selectedEventTypeIds.isNotEmpty &&
|
||||||
|
!_selectedEventTypeIds.contains(event.eventTypeId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par statut
|
||||||
|
if (_selectedStatuses.isNotEmpty &&
|
||||||
|
!_selectedStatuses.contains(eventStatusToString(event.status))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _generateCSV(
|
||||||
|
List<EventModel> events,
|
||||||
|
Map<String, dynamic> usersData,
|
||||||
|
) async {
|
||||||
|
final List<List<dynamic>> rows = [];
|
||||||
|
|
||||||
|
// En-têtes
|
||||||
|
rows.add([
|
||||||
|
'Titre',
|
||||||
|
'Type d\'événement',
|
||||||
|
'Statut',
|
||||||
|
'Description',
|
||||||
|
'Date début',
|
||||||
|
'Date fin',
|
||||||
|
'Durée montage (min)',
|
||||||
|
'Durée démontage (min)',
|
||||||
|
'Prix de base HT (€)',
|
||||||
|
'Prix de base TTC (€)',
|
||||||
|
'Options',
|
||||||
|
'Prix total HT (€)',
|
||||||
|
'Prix total TTC (€)',
|
||||||
|
'Workforce',
|
||||||
|
'Contact client',
|
||||||
|
'Jauge',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Récupérer les noms des types d'événements
|
||||||
|
final eventTypesMap = <String, String>{};
|
||||||
|
for (var eventType in _eventTypes) {
|
||||||
|
eventTypesMap[eventType['id']] = eventType['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
||||||
|
|
||||||
|
for (var event in events) {
|
||||||
|
// Calculer le prix total TTC (base + options)
|
||||||
|
final totalTTC = event.basePrice +
|
||||||
|
event.options.fold<num>(
|
||||||
|
0,
|
||||||
|
(sum, opt) {
|
||||||
|
final priceTTC = opt['price'] ?? 0.0;
|
||||||
|
final quantity = opt['quantity'] ?? 1;
|
||||||
|
return sum + (priceTTC * quantity);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculer les prix HT (TVA 20%)
|
||||||
|
final basePriceHT = event.basePrice / 1.20;
|
||||||
|
final totalHT = totalTTC / 1.20;
|
||||||
|
|
||||||
|
// Formatter les options
|
||||||
|
final optionsStr = event.options.isEmpty
|
||||||
|
? 'Aucune'
|
||||||
|
: event.options.map((opt) {
|
||||||
|
final name = opt['name'] ?? '';
|
||||||
|
final code = opt['code'] ?? '';
|
||||||
|
final details = opt['details'] ?? '';
|
||||||
|
final price = opt['price'] ?? 0.0;
|
||||||
|
final quantity = opt['quantity'] ?? 1;
|
||||||
|
|
||||||
|
final optName = code.isNotEmpty ? '$code - $name' : name;
|
||||||
|
final optDetails = details.isNotEmpty ? ' ($details)' : '';
|
||||||
|
final optPrice = (price * quantity).toStringAsFixed(2);
|
||||||
|
|
||||||
|
return '$optName$optDetails: $optPrice €';
|
||||||
|
}).join(' | ');
|
||||||
|
|
||||||
|
// Formatter la workforce
|
||||||
|
final workforceStr = event.workforce.isEmpty
|
||||||
|
? 'Aucun'
|
||||||
|
: event.workforce.map((worker) {
|
||||||
|
if (worker is String) {
|
||||||
|
// UID - récupérer le nom depuis usersData
|
||||||
|
final userData = usersData[worker] as Map<String, dynamic>?;
|
||||||
|
if (userData != null) {
|
||||||
|
final firstName = userData['firstName'] ?? '';
|
||||||
|
final lastName = userData['lastName'] ?? '';
|
||||||
|
return '$firstName $lastName'.trim();
|
||||||
|
}
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
return worker.toString();
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
// Formatter le contact
|
||||||
|
final contactParts = <String>[];
|
||||||
|
if (event.contactEmail != null && event.contactEmail!.isNotEmpty) {
|
||||||
|
contactParts.add(event.contactEmail!);
|
||||||
|
}
|
||||||
|
if (event.contactPhone != null && event.contactPhone!.isNotEmpty) {
|
||||||
|
contactParts.add(event.contactPhone!);
|
||||||
|
}
|
||||||
|
final contactStr = contactParts.isEmpty ? 'N/A' : contactParts.join(' | ');
|
||||||
|
|
||||||
|
rows.add([
|
||||||
|
event.name,
|
||||||
|
eventTypesMap[event.eventTypeId] ?? event.eventTypeId,
|
||||||
|
_getStatusLabel(eventStatusToString(event.status)),
|
||||||
|
event.description,
|
||||||
|
dateFormat.format(event.startDateTime),
|
||||||
|
dateFormat.format(event.endDateTime),
|
||||||
|
event.installationTime,
|
||||||
|
event.disassemblyTime,
|
||||||
|
basePriceHT.toStringAsFixed(2),
|
||||||
|
event.basePrice.toStringAsFixed(2),
|
||||||
|
optionsStr,
|
||||||
|
totalHT.toStringAsFixed(2),
|
||||||
|
totalTTC.toStringAsFixed(2),
|
||||||
|
workforceStr,
|
||||||
|
contactStr,
|
||||||
|
event.jauge?.toString() ?? 'N/A',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const ListToCsvConverter().convert(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAndShareCSV(String csvContent) async {
|
||||||
|
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||||
|
final fileName = 'evenements_export_$timestamp.csv';
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Sur le web, télécharger directement avec la fonction conditionnelle
|
||||||
|
web_download.downloadFile(csvContent, fileName);
|
||||||
|
} else {
|
||||||
|
// Sur mobile/desktop, utiliser share_plus
|
||||||
|
final directory = await getTemporaryDirectory();
|
||||||
|
final filePath = '${directory.path}/$fileName';
|
||||||
|
final file = File(filePath);
|
||||||
|
await file.writeAsString(csvContent);
|
||||||
|
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
await Share.shareXFiles(
|
||||||
|
[XFile(filePath)],
|
||||||
|
subject: 'Export des événements',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectDateRange() async {
|
||||||
|
final picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2030),
|
||||||
|
initialDateRange: _startDate != null && _endDate != null
|
||||||
|
? DateTimeRange(start: _startDate!, end: _endDate!)
|
||||||
|
: null,
|
||||||
|
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!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_startDate = picked.start;
|
||||||
|
_endDate = picked.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearDateRange() {
|
||||||
|
setState(() {
|
||||||
|
_startDate = null;
|
||||||
|
_endDate = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_loadingEventTypes) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// En-tête
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.file_download, color: AppColors.rouge, size: 32),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Exporter les événements',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Exportez les événements au format CSV avec filtres personnalisables',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Section des filtres
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildFiltersSection(),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
if (_error != null) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red[50],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bouton d'export
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _exportEvents,
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
label: Text(
|
||||||
|
_isLoading ? 'Export en cours...' : 'Exporter les événements',
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFiltersSection() {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_list, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Filtres',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
|
||||||
|
// Filtre par période
|
||||||
|
_buildDateRangeFilter(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Filtre par type d'événement
|
||||||
|
_buildEventTypeFilter(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Filtre par statut
|
||||||
|
_buildStatusFilter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDateRangeFilter() {
|
||||||
|
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Période',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _selectDateRange,
|
||||||
|
icon: const Icon(Icons.calendar_today),
|
||||||
|
label: Text(
|
||||||
|
_startDate != null && _endDate != null
|
||||||
|
? 'Du ${dateFormat.format(_startDate!)} au ${dateFormat.format(_endDate!)}'
|
||||||
|
: 'Toutes les périodes',
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.rouge,
|
||||||
|
side: BorderSide(color: AppColors.rouge),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_startDate != null && _endDate != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _clearDateRange,
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
color: Colors.grey,
|
||||||
|
tooltip: 'Effacer la période',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEventTypeFilter() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Types d\'événement',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
// Bouton "Tous"
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Tous'),
|
||||||
|
selected: _selectedEventTypeIds.isEmpty,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedEventTypeIds.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: _selectedEventTypeIds.isEmpty
|
||||||
|
? Colors.white
|
||||||
|
: AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Chips pour chaque type
|
||||||
|
..._eventTypes.map((eventType) {
|
||||||
|
final isSelected =
|
||||||
|
_selectedEventTypeIds.contains(eventType['id']);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(eventType['name']),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedEventTypeIds.add(eventType['id']);
|
||||||
|
} else {
|
||||||
|
_selectedEventTypeIds.remove(eventType['id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusFilter() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Statut de l\'événement',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
// Bouton "Tous"
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Tous'),
|
||||||
|
selected: _selectedStatuses.isEmpty,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedStatuses.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: _selectedStatuses.isEmpty ? Colors.white : AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Chips pour chaque statut
|
||||||
|
..._availableStatuses.map((status) {
|
||||||
|
final isSelected = _selectedStatuses.contains(status);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(_getStatusLabel(status)),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedStatuses.add(status);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.remove(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedColor: AppColors.rouge,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +61,8 @@ dependencies:
|
|||||||
audioplayers: ^6.1.0
|
audioplayers: ^6.1.0
|
||||||
|
|
||||||
path: any
|
path: any
|
||||||
|
csv: ^6.0.0
|
||||||
|
web: ^1.1.1
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.9",
|
"version": "1.1.4",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"forceUpdate": true,
|
||||||
"releaseNotes": "Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :\r\n\r\n* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.\r\n* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.",
|
"releaseNotes": "Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :\r\n\r\n* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.\r\n* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.",
|
||||||
"timestamp": "2026-02-07T15:35:30.790Z"
|
"timestamp": "2026-02-13T17:07:39.024Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user