feat: Ajout de l'exportation des événements au format CSV avec filtres personnalisables

This commit is contained in:
ElPoyo
2026-02-18 13:25:14 +01:00
parent 7cbb48e679
commit 5b9ca568f8
13 changed files with 806 additions and 38 deletions

View File

@@ -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';

View File

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

View File

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

View 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';

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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"
} }