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
|
||||
class AppVersion {
|
||||
static const String version = '1.0.9';
|
||||
static const String version = '1.1.4';
|
||||
|
||||
/// Retourne la version complète de l'application
|
||||
static String get fullVersion => 'v$version';
|
||||
|
||||
@@ -28,6 +28,8 @@ import 'package:provider/provider.dart';
|
||||
import 'providers/local_user_provider.dart';
|
||||
import 'views/reset_password_page.dart';
|
||||
import 'config/env.dart';
|
||||
import 'services/update_service.dart';
|
||||
import 'views/widgets/common/update_dialog.dart';
|
||||
import 'config/api_config.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'views/widgets/common/update_dialog.dart';
|
||||
@@ -98,26 +100,25 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UpdateChecker(
|
||||
child: MaterialApp(
|
||||
title: 'EM2 Hub',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.red,
|
||||
primaryColor: AppColors.noir,
|
||||
colorScheme:
|
||||
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
|
||||
textTheme: const TextTheme(
|
||||
bodyMedium: TextStyle(color: AppColors.noir),
|
||||
return MaterialApp(
|
||||
title: 'EM2 Hub',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.red,
|
||||
primaryColor: AppColors.noir,
|
||||
colorScheme:
|
||||
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
|
||||
textTheme: const TextTheme(
|
||||
bodyMedium: TextStyle(color: AppColors.noir),
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.noir),
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.noir),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.gris),
|
||||
),
|
||||
labelStyle: TextStyle(color: AppColors.noir),
|
||||
hintStyle: TextStyle(color: AppColors.gris),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: AppColors.gris),
|
||||
),
|
||||
labelStyle: TextStyle(color: AppColors.noir),
|
||||
hintStyle: TextStyle(color: AppColors.gris),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -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
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_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 {
|
||||
PerformanceMonitor.start('App.autoLogin');
|
||||
try {
|
||||
|
||||
@@ -80,9 +80,23 @@ class UpdateService {
|
||||
/// Force le rechargement de l'application (vide le cache)
|
||||
static Future<void> reloadApp() async {
|
||||
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;
|
||||
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 {
|
||||
// --- É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(
|
||||
emailController.text,
|
||||
passwordController.text,
|
||||
);
|
||||
|
||||
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
|
||||
await localAuthProvider.loadUserData();
|
||||
|
||||
// Vérifier si le contexte est toujours valide
|
||||
if (context.mounted) {
|
||||
// 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! < -200) {
|
||||
// Swipe gauche : mois suivant
|
||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
_focusedDay = newMonth;
|
||||
});
|
||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||
_loadCurrentMonthEvents();
|
||||
} else if (details.primaryVelocity! > 200) {
|
||||
// Swipe droite : mois précédent
|
||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
_focusedDay = newMonth;
|
||||
});
|
||||
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! < -200) {
|
||||
// Swipe gauche : mois suivant
|
||||
final newMonth = DateTime(
|
||||
_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
setState(() {
|
||||
_focusedDay = DateTime(
|
||||
_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
_focusedDay = newMonth;
|
||||
});
|
||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||
_loadCurrentMonthEvents();
|
||||
} else if (details.primaryVelocity! > 200) {
|
||||
// Swipe droite : mois précédent
|
||||
final newMonth = DateTime(
|
||||
_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
setState(() {
|
||||
_focusedDay = DateTime(
|
||||
_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
_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,
|
||||
color: AppColors.rouge, size: 28),
|
||||
onPressed: () {
|
||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
_focusedDay = newMonth;
|
||||
});
|
||||
print('[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
|
||||
_loadCurrentMonthEvents();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
@@ -588,10 +600,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
icon: const Icon(Icons.chevron_right,
|
||||
color: AppColors.rouge, size: 28),
|
||||
onPressed: () {
|
||||
final newMonth = DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
_focusedDay = newMonth;
|
||||
});
|
||||
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/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/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/utils/permission_gate.dart';
|
||||
@@ -26,6 +27,11 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
||||
icon: Icons.tune,
|
||||
widget: const OptionsManagement(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Exporter les événements',
|
||||
icon: Icons.file_download,
|
||||
widget: const EventsExport(),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -814,6 +814,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
|
||||
// Effacer le champ après traitement
|
||||
_manualCodeController.clear();
|
||||
|
||||
// Maintenir le focus sur le champ pour permettre une saisie continue
|
||||
_manualCodeFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
path: any
|
||||
csv: ^6.0.0
|
||||
web: ^1.1.1
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "1.0.9",
|
||||
"version": "1.1.4",
|
||||
"updateUrl": "https://app.em2events.fr",
|
||||
"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.",
|
||||
"timestamp": "2026-02-07T15:35:30.790Z"
|
||||
"timestamp": "2026-02-13T17:07:39.024Z"
|
||||
}
|
||||
Reference in New Issue
Block a user