diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index 71565f1..739e9ab 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -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'; diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 739bf0b..f89ee27 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -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 { // 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 _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 _autoLogin() async { PerformanceMonitor.start('App.autoLogin'); try { diff --git a/em2rp/lib/services/update_service.dart b/em2rp/lib/services/update_service.dart index 55d72f7..494c272 100644 --- a/em2rp/lib/services/update_service.dart +++ b/em2rp/lib/services/update_service.dart @@ -80,9 +80,23 @@ class UpdateService { /// Force le rechargement de l'application (vide le cache) static Future 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'); } } diff --git a/em2rp/lib/utils/web_download.dart b/em2rp/lib/utils/web_download.dart new file mode 100644 index 0000000..408f136 --- /dev/null +++ b/em2rp/lib/utils/web_download.dart @@ -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'; + diff --git a/em2rp/lib/utils/web_download_stub.dart b/em2rp/lib/utils/web_download_stub.dart new file mode 100644 index 0000000..53d82fb --- /dev/null +++ b/em2rp/lib/utils/web_download_stub.dart @@ -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'); +} + diff --git a/em2rp/lib/utils/web_download_web.dart b/em2rp/lib/utils/web_download_web.dart new file mode 100644 index 0000000..bdc1c4d --- /dev/null +++ b/em2rp/lib/utils/web_download_web.dart @@ -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); +} + + diff --git a/em2rp/lib/view_model/login_view_model.dart b/em2rp/lib/view_model/login_view_model.dart index bf1020c..e4c8283 100644 --- a/em2rp/lib/view_model/login_view_model.dart +++ b/em2rp/lib/view_model/login_view_model.dart @@ -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 diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index 4c9cf20..148840c 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -341,16 +341,20 @@ class _CalendarPageState extends State { 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 { 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 { 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 { 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(); }, ), ], diff --git a/em2rp/lib/views/data_management_page.dart b/em2rp/lib/views/data_management_page.dart index 2f4c6ef..5361968 100644 --- a/em2rp/lib/views/data_management_page.dart +++ b/em2rp/lib/views/data_management_page.dart @@ -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 { icon: Icons.tune, widget: const OptionsManagement(), ), + DataCategory( + title: 'Exporter les événements', + icon: Icons.file_download, + widget: const EventsExport(), + ), ]; @override diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart index 00c4cb8..34c75b6 100644 --- a/em2rp/lib/views/event_preparation_page.dart +++ b/em2rp/lib/views/event_preparation_page.dart @@ -814,6 +814,9 @@ class _EventPreparationPageState extends State 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 diff --git a/em2rp/lib/views/widgets/data_management/events_export.dart b/em2rp/lib/views/widgets/data_management/events_export.dart new file mode 100644 index 0000000..dc66f45 --- /dev/null +++ b/em2rp/lib/views/widgets/data_management/events_export.dart @@ -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 createState() => _EventsExportState(); +} + +class _EventsExportState extends State { + late final DataService _dataService; + + // Filtres + DateTime? _startDate; + DateTime? _endDate; + final List _selectedEventTypeIds = []; + final List _selectedStatuses = []; + + // Options disponibles + List> _eventTypes = []; + final List _availableStatuses = [ + 'CONFIRMED', + 'WAITING_FOR_APPROVAL', + 'CANCELED', + ]; + + bool _isLoading = false; + bool _loadingEventTypes = true; + String? _error; + + @override + void initState() { + super.initState(); + _dataService = DataService(FirebaseFunctionsApiService()); + _loadEventTypes(); + } + + Future _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 _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>; + final usersData = result['users'] as Map; + + // Parser les événements + List 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 _applyFilters(List 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 _generateCSV( + List events, + Map usersData, + ) async { + final List> 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 = {}; + 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( + 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?; + 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 = []; + 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 _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(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, + ), + ); + }), + ], + ), + ], + ); + } +} + + diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index b6b2c3a..0aa84f9 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -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 diff --git a/em2rp/web/version.json b/em2rp/web/version.json index 23146b9..c1ed6ac 100644 --- a/em2rp/web/version.json +++ b/em2rp/web/version.json @@ -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" } \ No newline at end of file